feat: 实现积分与等级系统 + 签到功能 + 下拉菜单优化
- User 模型增加 exp 字段,等级规则 Lv0-Lv6 - 新增签到记录(UserCheckIn)与任务记录(UserTask)模型,唯一索引防重 - 自动签到:AuthMiddleware 验证会话后,基于 Asia/Shanghai 自然日自动签到 +5 经验 - 手动签到:下拉菜单"签到"按钮兜底,已签到显示"今日已签到"并禁用 - 首次任务经验:上传头像/设置用户名/设置个性签名各 +10,审核通过与直接更新均触发 - 经验值变动后自动比对等级,升级时通过通知系统发送"恭喜提升至 LvX" - 下拉菜单重构:顶部头像+用户名+LvX 等级标签,菜单项增加左侧图标,优化布局样式 - 新增 API:POST /api/level/checkin、GET /api/level/info
This commit is contained in:
@ -27,7 +27,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -89,6 +89,12 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
|||||||
if avatar, exists := c.Get("avatar"); exists {
|
if avatar, exists := c.Get("avatar"); exists {
|
||||||
data["Avatar"] = avatar
|
data["Avatar"] = avatar
|
||||||
}
|
}
|
||||||
|
if expVal, exists := c.Get("exp"); exists {
|
||||||
|
if exp, ok := expVal.(int); ok {
|
||||||
|
data["Exp"] = exp
|
||||||
|
data["Level"] = model.GetLevelByExp(exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
||||||
if token, exists := c.Get("csrf_token"); exists {
|
if token, exists := c.Get("csrf_token"); exists {
|
||||||
data["CSRFToken"] = token
|
data["CSRFToken"] = token
|
||||||
|
|||||||
@ -61,3 +61,10 @@ type sessionManager interface {
|
|||||||
DestroyOtherByUID(uid uint, currentSID string) error
|
DestroyOtherByUID(uid uint, currentSID string) error
|
||||||
UpdateRemark(sid string, uid uint, remark string) error
|
UpdateRemark(sid string, uid uint, remark string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// levelUseCase LevelController 对 LevelService 的最小依赖
|
||||||
|
type levelUseCase interface {
|
||||||
|
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||||
|
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
|
||||||
|
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||||
|
}
|
||||||
|
|||||||
61
internal/controller/level_controller.go
Normal file
61
internal/controller/level_controller.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LevelController 积分/等级/签到 API
|
||||||
|
type LevelController struct {
|
||||||
|
levelSvc levelUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelController 构造函数
|
||||||
|
func NewLevelController(levelSvc levelUseCase) *LevelController {
|
||||||
|
return &LevelController{levelSvc: levelSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIn 手动签到 API
|
||||||
|
func (lc *LevelController) CheckIn(c *gin.Context) {
|
||||||
|
uidVal, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
|
checkedIn, newExp, levelUp, err := lc.levelSvc.CheckIn(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "签到失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
level, _, _, _ := lc.levelSvc.GetLevel(uid)
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"checked_in": checkedIn,
|
||||||
|
"exp": newExp,
|
||||||
|
"level": level,
|
||||||
|
"level_up": levelUp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevel 获取当前等级、经验值和签到状态 API
|
||||||
|
func (lc *LevelController) GetLevel(c *gin.Context) {
|
||||||
|
uidVal, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
|
level, exp, checkedIn, err := lc.levelSvc.GetLevel(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{"level": level, "exp": exp, "checked_in": checkedIn})
|
||||||
|
}
|
||||||
@ -57,6 +57,14 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发首次任务奖励(静默失败不影响主流程)
|
||||||
|
if req.Username != "" {
|
||||||
|
_, _, _ = sc.levelSvc.CompleteTask(userID, "username")
|
||||||
|
}
|
||||||
|
if req.Bio != "" {
|
||||||
|
_, _, _ = sc.levelSvc.CompleteTask(userID, "bio")
|
||||||
|
}
|
||||||
|
|
||||||
common.OkMessage(c, "个人资料已更新")
|
common.OkMessage(c, "个人资料已更新")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,5 +120,8 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发首次上传头像奖励
|
||||||
|
_, _, _ = sc.levelSvc.CompleteTask(userID, "avatar")
|
||||||
|
|
||||||
common.Ok(c, gin.H{"url": url})
|
common.Ok(c, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,11 @@ type sessionConfig interface {
|
|||||||
GetRememberTimeout() int // 分钟
|
GetRememberTimeout() int // 分钟
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励)
|
||||||
|
type taskCompleter interface {
|
||||||
|
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// SettingsController 个人设置控制器
|
// SettingsController 个人设置控制器
|
||||||
type SettingsController struct {
|
type SettingsController struct {
|
||||||
authService profileProvider
|
authService profileProvider
|
||||||
@ -47,11 +52,12 @@ type SettingsController struct {
|
|||||||
auditService auditSubmittable
|
auditService auditSubmittable
|
||||||
sessionMgr sessionManager
|
sessionMgr sessionManager
|
||||||
sessionCfg sessionConfig
|
sessionCfg sessionConfig
|
||||||
|
levelSvc taskCompleter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsController 构造函数
|
// NewSettingsController 构造函数
|
||||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
|
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig, levelSvc taskCompleter) *SettingsController {
|
||||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
|
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsPage 个人设置页面(需登录)
|
// SettingsPage 个人设置页面(需登录)
|
||||||
|
|||||||
@ -10,10 +10,16 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
|
||||||
|
type autoCheckIner interface {
|
||||||
|
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager)
|
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager)
|
||||||
type AuthMiddleware struct {
|
type AuthMiddleware struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
sessionManager *session.Manager
|
sessionManager *session.Manager
|
||||||
|
levelSvc autoCheckIner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware 构造函数
|
// NewAuthMiddleware 构造函数
|
||||||
@ -21,6 +27,12 @@ func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware
|
|||||||
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithLevelService 链式注入等级服务(用于自动签到)
|
||||||
|
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
|
||||||
|
am.levelSvc = svc
|
||||||
|
return am
|
||||||
|
}
|
||||||
|
|
||||||
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
|
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
|
||||||
// 验证失败 → 清除 cookie → 返回 401
|
// 验证失败 → 清除 cookie → 返回 401
|
||||||
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
||||||
@ -44,6 +56,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
injectSessionContext(c, s)
|
injectSessionContext(c, s)
|
||||||
|
am.tryAutoCheckIn(c, s)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,10 +78,25 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
injectSessionContext(c, s)
|
injectSessionContext(c, s)
|
||||||
|
am.tryAutoCheckIn(c, s)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryAutoCheckIn 尝试自动签到(静默失败,不影响主流程)
|
||||||
|
// 若签到成功,同步更新 gin context 中的 exp,确保页面渲染获取最新等级
|
||||||
|
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
|
||||||
|
if am.levelSvc == nil || s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkedIn, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
|
||||||
|
if checkedIn {
|
||||||
|
s.Exp = newExp
|
||||||
|
_ = am.sessionManager.UpdateSession(s)
|
||||||
|
c.Set("exp", newExp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// injectSessionContext 将会话中的用户信息注入 gin context
|
// injectSessionContext 将会话中的用户信息注入 gin context
|
||||||
func injectSessionContext(c *gin.Context, s *session.Session) {
|
func injectSessionContext(c *gin.Context, s *session.Session) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@ -79,5 +107,6 @@ func injectSessionContext(c *gin.Context, s *session.Session) {
|
|||||||
c.Set("username", s.Username)
|
c.Set("username", s.Username)
|
||||||
c.Set("avatar", s.Avatar)
|
c.Set("avatar", s.Avatar)
|
||||||
c.Set("role", s.Role)
|
c.Set("role", s.Role)
|
||||||
|
c.Set("exp", s.Exp)
|
||||||
c.Set("sid", s.ID)
|
c.Set("sid", s.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
25
internal/model/level.go
Normal file
25
internal/model/level.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// LevelThresholds 等级阈值表(经验值区间下限)
|
||||||
|
// 索引即等级,如 thresholds[3] = 1500 表示 Lv3 至少需要 1500 经验值
|
||||||
|
var LevelThresholds = []int{0, 1, 200, 1500, 4500, 10800, 28800}
|
||||||
|
|
||||||
|
// GetLevelByExp 根据经验值计算当前等级
|
||||||
|
func GetLevelByExp(exp int) int {
|
||||||
|
for i := len(LevelThresholds) - 1; i >= 0; i-- {
|
||||||
|
if exp >= LevelThresholds[i] {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevelName 根据等级返回展示文本(如 "Lv3")
|
||||||
|
func GetLevelName(level int) string {
|
||||||
|
if level < 0 {
|
||||||
|
level = 0
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Lv%d", level)
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ const (
|
|||||||
NotifyAuditRejected = "audit_rejected"
|
NotifyAuditRejected = "audit_rejected"
|
||||||
NotifyPostApproved = "post_approved"
|
NotifyPostApproved = "post_approved"
|
||||||
NotifyPostRejected = "post_rejected"
|
NotifyPostRejected = "post_rejected"
|
||||||
|
NotifyLevelUp = "level_up"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification 消息/通知模型
|
// Notification 消息/通知模型
|
||||||
@ -26,6 +27,7 @@ var NotifyTypeNames = map[string]string{
|
|||||||
NotifyAuditRejected: "资料审核",
|
NotifyAuditRejected: "资料审核",
|
||||||
NotifyPostApproved: "稿件审核",
|
NotifyPostApproved: "稿件审核",
|
||||||
NotifyPostRejected: "稿件审核",
|
NotifyPostRejected: "稿件审核",
|
||||||
|
NotifyLevelUp: "等级提升",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationListResult 消息列表查询结果
|
// NotificationListResult 消息列表查询结果
|
||||||
|
|||||||
@ -15,6 +15,9 @@ type User struct {
|
|||||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||||
|
|
||||||
|
// 积分与等级
|
||||||
|
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
|
||||||
|
|
||||||
// 安全与审计
|
// 安全与审计
|
||||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||||
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||||
|
|||||||
11
internal/model/user_checkin.go
Normal file
11
internal/model/user_checkin.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserCheckIn 用户签到记录(自然日维度,严格防重)
|
||||||
|
type UserCheckIn struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_checkin_date,priority:1;not null" json:"user_id"`
|
||||||
|
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_checkin_date,priority:2;not null" json:"date"` // Asia/Shanghai 自然日
|
||||||
|
}
|
||||||
23
internal/model/user_task.go
Normal file
23
internal/model/user_task.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// 任务类型常量
|
||||||
|
const (
|
||||||
|
TaskTypeAvatar = "avatar"
|
||||||
|
TaskTypeUsername = "username"
|
||||||
|
TaskTypeBio = "bio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskTypeNames 任务类型 → 中文名
|
||||||
|
var TaskTypeNames = map[string]string{
|
||||||
|
TaskTypeAvatar: "上传头像",
|
||||||
|
TaskTypeUsername: "设置用户名",
|
||||||
|
TaskTypeBio: "设置个性签名",
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserTask 用户首次完成任务记录(严格防重)
|
||||||
|
type UserTask struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_task,priority:1;not null" json:"user_id"`
|
||||||
|
TaskType string `gorm:"type:varchar(20);uniqueIndex:idx_user_task,priority:2;not null" json:"task_type"`
|
||||||
|
}
|
||||||
66
internal/repository/level_repo.go
Normal file
66
internal/repository/level_repo.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LevelRepo 积分/等级/签到/任务数据访问
|
||||||
|
type LevelRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelRepo 构造函数
|
||||||
|
func NewLevelRepo(db *gorm.DB) *LevelRepo {
|
||||||
|
return &LevelRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按 UID 查找用户(实现 userLevelStore 接口)
|
||||||
|
func (r *LevelRepo) FindByID(id uint) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddExp 原子增加用户经验值
|
||||||
|
func (r *LevelRepo) AddExp(userID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.User{}).
|
||||||
|
Where("uid = ?", userID).
|
||||||
|
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindCheckIn 查询指定自然日的签到记录
|
||||||
|
func (r *LevelRepo) FindCheckIn(userID uint, date time.Time) (*model.UserCheckIn, error) {
|
||||||
|
var ci model.UserCheckIn
|
||||||
|
err := r.db.Where("user_id = ? AND date = ?", userID, date).First(&ci).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ci, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckIn 创建签到记录
|
||||||
|
func (r *LevelRepo) CreateCheckIn(ci *model.UserCheckIn) error {
|
||||||
|
return r.db.Create(ci).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindTask 查询用户是否已完成指定任务
|
||||||
|
func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, error) {
|
||||||
|
var t model.UserTask
|
||||||
|
err := r.db.Where("user_id = ? AND task_type = ?", userID, taskType).First(&t).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTask 创建任务完成记录
|
||||||
|
func (r *LevelRepo) CreateTask(task *model.UserTask) error {
|
||||||
|
return r.db.Create(task).Error
|
||||||
|
}
|
||||||
@ -36,6 +36,15 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 积分/等级 API(需登录) ---
|
||||||
|
levelAPI := r.Group("/api/level")
|
||||||
|
levelAPI.Use(d.authMdw.Required())
|
||||||
|
levelAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
levelAPI.POST("/checkin", d.levelCtrl.CheckIn)
|
||||||
|
levelAPI.GET("/info", d.levelCtrl.GetLevel)
|
||||||
|
}
|
||||||
|
|
||||||
// --- 认证 API ---
|
// --- 认证 API ---
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
api.Use(middleware.CSRF(cfg))
|
api.Use(middleware.CSRF(cfg))
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type dependencies struct {
|
|||||||
adminCtrl *adminCtrl.AdminController
|
adminCtrl *adminCtrl.AdminController
|
||||||
auditCtrl *adminCtrl.AuditController
|
auditCtrl *adminCtrl.AuditController
|
||||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||||
|
levelCtrl *controller.LevelController
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
@ -68,3 +69,14 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
|
|||||||
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
|
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
|
||||||
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw
|
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildDepsLevel 构建等级/签到相关依赖
|
||||||
|
func buildDepsLevel(db *gorm.DB, notifSvc *service.NotificationService, authMdw *middleware.AuthMiddleware) (
|
||||||
|
*repository.LevelRepo, *service.LevelService, *controller.LevelController,
|
||||||
|
) {
|
||||||
|
levelRepo := repository.NewLevelRepo(db)
|
||||||
|
levelSvc := service.NewLevelService(levelRepo, levelRepo, levelRepo, notifSvc)
|
||||||
|
levelCtrl := controller.NewLevelController(levelSvc)
|
||||||
|
authMdw.WithLevelService(levelSvc)
|
||||||
|
return levelRepo, levelSvc, levelCtrl
|
||||||
|
}
|
||||||
|
|||||||
@ -18,12 +18,16 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
auditRepo := repository.NewAuditRepo(db)
|
auditRepo := repository.NewAuditRepo(db)
|
||||||
notificationRepo := repository.NewNotificationRepo(db)
|
notificationRepo := repository.NewNotificationRepo(db)
|
||||||
notificationService := service.NewNotificationService(notificationRepo)
|
notificationService := service.NewNotificationService(notificationRepo)
|
||||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService)
|
|
||||||
|
// 等级/签到/任务系统
|
||||||
|
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
||||||
|
|
||||||
|
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
||||||
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||||
|
|
||||||
messageController := controller.NewMessageController(notificationService)
|
messageController := controller.NewMessageController(notificationService)
|
||||||
|
|
||||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
|
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
|
||||||
siteSettingController := adminCtrl.NewSiteSettingController(
|
siteSettingController := adminCtrl.NewSiteSettingController(
|
||||||
service.NewSiteSettingService(siteSettings),
|
service.NewSiteSettingService(siteSettings),
|
||||||
&service.AuditDefaults{
|
&service.AuditDefaults{
|
||||||
@ -54,5 +58,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
adminPostCtrl: adminPostCtrl,
|
adminPostCtrl: adminPostCtrl,
|
||||||
adminCtrl: adminController, auditCtrl: auditController,
|
adminCtrl: adminController, auditCtrl: auditController,
|
||||||
siteSettingCtrl: siteSettingController,
|
siteSettingCtrl: siteSettingController,
|
||||||
|
levelCtrl: levelCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,12 +33,17 @@ type siteSettingsChecker interface {
|
|||||||
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
|
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditNotifier 审核服务所需的通知接口(ISP:审核通过/拒绝时发送通知)
|
// auditNotifier AuditService 所需的通知接口(ISP:审核通过/拒绝时发送通知)
|
||||||
type auditNotifier interface {
|
type auditNotifier interface {
|
||||||
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
|
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
|
||||||
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
|
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditTaskCompleter 审核通过时触发首次任务奖励的接口
|
||||||
|
type auditTaskCompleter interface {
|
||||||
|
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// AuditService 审核业务逻辑
|
// AuditService 审核业务逻辑
|
||||||
type AuditService struct {
|
type AuditService struct {
|
||||||
auditRepo auditRepo
|
auditRepo auditRepo
|
||||||
@ -46,16 +51,18 @@ type AuditService struct {
|
|||||||
settings siteSettingsChecker
|
settings siteSettingsChecker
|
||||||
defaultCfg config.AuditConfig
|
defaultCfg config.AuditConfig
|
||||||
notifier auditNotifier
|
notifier auditNotifier
|
||||||
|
levelSvc auditTaskCompleter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuditService 构造函数
|
// NewAuditService 构造函数
|
||||||
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier) *AuditService {
|
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier, levelSvc auditTaskCompleter) *AuditService {
|
||||||
return &AuditService{
|
return &AuditService{
|
||||||
auditRepo: auditRepo,
|
auditRepo: auditRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
defaultCfg: cfg,
|
defaultCfg: cfg,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
levelSvc: levelSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,7 +227,23 @@ func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
|||||||
case model.AuditTypeBio:
|
case model.AuditTypeBio:
|
||||||
user.Bio = submission.NewValue
|
user.Bio = submission.NewValue
|
||||||
}
|
}
|
||||||
return s.userRepo.Update(user)
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发首次任务奖励(静默失败不影响主流程)
|
||||||
|
if s.levelSvc != nil {
|
||||||
|
switch submission.AuditType {
|
||||||
|
case model.AuditTypeUsername:
|
||||||
|
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username")
|
||||||
|
case model.AuditTypeAvatar:
|
||||||
|
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar")
|
||||||
|
case model.AuditTypeBio:
|
||||||
|
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 分页查询审核列表
|
// List 分页查询审核列表
|
||||||
|
|||||||
149
internal/service/level_service.go
Normal file
149
internal/service/level_service.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LevelService 积分与等级业务逻辑
|
||||||
|
type LevelService struct {
|
||||||
|
userRepo userLevelStore
|
||||||
|
checkRepo checkInStore
|
||||||
|
taskRepo taskStore
|
||||||
|
notifSvc levelNotifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelService 构造函数
|
||||||
|
func NewLevelService(userRepo userLevelStore, checkRepo checkInStore, taskRepo taskStore, notifSvc levelNotifier) *LevelService {
|
||||||
|
return &LevelService{userRepo: userRepo, checkRepo: checkRepo, taskRepo: taskRepo, notifSvc: notifSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevel 获取用户当前等级、经验值和今日签到状态
|
||||||
|
func (s *LevelService) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
|
_, err = s.checkRepo.FindCheckIn(userID, today)
|
||||||
|
checkedIn = err == nil
|
||||||
|
|
||||||
|
return model.GetLevelByExp(user.Exp), user.Exp, checkedIn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddExp 增加经验值,自动检查升级并发送通知
|
||||||
|
// 返回:新经验值、是否升级、错误
|
||||||
|
func (s *LevelService) AddExp(userID uint, delta int) (newExp int, levelUp bool, err error) {
|
||||||
|
if delta <= 0 {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return user.Exp, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先查旧等级
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
oldLevel := model.GetLevelByExp(user.Exp)
|
||||||
|
|
||||||
|
// 原子增加经验值
|
||||||
|
if err := s.userRepo.AddExp(userID, delta); err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查新等级
|
||||||
|
user, err = s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
newLevel := model.GetLevelByExp(user.Exp)
|
||||||
|
|
||||||
|
if newLevel > oldLevel {
|
||||||
|
levelUp = true
|
||||||
|
if s.notifSvc != nil {
|
||||||
|
_ = s.notifSvc.Create(userID, model.NotifyLevelUp,
|
||||||
|
"等级提升",
|
||||||
|
fmt.Sprintf("恭喜您!您的等级已提升至 %s", model.GetLevelName(newLevel)),
|
||||||
|
nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.Exp, levelUp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIn 执行签到(基于 Asia/Shanghai 自然日严格防重)
|
||||||
|
// 返回:是否成功签到、新经验值、是否升级、错误
|
||||||
|
func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error) {
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
// 检查今日是否已签到
|
||||||
|
_, err = s.checkRepo.FindCheckIn(userID, today)
|
||||||
|
if err == nil {
|
||||||
|
// 已签到
|
||||||
|
user, _ := s.userRepo.FindByID(userID)
|
||||||
|
if user != nil {
|
||||||
|
return false, user.Exp, false, nil
|
||||||
|
}
|
||||||
|
return false, 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建签到记录
|
||||||
|
ci := &model.UserCheckIn{
|
||||||
|
UserID: userID,
|
||||||
|
Date: today,
|
||||||
|
}
|
||||||
|
if err := s.checkRepo.CreateCheckIn(ci); err != nil {
|
||||||
|
// 唯一索引冲突 → 已签到
|
||||||
|
user, _ := s.userRepo.FindByID(userID)
|
||||||
|
if user != nil {
|
||||||
|
return false, user.Exp, false, nil
|
||||||
|
}
|
||||||
|
return false, 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加经验值
|
||||||
|
newExp, levelUp, err = s.AddExp(userID, 5)
|
||||||
|
return true, newExp, levelUp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTask 完成首次任务(严格防重)
|
||||||
|
// 返回:新经验值、是否升级、错误
|
||||||
|
func (s *LevelService) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) {
|
||||||
|
// 检查是否已完成
|
||||||
|
_, err = s.taskRepo.FindTask(userID, taskType)
|
||||||
|
if err == nil {
|
||||||
|
// 已完成过,不重复奖励
|
||||||
|
user, _ := s.userRepo.FindByID(userID)
|
||||||
|
if user != nil {
|
||||||
|
return user.Exp, false, nil
|
||||||
|
}
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建任务记录
|
||||||
|
task := &model.UserTask{
|
||||||
|
UserID: userID,
|
||||||
|
TaskType: taskType,
|
||||||
|
}
|
||||||
|
if err := s.taskRepo.CreateTask(task); err != nil {
|
||||||
|
// 唯一索引冲突 → 已完成
|
||||||
|
user, _ := s.userRepo.FindByID(userID)
|
||||||
|
if user != nil {
|
||||||
|
return user.Exp, false, nil
|
||||||
|
}
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加经验值
|
||||||
|
return s.AddExp(userID, 10)
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "metazone.cc/metalab/internal/model"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法)
|
// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法)
|
||||||
type userAuthStore interface {
|
type userAuthStore interface {
|
||||||
@ -58,3 +62,26 @@ type postNotifier interface {
|
|||||||
NotifyPostApproved(userID uint, postTitle string, postID uint)
|
NotifyPostApproved(userID uint, postTitle string, postID uint)
|
||||||
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
|
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userLevelStore LevelService 所需的用户仓储接口
|
||||||
|
type userLevelStore interface {
|
||||||
|
FindByID(id uint) (*model.User, error)
|
||||||
|
AddExp(userID uint, delta int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkInStore 签到记录仓储接口
|
||||||
|
type checkInStore interface {
|
||||||
|
FindCheckIn(userID uint, date time.Time) (*model.UserCheckIn, error)
|
||||||
|
CreateCheckIn(ci *model.UserCheckIn) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskStore 任务完成记录仓储接口
|
||||||
|
type taskStore interface {
|
||||||
|
FindTask(userID uint, taskType string) (*model.UserTask, error)
|
||||||
|
CreateTask(task *model.UserTask) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelNotifier 等级升级通知接口
|
||||||
|
type levelNotifier interface {
|
||||||
|
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string
|
|||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Avatar: user.Avatar,
|
Avatar: user.Avatar,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
|
Exp: user.Exp,
|
||||||
RememberMe: rememberMe,
|
RememberMe: rememberMe,
|
||||||
IP: ip,
|
IP: ip,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
@ -79,10 +80,11 @@ func (m *Manager) Validate(sid string) (*Session, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步 DB 最新字段(头像/用户名/角色等可能已变更)
|
// 同步 DB 最新字段(头像/用户名/角色/经验值等可能已变更)
|
||||||
s.Avatar = user.Avatar
|
s.Avatar = user.Avatar
|
||||||
s.Username = user.Username
|
s.Username = user.Username
|
||||||
s.Role = user.Role
|
s.Role = user.Role
|
||||||
|
s.Exp = user.Exp
|
||||||
|
|
||||||
// 滑动窗口续期
|
// 滑动窗口续期
|
||||||
s.Touch()
|
s.Touch()
|
||||||
@ -134,6 +136,11 @@ func (m *Manager) UpdateRemark(sid string, uid uint, remark string) error {
|
|||||||
return m.store.Set(s)
|
return m.store.Set(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateSession 直接更新会话存储(用于同步经验值等字段)
|
||||||
|
func (m *Manager) UpdateSession(s *Session) error {
|
||||||
|
return m.store.Set(s)
|
||||||
|
}
|
||||||
|
|
||||||
// GetStoreMetrics 返回当前存储的状态信息
|
// GetStoreMetrics 返回当前存储的状态信息
|
||||||
func (m *Manager) GetStoreMetrics() StoreMetrics {
|
func (m *Manager) GetStoreMetrics() StoreMetrics {
|
||||||
return m.store.Metrics()
|
return m.store.Metrics()
|
||||||
|
|||||||
@ -14,6 +14,7 @@ type Session struct {
|
|||||||
Username string // 用户名
|
Username string // 用户名
|
||||||
Avatar string // 头像 URL
|
Avatar string // 头像 URL
|
||||||
Role string // 角色
|
Role string // 角色
|
||||||
|
Exp int // 经验值(用于前端实时展示等级)
|
||||||
RememberMe bool // 是否持久化(决定 cookie maxAge)
|
RememberMe bool // 是否持久化(决定 cookie maxAge)
|
||||||
IP string // 登录 IP
|
IP string // 登录 IP
|
||||||
UserAgent string // 登录设备 User-Agent
|
UserAgent string // 登录设备 User-Agent
|
||||||
|
|||||||
@ -27,9 +27,34 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</a>
|
</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
<div class="nav-dropdown-header">{{.Username}}</div>
|
<div class="nav-dropdown-header">
|
||||||
<a href="/settings/profile" class="nav-dropdown-item">个人中心</a>
|
<div class="nav-dropdown-avatar">
|
||||||
<a href="javascript:void(0)" class="nav-dropdown-item nav-logout" id="logoutBtn">退出登录</a>
|
{{if .Avatar}}
|
||||||
|
<img src="{{.Avatar}}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
|
||||||
|
<span style="display:none">{{substr .Username 0 1}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span>{{substr .Username 0 1}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="nav-dropdown-meta">
|
||||||
|
<div class="nav-dropdown-username">{{.Username}}</div>
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
<div class="nav-dropdown-level">Lv{{.Level}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="javascript:void(0)" class="nav-dropdown-item" id="checkInBtn">
|
||||||
|
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
<span>签到</span>
|
||||||
|
</a>
|
||||||
|
<a href="/settings/profile" class="nav-dropdown-item">
|
||||||
|
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<span>个人中心</span>
|
||||||
|
</a>
|
||||||
|
<a href="javascript:void(0)" class="nav-dropdown-item nav-logout" id="logoutBtn">
|
||||||
|
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@ -262,17 +262,65 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown-header {
|
.nav-dropdown-header {
|
||||||
padding: .75rem 1rem;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
font-size: .9rem;
|
gap: .75rem;
|
||||||
color: var(--color-text);
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-username {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-level {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: .2rem;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #feca57);
|
||||||
|
color: #fff;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown-item {
|
.nav-dropdown-item {
|
||||||
display: block !important;
|
display: flex !important;
|
||||||
padding: .6rem 1rem !important;
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
padding: .65rem 1rem !important;
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
color: var(--color-text-light) !important;
|
color: var(--color-text-light) !important;
|
||||||
transition: background .15s ease, color .15s ease;
|
transition: background .15s ease, color .15s ease;
|
||||||
@ -291,6 +339,28 @@ header {
|
|||||||
color: var(--color-danger) !important;
|
color: var(--color-danger) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-item:hover .nav-dropdown-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-item.disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-item.disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@ -283,6 +283,59 @@
|
|||||||
setInterval(pollUnread, POLL_INTERVAL);
|
setInterval(pollUnread, POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Level / Check-in ----
|
||||||
|
var checkInBtn = document.getElementById('checkInBtn');
|
||||||
|
if (checkInBtn) {
|
||||||
|
// 页面加载时查询等级和签到状态
|
||||||
|
fetch('/api/level/info')
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) return;
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data || !data.success) return;
|
||||||
|
var d = data.data || {};
|
||||||
|
if (d.checked_in) {
|
||||||
|
setCheckInDone();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
|
||||||
|
function setCheckInDone() {
|
||||||
|
checkInBtn.classList.add('disabled');
|
||||||
|
var span = checkInBtn.querySelector('span');
|
||||||
|
if (span) span.textContent = '今日已签到';
|
||||||
|
}
|
||||||
|
|
||||||
|
checkInBtn.addEventListener('click', function () {
|
||||||
|
if (checkInBtn.classList.contains('disabled')) return;
|
||||||
|
|
||||||
|
fetch('/api/level/checkin', { method: 'POST' })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) return;
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data || !data.success) return;
|
||||||
|
var d = data.data || {};
|
||||||
|
if (!d.checked_in) {
|
||||||
|
setCheckInDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCheckInDone();
|
||||||
|
if (d.level_up) {
|
||||||
|
window.MetaLab.toast(document.querySelector('.toast'), '恭喜您!您的等级已提升至 Lv' + d.level, false);
|
||||||
|
}
|
||||||
|
// 刷新等级展示
|
||||||
|
var levelEl = document.querySelector('.nav-dropdown-level');
|
||||||
|
if (levelEl) {
|
||||||
|
levelEl.textContent = 'Lv' + d.level;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Auto-refresh token (silent) ----
|
// ---- Auto-refresh token (silent) ----
|
||||||
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
||||||
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
||||||
|
|||||||
Reference in New Issue
Block a user