diff --git a/internal/common/errors.go b/internal/common/errors.go index a014b46..571c778 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -34,4 +34,5 @@ var ( ErrLoginBeforeDelete = errors.New("请先登录再注销") ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号") ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限") + ErrRegistrationDisabled = errors.New("注册功能已关闭") ) diff --git a/internal/common/helper.go b/internal/common/helper.go index c2330fc..04ce89c 100644 --- a/internal/common/helper.go +++ b/internal/common/helper.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" ) -// BuildPageData 构建页面模板数据,自动注入登录状态与 CSRF token +// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token func BuildPageData(c *gin.Context, extra gin.H) gin.H { data := gin.H{} for k, v := range extra { @@ -20,6 +20,8 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H { if token, exists := c.Get("csrf_token"); exists { data["CSRFToken"] = token } + // 注入站点信息(由 InjectSiteInfo 中间件设置) + injectSiteInfo(c, data) return data } @@ -48,3 +50,16 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H { } return data } + +// injectSiteInfo 从 context 注入站点展示信息 +func injectSiteInfo(c *gin.Context, data gin.H) { + if brand, exists := c.Get("site_brand"); exists { + data["Brand"] = brand + } + if framework, exists := c.Get("site_framework"); exists { + data["Framework"] = framework + } + if copyrightStart, exists := c.Get("site_copyright_start"); exists { + data["CopyrightStart"] = copyrightStart + } +} diff --git a/internal/config/site_settings.go b/internal/config/site_settings.go index ad63b20..623ba3e 100644 --- a/internal/config/site_settings.go +++ b/internal/config/site_settings.go @@ -107,3 +107,26 @@ func (s *SiteSettings) IsAuditEnabled() bool { func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) bool { return s.GetBool("audit."+auditType, defaultVal) } + +// --- 站点信息设置 --- + +// SiteInfoDefaults 站点展示信息的默认值 +type SiteInfoDefaults struct { + Brand string // 品牌名,默认 "MetaLab" + Framework string // 框架名,默认 "MetaZone" + CopyrightStart string // 版权起始年份,默认 "2024" +} + +// SiteInfo 获取站点展示信息(优先 DB 设置,fallback 默认值) +func (s *SiteSettings) SiteInfo() SiteInfoDefaults { + return SiteInfoDefaults{ + Brand: s.Get("site.brand", "MetaLab"), + Framework: s.Get("site.framework", "MetaZone"), + CopyrightStart: s.Get("site.copyright_start", "2024"), + } +} + +// IsRegistrationEnabled 是否允许新用户注册 +func (s *SiteSettings) IsRegistrationEnabled() bool { + return s.GetBool("registration.enabled", true) +} diff --git a/internal/controller/auth_api_register.go b/internal/controller/auth_api_register.go index e961c24..b6ab990 100644 --- a/internal/controller/auth_api_register.go +++ b/internal/controller/auth_api_register.go @@ -51,6 +51,8 @@ func (ac *AuthController) Register(c *gin.Context) { common.Error(c, http.StatusConflict, "该邮箱已注册") case common.ErrWeakPassword: common.Error(c, http.StatusBadRequest, err.Error()) + case common.ErrRegistrationDisabled: + common.Error(c, http.StatusForbidden, "注册功能已关闭") default: common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试") } diff --git a/internal/controller/auth_controller.go b/internal/controller/auth_controller.go index bf3145a..fe1b7c7 100644 --- a/internal/controller/auth_controller.go +++ b/internal/controller/auth_controller.go @@ -23,7 +23,7 @@ func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg} } -// RegisterPage 注册页面(已登录用户重定向到首页) +// RegisterPage 注册页面(已登录用户重定向到首页,注册关闭时重定向到登录页) func (ac *AuthController) RegisterPage(c *gin.Context) { if _, exists := c.Get("uid"); exists { c.Redirect(http.StatusFound, "/") diff --git a/internal/middleware/site_info.go b/internal/middleware/site_info.go new file mode 100644 index 0000000..8689480 --- /dev/null +++ b/internal/middleware/site_info.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "metazone.cc/metalab/internal/config" + + "github.com/gin-gonic/gin" +) + +// InjectSiteInfo 注入站点展示信息到请求上下文(供 BuildPageData 使用) +// 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Brand/Framework/CopyrightStart +func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc { + return func(c *gin.Context) { + info := siteSettings.SiteInfo() + c.Set("site_brand", info.Brand) + c.Set("site_framework", info.Framework) + c.Set("site_copyright_start", info.CopyrightStart) + c.Next() + } +} diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 8ae1d4c..5fbf3c2 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -22,14 +22,14 @@ type dependencies struct { tokenCtrl *controller.AuthController } -func buildDepsCore(db *gorm.DB, cfg *config.Config) ( +func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) ( *repository.UserRepo, *service.TokenService, *service.AuthService, *middleware.RateLimiter, *controller.AuthController, *service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController, ) { userRepo := repository.NewUserRepo(db) tokenSvc := service.NewTokenService(cfg, userRepo) - authService := service.NewAuthService(userRepo, tokenSvc, cfg) + authService := service.NewAuthService(userRepo, tokenSvc, cfg, siteSettings) rateLimiter := middleware.NewRateLimiter() authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) avatarSvc := service.NewAvatarService(userRepo) diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index d8683e8..685abc0 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -11,7 +11,7 @@ import ( ) func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies { - userRepo, _, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg) + userRepo, _, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings) auditRepo := repository.NewAuditRepo(db) notificationRepo := repository.NewNotificationRepo(db) diff --git a/internal/router/router.go b/internal/router/router.go index 8c38a18..08cbd1f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -11,6 +11,7 @@ import ( // Setup 注册所有路由 func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) { r.Use(middleware.SecurityHeaders()) + r.Use(middleware.InjectSiteInfo(siteSettings)) deps := buildDeps(db, cfg, siteSettings) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a8f989f..a1e9af9 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -18,11 +18,12 @@ type AuthService struct { userRepo userAuthStore tokenService tokenProvider cfg *config.Config + siteSettings *config.SiteSettings } // NewAuthService 构造函数 -func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config) *AuthService { - return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg} +func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService { + return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg, siteSettings: siteSettings} } var pwLetter = regexp.MustCompile(`[a-zA-Z]`) @@ -56,6 +57,11 @@ func validatePassword(pw string) error { // rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除) // regIP: 注册 IP 地址 func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) { + // 0. 检查注册开关 + if !s.siteSettings.IsRegistrationEnabled() { + return "", "", nil, common.ErrRegistrationDisabled + } + // 1. 密码强度 if err := validatePassword(req.Password); err != nil { return "", "", nil, err diff --git a/templates/MetaLab-2026/html/layout/footer.html b/templates/MetaLab-2026/html/layout/footer.html index 2083568..8317806 100644 --- a/templates/MetaLab-2026/html/layout/footer.html +++ b/templates/MetaLab-2026/html/layout/footer.html @@ -1,5 +1,5 @@ diff --git a/templates/MetaLab-2026/html/layout/header.html b/templates/MetaLab-2026/html/layout/header.html index 81fc2d7..5be7973 100644 --- a/templates/MetaLab-2026/html/layout/header.html +++ b/templates/MetaLab-2026/html/layout/header.html @@ -4,7 +4,7 @@ {{if .CSRFToken}}{{end}} -{{.Title}} - MetaLab +{{.Title}} - {{.Brand}} {{if .ExtraCSS}} diff --git a/templates/admin/html/layout/base.html b/templates/admin/html/layout/base.html index 538b24c..a6d64de 100644 --- a/templates/admin/html/layout/base.html +++ b/templates/admin/html/layout/base.html @@ -4,7 +4,7 @@ {{if .CSRFToken}}{{end}} -{{.Title}} - MetaLab 管理面板 +{{.Title}} - {{.Brand}} 管理面板 {{if .ExtraCSS}} @@ -13,7 +13,7 @@
- +
{{.Username}} 返回前台 diff --git a/templates/admin/html/site-settings/index.html b/templates/admin/html/site-settings/index.html index 804bf6b..9c8cbae 100644 --- a/templates/admin/html/site-settings/index.html +++ b/templates/admin/html/site-settings/index.html @@ -1,7 +1,61 @@ {{template "admin/layout/base.html" .}} + + +
+
基本设置
+
+
+
+ 品牌名称 + 显示在页面标题、页脚、管理面板中 +
+
+ + +
+
+
+
+ 框架名称 + 显示在页脚 Framework 标识中 +
+
+ + +
+
+
+
+ 版权起始年份 + 页脚版权标起始年(如 2024 → © 2024-当前年) +
+
+ + +
+
+
+
+ + +
+
注册设置
+
+
+
+ 允许注册 + 关闭后,新用户将无法注册 +
+ +
+
@@ -56,7 +110,7 @@
-
通用设置
+
其他设置
加载中...
diff --git a/templates/admin/static/css/site-settings.css b/templates/admin/static/css/site-settings.css index 815e366..ce1557a 100644 --- a/templates/admin/static/css/site-settings.css +++ b/templates/admin/static/css/site-settings.css @@ -45,6 +45,19 @@ word-break: break-all; } +/* --- 文本输入框 + 保存按钮 --- */ +.setting-input-wrap { + display: flex; gap: 8px; align-items: center; flex-shrink: 0; +} +.setting-input { + padding: 6px 10px; border: 1px solid var(--border); + border-radius: 4px; font-size: 13px; width: 200px; + outline: none; transition: border-color 0.2s; +} +.setting-input:focus { border-color: var(--primary); } +.btn-save { width: 56px; text-align: center; } +.btn-save:disabled { opacity: 0.5; cursor: not-allowed; } + /* --- 空状态 --- */ .settings-empty { text-align: center; padding: 28px 20px; diff --git a/templates/admin/static/js/site-settings.js b/templates/admin/static/js/site-settings.js index a6ac468..7d87174 100644 --- a/templates/admin/static/js/site-settings.js +++ b/templates/admin/static/js/site-settings.js @@ -1,43 +1,94 @@ /* ===================================================== MetaLab 管理面板 - 站点设置 - 单一职责:加载/保存站点设置,开关即时生效 + 单一职责:加载/保存站点设置,开关即时生效,文本输入手动保存 ===================================================== */ (function() { 'use strict'; const TOGGLE_KEYS = [ + { id: 'registration-enabled', key: 'registration.enabled' }, { id: 'audit-enabled', key: 'audit.enabled' }, { id: 'audit-username', key: 'audit.username_audit' }, { id: 'audit-avatar', key: 'audit.avatar_audit' }, { id: 'audit-bio', key: 'audit.bio_audit' } ]; + // 已有专属 UI 的设置 key,不在"其他设置"中重复显示 + var DEDICATED_KEYS = { + 'site.brand': true, 'site.framework': true, 'site.copyright_start': true, + 'registration.enabled': true, + 'audit.enabled': true, 'audit.username_audit': true, 'audit.avatar_audit': true, 'audit.bio_audit': true + }; + // --- 初始加载 --- async function loadSettings() { try { - const res = await api.get('/api/admin/site-settings'); + var res = await api.get('/api/admin/site-settings'); if (!res.success) return; - // 审核开关 - const audit = res.data.audit || {}; + // 所有开关 + var audit = res.data.audit || {}; + var allSettings = res.data.all || {}; TOGGLE_KEYS.forEach(function(t) { var el = document.getElementById(t.id); - if (el) { + if (!el) return; + + if (t.id === 'registration-enabled') { + el.checked = allSettings['registration.enabled'] !== 'false'; + } else { el.checked = audit[t.key.split('.')[1]] === true; - el.addEventListener('change', function() { - updateBoolSetting(t.key, el.checked, el); - }); } + el.addEventListener('change', function() { + updateBoolSetting(t.key, el.checked, el); + }); }); - // 通用设置 - renderGeneralSettings(res.data.all || {}); + // 文本输入保存按钮 + bindSaveButtons(); + + // 其他设置(过滤已有专属UI的key) + renderOtherSettings(allSettings); } catch (e) { showToast('加载设置失败', 'error'); } } + // --- 文本输入保存 --- + function bindSaveButtons() { + var buttons = document.querySelectorAll('.btn-save'); + buttons.forEach(function(btn) { + btn.addEventListener('click', function() { + var key = btn.getAttribute('data-key'); + var inputId = btn.getAttribute('data-input'); + var input = document.getElementById(inputId); + if (!key || !input) return; + updateTextSetting(key, input.value, btn); + }); + }); + } + + async function updateTextSetting(key, value, btn) { + btn.disabled = true; + btn.textContent = '...'; + try { + var res = await api.put('/api/admin/site-settings', { + key: key, + value: value + }); + if (res.success) { + showToast('设置已更新', 'success'); + } else { + showToast(res.message || '保存失败', 'error'); + } + } catch (e) { + showToast('保存失败', 'error'); + } finally { + btn.disabled = false; + btn.textContent = '保存'; + } + } + // --- 更新布尔设置 --- async function updateBoolSetting(key, value, toggle) { toggle.disabled = true; @@ -60,16 +111,19 @@ } } - // --- 渲染通用设置 --- - function renderGeneralSettings(all) { + // --- 渲染其他设置(过滤已有专属UI的key) --- + function renderOtherSettings(all) { var container = document.getElementById('general-settings'); - var keys = Object.keys(all); - if (keys.length === 0) { + var filtered = []; + Object.keys(all).forEach(function(k) { + if (!DEDICATED_KEYS[k]) filtered.push(k); + }); + if (filtered.length === 0) { container.innerHTML = '
暂无其他设置
'; return; } container.innerHTML = ''; - keys.forEach(function(key) { + filtered.forEach(function(key) { var val = all[key] || ''; var item = document.createElement('div'); item.className = 'setting-item';