diff --git a/internal/config/config.go b/internal/config/config.go index 9e899ee..db2a061 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,6 +129,16 @@ func bindEnvOverride(v *viper.Viper) { _ = v.BindEnv("database.password", "DATABASE_PASSWORD") } +// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口 +func (c *Config) GetIdleTimeout() int { + return c.Session.IdleTimeout +} + +// GetRememberTimeout 返回记住我会话空闲超时(分钟) +func (c *Config) GetRememberTimeout() int { + return c.Session.RememberTimeout +} + // DSN 返回 PostgreSQL 连接字符串 func (d DatabaseConfig) DSN() string { return "host=" + d.Host + diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index f87d2e4..70d02ec 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -3,6 +3,7 @@ package controller import ( "io" "net/http" + "time" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" @@ -33,17 +34,24 @@ type auditSubmittable interface { GetPendingTypes(userID uint) ([]string, error) } +// sessionConfig 登录管理对配置的最小依赖(ISP:2 个方法) +type sessionConfig interface { + GetIdleTimeout() int // 分钟 + GetRememberTimeout() int // 分钟 +} + // SettingsController 个人设置控制器 type SettingsController struct { authService profileProvider avatarService avatarProvider auditService auditSubmittable sessionMgr sessionManager + sessionCfg sessionConfig } // NewSettingsController 构造函数 -func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager) *SettingsController { - return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr} +func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController { + return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg} } // SettingsPage 个人设置页面(需登录) @@ -94,13 +102,24 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { *session.Session DeviceInfo session.DeviceInfo IsCurrent bool + TTLMinutes int // 剩余有效分钟数(仅非当前设备显示) } var views []sessionView for _, s := range sessions { + timeout := sc.sessionCfg.GetIdleTimeout() // 临时会话超时(分钟) + if s.RememberMe { + timeout = sc.sessionCfg.GetRememberTimeout() // 记住我超时(分钟) + } + elapsed := int(time.Since(s.LastAccess).Minutes()) + ttl := timeout - elapsed + if ttl < 0 { + ttl = 0 + } views = append(views, sessionView{ Session: s, DeviceInfo: session.ParseUA(s.UserAgent), IsCurrent: s.ID == currentSID, + TTLMinutes: ttl, }) } data["Sessions"] = views diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index ade0496..9483a93 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -22,7 +22,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting messageController := controller.NewMessageController(notificationService) - settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr) + settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg) siteSettingController := adminCtrl.NewSiteSettingController( service.NewSiteSettingService(siteSettings), &service.AuditDefaults{ diff --git a/internal/theme/loader.go b/internal/theme/loader.go index bdc9b04..564c89b 100644 --- a/internal/theme/loader.go +++ b/internal/theme/loader.go @@ -1,6 +1,7 @@ package theme import ( + "fmt" "html/template" "os" "path/filepath" @@ -17,8 +18,9 @@ type TemplateRoot struct { // 模板名 = prefix + 相对于 root.Dir 的相对路径 func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) { funcMap := template.FuncMap{ - "add": func(a, b int) int { return a + b }, - "subtract": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, + "subtract": func(a, b int) int { return a - b }, + "formatTTL": formatTTL, } t := template.New("").Funcs(funcMap) for _, root := range roots { @@ -55,3 +57,23 @@ func LoadContent(path string) (template.HTML, error) { } return template.HTML(content), nil } + +// formatTTL 将剩余分钟数格式化为人类可读的时间(如 "23 小时"、"3 天"、"15 分钟") +func formatTTL(minutes int) string { + if minutes <= 0 { + return "即将过期" + } + if minutes < 60 { + return fmt.Sprintf("%d 分钟", minutes) + } + hours := minutes / 60 + if hours < 24 { + return fmt.Sprintf("%d 小时", hours) + } + days := hours / 24 + remainHours := hours % 24 + if remainHours == 0 { + return fmt.Sprintf("%d 天", days) + } + return fmt.Sprintf("%d 天 %d 小时", days, remainHours) +} diff --git a/templates/MetaLab-2026/html/settings/index.html b/templates/MetaLab-2026/html/settings/index.html index 4c7f9e1..e43ae58 100644 --- a/templates/MetaLab-2026/html/settings/index.html +++ b/templates/MetaLab-2026/html/settings/index.html @@ -118,11 +118,13 @@ {{.DeviceInfo.Browser}} / {{.DeviceInfo.OS}} {{if .IsCurrent}}当前设备{{end}} + {{if not .RememberMe}}临时会话{{end}}
IP: {{.IP}} 登录于 {{.CreatedAt.Format "2006-01-02 15:04"}} 最后活动 {{.LastAccess.Format "2006-01-02 15:04"}} + {{if not .IsCurrent}}剩余约 {{formatTTL .TTLMinutes}}{{end}}
diff --git a/templates/MetaLab-2026/static/css/settings.css b/templates/MetaLab-2026/static/css/settings.css index 7e5a215..54376e0 100644 --- a/templates/MetaLab-2026/static/css/settings.css +++ b/templates/MetaLab-2026/static/css/settings.css @@ -649,6 +649,17 @@ body { margin-left: .2rem; } +.session-badge.session-badge-temp { + background: #fff3e0; + color: #e65100; +} + +.session-ttl { + color: #e65100; + font-size: .78rem; + font-weight: 500; +} + .session-meta { display: flex; gap: .8rem;