feat: 登录管理设备列表显示会话类型和剩余有效期

- 临时会话显示橙色'临时会话'徽章
- 非当前设备显示剩余有效时间(临时/长期均显示)
- 当前设备不显示剩余时间(持续续期,显示无意义)
- 新增 formatTTL 模板函数,分钟数转人类可读格式
- sessionConfig 接口增加 GetRememberTimeout 方法
This commit is contained in:
2026-05-31 00:36:25 +08:00
parent e244bccb5c
commit 213b2a571e
6 changed files with 69 additions and 5 deletions

View File

@ -129,6 +129,16 @@ func bindEnvOverride(v *viper.Viper) {
_ = v.BindEnv("database.password", "DATABASE_PASSWORD") _ = 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 连接字符串 // DSN 返回 PostgreSQL 连接字符串
func (d DatabaseConfig) DSN() string { func (d DatabaseConfig) DSN() string {
return "host=" + d.Host + return "host=" + d.Host +

View File

@ -3,6 +3,7 @@ package controller
import ( import (
"io" "io"
"net/http" "net/http"
"time"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
@ -33,17 +34,24 @@ type auditSubmittable interface {
GetPendingTypes(userID uint) ([]string, error) GetPendingTypes(userID uint) ([]string, error)
} }
// sessionConfig 登录管理对配置的最小依赖ISP2 个方法)
type sessionConfig interface {
GetIdleTimeout() int // 分钟
GetRememberTimeout() int // 分钟
}
// SettingsController 个人设置控制器 // SettingsController 个人设置控制器
type SettingsController struct { type SettingsController struct {
authService profileProvider authService profileProvider
avatarService avatarProvider avatarService avatarProvider
auditService auditSubmittable auditService auditSubmittable
sessionMgr sessionManager sessionMgr sessionManager
sessionCfg sessionConfig
} }
// NewSettingsController 构造函数 // NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager) *SettingsController { func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr} return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
} }
// SettingsPage 个人设置页面(需登录) // SettingsPage 个人设置页面(需登录)
@ -94,13 +102,24 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
*session.Session *session.Session
DeviceInfo session.DeviceInfo DeviceInfo session.DeviceInfo
IsCurrent bool IsCurrent bool
TTLMinutes int // 剩余有效分钟数(仅非当前设备显示)
} }
var views []sessionView var views []sessionView
for _, s := range sessions { 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{ views = append(views, sessionView{
Session: s, Session: s,
DeviceInfo: session.ParseUA(s.UserAgent), DeviceInfo: session.ParseUA(s.UserAgent),
IsCurrent: s.ID == currentSID, IsCurrent: s.ID == currentSID,
TTLMinutes: ttl,
}) })
} }
data["Sessions"] = views data["Sessions"] = views

View File

@ -22,7 +22,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
messageController := controller.NewMessageController(notificationService) messageController := controller.NewMessageController(notificationService)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr) settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
siteSettingController := adminCtrl.NewSiteSettingController( siteSettingController := adminCtrl.NewSiteSettingController(
service.NewSiteSettingService(siteSettings), service.NewSiteSettingService(siteSettings),
&service.AuditDefaults{ &service.AuditDefaults{

View File

@ -1,6 +1,7 @@
package theme package theme
import ( import (
"fmt"
"html/template" "html/template"
"os" "os"
"path/filepath" "path/filepath"
@ -19,6 +20,7 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"add": 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 }, "subtract": func(a, b int) int { return a - b },
"formatTTL": formatTTL,
} }
t := template.New("").Funcs(funcMap) t := template.New("").Funcs(funcMap)
for _, root := range roots { for _, root := range roots {
@ -55,3 +57,23 @@ func LoadContent(path string) (template.HTML, error) {
} }
return template.HTML(content), nil 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)
}

View File

@ -118,11 +118,13 @@
<span class="session-browser">{{.DeviceInfo.Browser}}</span> <span class="session-browser">{{.DeviceInfo.Browser}}</span>
<span class="session-os">/ {{.DeviceInfo.OS}}</span> <span class="session-os">/ {{.DeviceInfo.OS}}</span>
{{if .IsCurrent}}<span class="session-badge">当前设备</span>{{end}} {{if .IsCurrent}}<span class="session-badge">当前设备</span>{{end}}
{{if not .RememberMe}}<span class="session-badge session-badge-temp">临时会话</span>{{end}}
</div> </div>
<div class="session-meta"> <div class="session-meta">
<span class="session-ip">IP: {{.IP}}</span> <span class="session-ip">IP: {{.IP}}</span>
<span class="session-time">登录于 {{.CreatedAt.Format "2006-01-02 15:04"}}</span> <span class="session-time">登录于 {{.CreatedAt.Format "2006-01-02 15:04"}}</span>
<span class="session-time">最后活动 {{.LastAccess.Format "2006-01-02 15:04"}}</span> <span class="session-time">最后活动 {{.LastAccess.Format "2006-01-02 15:04"}}</span>
{{if not .IsCurrent}}<span class="session-ttl">剩余约 {{formatTTL .TTLMinutes}}</span>{{end}}
</div> </div>
<div class="session-remark-row"> <div class="session-remark-row">
<input type="text" class="session-remark-input" value="{{.Remark}}" placeholder="添加备注(如 我的笔记本)" maxlength="32" data-sid="{{.ID}}" data-current="{{.IsCurrent}}"> <input type="text" class="session-remark-input" value="{{.Remark}}" placeholder="添加备注(如 我的笔记本)" maxlength="32" data-sid="{{.ID}}" data-current="{{.IsCurrent}}">

View File

@ -649,6 +649,17 @@ body {
margin-left: .2rem; margin-left: .2rem;
} }
.session-badge.session-badge-temp {
background: #fff3e0;
color: #e65100;
}
.session-ttl {
color: #e65100;
font-size: .78rem;
font-weight: 500;
}
.session-meta { .session-meta {
display: flex; display: flex;
gap: .8rem; gap: .8rem;