feat(theme): 管理后台新增模板选择器,支持运行时动态切换主题
Some checks failed
CI / Lint + Build (push) Has been cancelled

新增功能:
- 主题发现模块:扫描 templates/ 目录下含 theme.json 的目录
- 站点设置新增 site.theme 键,DB 持久化 + 内存缓存
- 运行时切换主题:保存后即时重载 Gin 模板 + 静态资源哈希
- 管理后台 '外观主题' 页面:列表展示、选择、一键切换
- 动态静态资源服务:根据当前主题目录提供 CSS/JS

修复:
- account.html 残留孤儿 {{end}} 模板语法错误
- 动态静态资源 handler 误判 Gin *filepath 路径前缀为绝对路径

变更文件(12 modified + 3 new):
- cmd/server/main.go: 动态主题加载 + 运行时重载 + 动态静态服务
- internal/theme/discovery.go: 新增主题扫描
- internal/config/site_settings.go: ThemeName()
- internal/service/site_setting_service.go: ThemeReloader + UpdateTheme
- internal/controller/admin/: ISP 扩展 + GetThemes/UpdateTheme/ThemePage
- internal/controller/auth_controller.go: 准则路径动态化
- internal/router/: InjectThemeReloader + 新路由
- templates/: 外观主题页面 + JS + CSS + 子导航
This commit is contained in:
2026-06-30 17:43:08 +08:00
parent 3d69dae799
commit e7092254ab
14 changed files with 426 additions and 18 deletions

View File

@ -3,4 +3,5 @@
<a href="/admin/settings/security" class="subnav-item {{if eq .ActiveSetting "security"}}active{{end}}">安全与维护</a>
<a href="/admin/settings/registration" class="subnav-item {{if eq .ActiveSetting "registration"}}active{{end}}">注册策略</a>
<a href="/admin/settings/content" class="subnav-item {{if eq .ActiveSetting "content"}}active{{end}}">审核与内容</a>
<a href="/admin/settings/theme" class="subnav-item {{if eq .ActiveSetting "theme"}}active{{end}}">外观主题</a>
</div>

View File

@ -0,0 +1,44 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>站点设置</h1>
<p class="page-desc">管理与切换站点外观主题</p>
</div>
{{template "admin/site-settings/_menu.html" .}}
<div class="settings-section">
<div class="section-title">主题选择</div>
<div class="section-body">
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">当前主题</span>
<span class="setting-desc">切换主题后立即生效,无需重启服务</span>
</div>
<div class="setting-input-wrap">
<select id="theme-selector" class="setting-select">
<option value="">加载中...</option>
</select>
<button id="theme-save-btn" class="btn btn-sm btn-primary" disabled>保存</button>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">主题信息</span>
<span class="setting-desc">选中主题的详细描述</span>
</div>
<div class="setting-info-detail" id="theme-info">
<span class="setting-value"></span>
</div>
</div>
</div>
</div>
<div id="theme-status" class="settings-section" style="display:none">
<div class="section-body">
<div class="setting-item">
<span id="theme-status-msg"></span>
</div>
</div>
</div>
{{template "admin/layout/footer.html" .}}
<script src="/admin/static/js/site-settings-theme.js?v={{assetV "/admin/static/js/site-settings-theme.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -123,3 +123,30 @@
.toggle-switch input:disabled + .toggle-slider {
opacity: 0.4; cursor: not-allowed;
}
/* --- 下拉选择器 + 保存按钮 --- */
.setting-select {
padding: 6px 10px; border: 1px solid var(--border);
border-radius: 4px; font-size: 13px; min-width: 220px;
outline: none; background: #fff; cursor: pointer;
transition: border-color 0.2s;
}
.setting-select:focus { border-color: var(--primary); }
.btn-save-select { min-width: 56px; text-align: center; }
.btn-save-select:disabled { opacity: 0.5; cursor: not-allowed; }
/* --- 主题信息详情 --- */
.setting-info-detail {
flex: 1; min-width: 0;
font-size: 13px; color: var(--text-muted);
}
/* --- 主题状态提示 --- */
.status-success .section-body {
background: #e8f5e9; border-left: 3px solid #4caf50;
padding: 12px 20px;
}
.status-error .section-body {
background: #fce4ec; border-left: 3px solid #f44336;
padding: 12px 20px;
}

View File

@ -0,0 +1,109 @@
/* =====================================================
MetaLab 管理面板 - 主题切换
立即生效,无需重启
===================================================== */
(function() {
'use strict';
var selector = document.getElementById('theme-selector');
var saveBtn = document.getElementById('theme-save-btn');
var infoEl = document.getElementById('theme-info');
var statusSection = document.getElementById('theme-status');
var statusMsg = document.getElementById('theme-status-msg');
var themes = [];
var currentTheme = '';
async function loadThemes() {
try {
var res = await api.get('/api/admin/site-settings/themes');
if (!res.success) {
showToast('加载主题列表失败', 'error');
return;
}
themes = res.data.themes || [];
currentTheme = res.data.current || 'MetaLab-2026';
// 填充下拉
selector.innerHTML = '';
themes.forEach(function(t) {
var opt = document.createElement('option');
opt.value = t.dir;
opt.textContent = t.display_name || t.name;
if (t.dir === currentTheme) {
opt.selected = true;
updateInfo(t);
}
selector.appendChild(opt);
});
saveBtn.disabled = false;
saveBtn.textContent = '保存';
// 选择变化时更新信息
selector.addEventListener('change', function() {
var selected = selector.value;
var found = themes.find(function(t) { return t.dir === selected; });
if (found) {
updateInfo(found);
}
saveBtn.disabled = (selected === currentTheme);
});
// 保存
saveBtn.addEventListener('click', function() {
applyTheme(selector.value);
});
} catch (e) {
showToast('加载主题列表失败', 'error');
}
}
function updateInfo(theme) {
var parts = [];
if (theme.display_name) parts.push(theme.display_name);
if (theme.version) parts.push('v' + theme.version);
if (theme.author) parts.push('作者:' + theme.author);
if (theme.description) parts.push(theme.description);
infoEl.innerHTML = '<span class="setting-value">' + escapeHtml(parts.join(' &middot; ')) + '</span>';
}
async function applyTheme(themeDir) {
saveBtn.disabled = true;
saveBtn.textContent = '切换中...';
showStatus('正在切换主题...', '');
try {
var res = await api.put('/api/admin/site-settings/theme', { theme: themeDir });
if (res.success) {
currentTheme = themeDir;
saveBtn.textContent = '已生效';
showToast('主题已切换,即时生效', 'success');
showStatus('✅ 主题已切换为:<strong>' + themeDir + '</strong>,所有访客可见新主题', 'success');
} else {
saveBtn.disabled = false;
saveBtn.textContent = '保存';
showToast(res.message || '切换失败', 'error');
showStatus('❌ 切换失败:' + (res.message || '未知错误'), 'error');
}
} catch (e) {
saveBtn.disabled = false;
saveBtn.textContent = '保存';
showToast('切换失败', 'error');
showStatus('❌ 切换失败,请重试', 'error');
}
}
function showStatus(msg, type) {
statusSection.style.display = 'block';
statusSection.className = 'settings-section';
if (type === 'success') {
statusSection.classList.add('status-success');
} else if (type === 'error') {
statusSection.classList.add('status-error');
}
statusMsg.innerHTML = msg;
}
loadThemes();
})();