fix: 设计原则审查修复 — DIP/ISP, LoD, DRY, OCP, URL, 301缓存
- P0 DIP+ISP: 全链路注入接口,消除零接口紧耦合 - P0 URL: auth 301→302,修复登出后浏览器缓存陷阱 - P1 DRY: JWT 认证逻辑收敛至 TokenService+中间件 - P2 DRY: 前后端角色/状态映射统一为 model 常量 - P2 LoD: 新增 SettingsController,router 不再跨层调 repo - P2 URL: settings ?tab= → /settings/:tab 伪静态 - P3 OCP: 角色权限 map 化,告别硬编码 switch
This commit is contained in:
119
README.md
119
README.md
@ -1,3 +1,118 @@
|
|||||||
# MetaLab
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go 1.25">
|
||||||
|
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
||||||
|
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||||
|
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
|
||||||
|
<img src="https://img.shields.io/badge/status-dev-orange?style=flat-square" alt="status">
|
||||||
|
</p>
|
||||||
|
|
||||||
MetaLab - 下一代开源技术社区
|
<h1 align="center">MetaLab</h1>
|
||||||
|
|
||||||
|
<p align="center"><em>下一代开源技术社区——为构建者准备的纯粹空间</em></p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关于项目
|
||||||
|
|
||||||
|
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。
|
||||||
|
|
||||||
|
> **当前状态:私有开发中**。社区上线后将完全开源。
|
||||||
|
|
||||||
|
**核心理念:**
|
||||||
|
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
|
||||||
|
- 🤝 **平等交流**——没有特权,每个成员权利平等
|
||||||
|
- 🔓 **开放共享**——鼓励开源、协作与知识共享
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 类别 | 技术 | 版本 |
|
||||||
|
|------|------|------|
|
||||||
|
| 语言 | **Go** | 1.25 |
|
||||||
|
| Web 框架 | **Gin** | 1.12 |
|
||||||
|
| ORM | **GORM** | 1.31 |
|
||||||
|
| 数据库 | **PostgreSQL** | 16+ |
|
||||||
|
| 认证 | **JWT** (HS256) | v5 |
|
||||||
|
| 密码加密 | **bcrypt** (cost=12) | — |
|
||||||
|
| 配置 | **Viper** | 1.21 |
|
||||||
|
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
|
||||||
|
| 架构 | 分层六边形架构 | — |
|
||||||
|
|
||||||
|
## 当前功能
|
||||||
|
|
||||||
|
- ✅ **用户系统**——注册、登录、登出
|
||||||
|
- ✅ **双 Token 认证**——Access Token (15min) + Refresh Token (30d)
|
||||||
|
- ✅ **"记住我"**——跨会话自动恢复登录态
|
||||||
|
- ✅ **CSRF 保护**——全站 CSRF Token
|
||||||
|
- ✅ **速率限制**——API 调用频率控制
|
||||||
|
- ✅ **安全头**——Content-Security-Policy 等安全响应头
|
||||||
|
- ✅ **管理后台**——用户管理、角色权限控制
|
||||||
|
- ✅ **软删除**——用户数据可恢复
|
||||||
|
- ✅ **多主题支持**——可切换的前端主题系统
|
||||||
|
|
||||||
|
## 计划中
|
||||||
|
|
||||||
|
- 🔲 帖子系统(CRUD + Markdown)
|
||||||
|
- 🔲 话题/分类导航
|
||||||
|
- 🔲 用户设置页
|
||||||
|
- 🔲 邮箱验证
|
||||||
|
- 🔲 全文搜索
|
||||||
|
- 🔲 管理后台增强(内容审核、统计分析)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone git@git.metazone.cc:MetaZone/MetaLab.git
|
||||||
|
cd MetaLab
|
||||||
|
|
||||||
|
# 创建 .env 文件
|
||||||
|
cat > .env << EOF
|
||||||
|
DATABASE_PASSWORD=your_db_password
|
||||||
|
JWT_SECRET=your_jwt_secret
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 确保 PostgreSQL 运行中
|
||||||
|
# 默认连接: 127.0.0.1:5432, 数据库: metalab_dev
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
go run cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 `http://localhost:8080`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
MetaLab/
|
||||||
|
├── cmd/server/ # 入口:初始化 → 启动
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 配置加载(YAML + .env)
|
||||||
|
│ ├── router/ # 路由注册
|
||||||
|
│ ├── controller/ # 控制器(参数绑定 → 调服务 → 返回)
|
||||||
|
│ │ └── admin/ # 后台控制器
|
||||||
|
│ ├── service/ # 业务逻辑层
|
||||||
|
│ ├── repository/ # 数据访问层(DAO)
|
||||||
|
│ ├── model/ # 数据模型 + DTO
|
||||||
|
│ ├── middleware/ # 中间件(认证/CSRF/限流/安全)
|
||||||
|
│ ├── common/ # 公共工具(响应/分页/加密)
|
||||||
|
│ └── theme/ # 主题/模板管理
|
||||||
|
├── templates/ # 前端模板(多主题)
|
||||||
|
│ ├── MetaLab-2026/ # 前台主题
|
||||||
|
│ └── admin/ # 后台模板
|
||||||
|
└── docs/ # 开发文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
本项目采用 **[GNU Affero General Public License v3.0 (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html)** 许可。
|
||||||
|
|
||||||
|
**社区版**(AGPL-3.0):可自由使用、修改、分发,但修改后通过网络提供服务时必须公开源码。
|
||||||
|
|
||||||
|
**商业授权**:如需闭源部署、私有化修改或商业技术支持,请联系 [metazone@foxmail.com](mailto:metazone@foxmail.com) 获取商业授权。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>© 2024-2026 MetaZone | Built with ❤️ by <a href="https://metazone.cc">MetaZone</a> </sub>
|
||||||
|
</p>
|
||||||
|
|||||||
@ -17,6 +17,9 @@ func main() {
|
|||||||
// 配置
|
// 配置
|
||||||
cfg := config.Load("config.yaml")
|
cfg := config.Load("config.yaml")
|
||||||
|
|
||||||
|
// 从配置初始化角色系统(OCP:新增角色无需修改源代码)
|
||||||
|
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
|
||||||
|
|
||||||
// 数据库
|
// 数据库
|
||||||
db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
|
db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -58,6 +61,7 @@ func main() {
|
|||||||
// 静态资源
|
// 静态资源
|
||||||
r.Static("/static", "./templates/MetaLab-2026/static")
|
r.Static("/static", "./templates/MetaLab-2026/static")
|
||||||
r.Static("/admin/static", "./templates/admin/static")
|
r.Static("/admin/static", "./templates/admin/static")
|
||||||
|
r.Static("/shared/static", "./templates/shared/static")
|
||||||
|
|
||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg)
|
router.Setup(r, db, cfg)
|
||||||
|
|||||||
21
config.yaml
21
config.yaml
@ -21,3 +21,24 @@ jwt:
|
|||||||
|
|
||||||
bcrypt:
|
bcrypt:
|
||||||
cost: 12
|
cost: 12
|
||||||
|
|
||||||
|
# 角色配置(可扩展,无需修改源代码)
|
||||||
|
roles:
|
||||||
|
# 角色层级(数字越大权限越高)
|
||||||
|
levels:
|
||||||
|
user: 0
|
||||||
|
moderator: 1
|
||||||
|
admin: 2
|
||||||
|
owner: 3
|
||||||
|
# 角色显示名称
|
||||||
|
names:
|
||||||
|
user: "普通用户"
|
||||||
|
moderator: "版主"
|
||||||
|
admin: "管理员"
|
||||||
|
owner: "站长"
|
||||||
|
# 角色权限规则
|
||||||
|
permissions:
|
||||||
|
# 可操作的目标角色(未在此列出的角色默认可操作所有角色)
|
||||||
|
operable_roles:
|
||||||
|
admin: ["user"]
|
||||||
|
moderator: ["user"]
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -1,6 +1,6 @@
|
|||||||
module metazone.cc/metalab
|
module metazone.cc/metalab
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
|||||||
@ -15,6 +15,10 @@ func OkMessage(c *gin.Context, message string) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func OkWithMessage(c *gin.Context, data interface{}, message string) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": data, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
func Error(c *gin.Context, code int, message string) {
|
func Error(c *gin.Context, code int, message string) {
|
||||||
c.JSON(code, gin.H{"success": false, "message": message})
|
c.JSON(code, gin.H{"success": false, "message": message})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type Config struct {
|
|||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
||||||
|
Roles RolesConfig `mapstructure:"roles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@ -42,6 +43,18 @@ type BcryptConfig struct {
|
|||||||
Cost int `mapstructure:"cost"`
|
Cost int `mapstructure:"cost"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml)
|
||||||
|
type RolesConfig struct {
|
||||||
|
Levels map[string]int `mapstructure:"levels"`
|
||||||
|
Names map[string]string `mapstructure:"names"`
|
||||||
|
Permissions RolesPermissions `mapstructure:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolesPermissions 角色间操作权限
|
||||||
|
type RolesPermissions struct {
|
||||||
|
OperableRoles map[string][]string `mapstructure:"operable_roles"`
|
||||||
|
}
|
||||||
|
|
||||||
// 全局配置实例(初始化后只读)
|
// 全局配置实例(初始化后只读)
|
||||||
var App *Config
|
var App *Config
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,15 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Note: service import kept for ListUsersParams / ListUsersResult DTOs
|
||||||
|
|
||||||
// AdminController 管理后台控制器
|
// AdminController 管理后台控制器
|
||||||
type AdminController struct {
|
type AdminController struct {
|
||||||
adminService *service.AdminService
|
adminService adminUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminController 构造函数
|
// NewAdminController 构造函数
|
||||||
func NewAdminController(adminService *service.AdminService) *AdminController {
|
func NewAdminController(adminService adminUseCase) *AdminController {
|
||||||
return &AdminController{adminService: adminService}
|
return &AdminController{adminService: adminService}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +36,8 @@ func (ac *AdminController) UsersPage(c *gin.Context) {
|
|||||||
"Title": "用户管理",
|
"Title": "用户管理",
|
||||||
"ExtraCSS": "/admin/static/css/users.css",
|
"ExtraCSS": "/admin/static/css/users.css",
|
||||||
"ExtraJS": "/admin/static/js/users.js",
|
"ExtraJS": "/admin/static/js/users.js",
|
||||||
|
"RoleNames": model.RoleDisplayNames,
|
||||||
|
"StatusNames": model.StatusDisplayNames,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
internal/controller/admin/interfaces.go
Normal file
10
internal/controller/admin/interfaces.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import "metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:3 个方法)
|
||||||
|
type adminUseCase interface {
|
||||||
|
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
|
||||||
|
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
|
||||||
|
ResetUserToken(operatorUID, targetUID uint) error
|
||||||
|
}
|
||||||
@ -5,9 +5,7 @@ import (
|
|||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/middleware"
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
|
||||||
"metazone.cc/metalab/internal/theme"
|
"metazone.cc/metalab/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -15,21 +13,21 @@ import (
|
|||||||
|
|
||||||
// AuthController 认证相关页面 + API
|
// AuthController 认证相关页面 + API
|
||||||
type AuthController struct {
|
type AuthController struct {
|
||||||
authService *service.AuthService
|
authService authUseCase
|
||||||
tokenService *service.TokenService
|
tokenService tokenRefresher
|
||||||
rateLimiter *middleware.RateLimiter
|
rateLimiter rateLimiter
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthController 构造函数
|
// NewAuthController 构造函数
|
||||||
func NewAuthController(authService *service.AuthService, tokenSvc *service.TokenService, limiter *middleware.RateLimiter, cfg *config.Config) *AuthController {
|
func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController {
|
||||||
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
|
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPage 注册页面(已登录用户重定向到首页)
|
// RegisterPage 注册页面(已登录用户重定向到首页)
|
||||||
func (ac *AuthController) RegisterPage(c *gin.Context) {
|
func (ac *AuthController) RegisterPage(c *gin.Context) {
|
||||||
if _, exists := c.Get("uid"); exists {
|
if _, exists := c.Get("uid"); exists {
|
||||||
c.Redirect(http.StatusMovedPermanently, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
||||||
@ -47,7 +45,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
|||||||
// LoginPage 登录页面(已登录用户重定向到首页)
|
// LoginPage 登录页面(已登录用户重定向到首页)
|
||||||
func (ac *AuthController) LoginPage(c *gin.Context) {
|
func (ac *AuthController) LoginPage(c *gin.Context) {
|
||||||
if _, exists := c.Get("uid"); exists {
|
if _, exists := c.Get("uid"); exists {
|
||||||
c.Redirect(http.StatusMovedPermanently, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, "auth/login.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "auth/login.html", common.BuildPageData(c, gin.H{
|
||||||
@ -103,13 +101,13 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch err {
|
switch err {
|
||||||
case service.ErrInvalidCred:
|
case common.ErrInvalidCred:
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case service.ErrUserBanned:
|
case common.ErrUserBanned:
|
||||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
||||||
case service.ErrUserLocked:
|
case common.ErrUserLocked:
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case service.ErrUserDeleted:
|
case common.ErrUserDeleted:
|
||||||
// deleted 状态本应在 Login 中自动恢复,此 case 作为兜底
|
// deleted 状态本应在 Login 中自动恢复,此 case 作为兜底
|
||||||
common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复")
|
common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复")
|
||||||
default:
|
default:
|
||||||
@ -123,11 +121,7 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
|
|
||||||
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.OkWithMessage(c, user, "登录成功")
|
||||||
"success": true,
|
|
||||||
"message": "登录成功",
|
|
||||||
"data": user,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckEmail 检查邮箱是否已注册
|
// CheckEmail 检查邮箱是否已注册
|
||||||
@ -144,10 +138,7 @@ func (ac *AuthController) CheckEmail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.Ok(c, gin.H{"exists": exists})
|
||||||
"success": true,
|
|
||||||
"data": gin.H{"exists": exists},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 注册 API
|
// Register 注册 API
|
||||||
@ -171,9 +162,9 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
recordReg()
|
recordReg()
|
||||||
}
|
}
|
||||||
switch err {
|
switch err {
|
||||||
case service.ErrEmailExists:
|
case common.ErrEmailExists:
|
||||||
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
||||||
case service.ErrWeakPassword:
|
case common.ErrWeakPassword:
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
default:
|
default:
|
||||||
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||||
@ -183,20 +174,13 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
|
|
||||||
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
|
||||||
"success": true,
|
|
||||||
"message": "注册成功!欢迎加入 MetaLab",
|
|
||||||
"data": user,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout 退出登录:清除所有认证 Cookie
|
// Logout 退出登录:清除所有认证 Cookie
|
||||||
func (ac *AuthController) Logout(c *gin.Context) {
|
func (ac *AuthController) Logout(c *gin.Context) {
|
||||||
common.ClearAuthCookies(c, ac.cfg)
|
common.ClearAuthCookies(c, ac.cfg)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.OkMessage(c, "已退出登录")
|
||||||
"success": true,
|
|
||||||
"message": "已退出登录",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 用 refresh token 换取新的 access token
|
// RefreshToken 用 refresh token 换取新的 access token
|
||||||
@ -207,23 +191,26 @@ func (ac *AuthController) RefreshToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken)
|
accessToken, user, err := ac.tokenService.RefreshAccessToken(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ClearAuthCookies(c, ac.cfg)
|
common.ClearAuthCookies(c, ac.cfg)
|
||||||
switch err {
|
switch err {
|
||||||
case service.ErrTokenExpired, service.ErrTokenRevoked:
|
case common.ErrTokenExpired, common.ErrTokenRevoked:
|
||||||
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
||||||
case service.ErrUserBanned:
|
|
||||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
|
||||||
default:
|
default:
|
||||||
common.Error(c, http.StatusUnauthorized, "请重新登录")
|
common.Error(c, http.StatusUnauthorized, "请重新登录")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防止封禁用户通过 refresh 续期(状态检查是认证业务域,放在控制器层)
|
||||||
|
if user.Status == model.StatusBanned {
|
||||||
|
common.ClearAuthCookies(c, ac.cfg)
|
||||||
|
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
common.SetAccessCookie(c, accessToken, ac.cfg)
|
common.SetAccessCookie(c, accessToken, ac.cfg)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
common.Ok(c, nil)
|
||||||
"success": true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
25
internal/controller/interfaces.go
Normal file
25
internal/controller/interfaces.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/middleware"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:3 个方法)
|
||||||
|
type authUseCase interface {
|
||||||
|
Register(req model.RegisterRequest) (string, string, *model.User, error)
|
||||||
|
Login(req model.LoginRequest) (string, string, *model.User, error)
|
||||||
|
CheckEmail(email string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefresher AuthController 对 TokenService 的最小依赖(ISP:1 个方法)
|
||||||
|
type tokenRefresher interface {
|
||||||
|
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:3 个方法)
|
||||||
|
type rateLimiter interface {
|
||||||
|
AllowAccount(email string) (middleware.RateLimitResult, func())
|
||||||
|
AllowIP(ip string) (middleware.RateLimitResult, func())
|
||||||
|
Clear(email, ip string)
|
||||||
|
}
|
||||||
57
internal/controller/settings_controller.go
Normal file
57
internal/controller/settings_controller.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileProvider SettingsController 对 Service 层的最小依赖(ISP:1 个方法)
|
||||||
|
type profileProvider interface {
|
||||||
|
GetProfile(userID uint) (*model.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsController 个人设置控制器
|
||||||
|
type SettingsController struct {
|
||||||
|
authService profileProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingsController 构造函数
|
||||||
|
func NewSettingsController(authService profileProvider) *SettingsController {
|
||||||
|
return &SettingsController{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsPage 个人设置页面(需登录)
|
||||||
|
func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
||||||
|
uidVal, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
c.Redirect(http.StatusFound, "/auth/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
|
tab := c.Param("tab")
|
||||||
|
if tab != "profile" && tab != "account" {
|
||||||
|
c.String(http.StatusNotFound, "页面不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := sc.authService.GetProfile(uid)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
c.String(http.StatusNotFound, "用户不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "个人设置",
|
||||||
|
"ExtraCSS": "/static/css/settings.css",
|
||||||
|
"ActiveTab": tab,
|
||||||
|
"User": user,
|
||||||
|
"RoleName": model.RoleDisplayNames[user.Role],
|
||||||
|
"StatusName": model.StatusDisplayNames[user.Status],
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -4,63 +4,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
|
||||||
"metazone.cc/metalab/internal/config"
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/repository"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminAuth 管理后台页面认证中间件
|
|
||||||
// 逻辑同 AuthRequired(JWT 校验 + token_version 吊销检查)
|
|
||||||
// 但失败时重定向到首页,而非返回 JSON
|
|
||||||
// 成功时向 context 注入 uid/email/username/role
|
|
||||||
func AdminAuth(cfg *config.Config, userRepo *repository.UserRepo) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
tokenStr, err := c.Cookie(common.CookieName)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[AdminAuth] NO_COOKIE path=%s → 302", c.Request.URL.Path)
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := parseToken(tokenStr, cfg.JWT.Secret)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[AdminAuth] TOKEN_PARSE_FAIL path=%s err=%v → 302", c.Request.URL.Path, err)
|
|
||||||
common.ClearAuthCookies(c, cfg)
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uid := uint(claims["uid"].(float64))
|
|
||||||
tokenVer := int(claims["ver"].(float64))
|
|
||||||
|
|
||||||
// 即时吊销检查
|
|
||||||
currentVer, err := userRepo.FindTokenVersion(uid)
|
|
||||||
if err != nil || tokenVer != currentVer {
|
|
||||||
if tokenVer != currentVer {
|
|
||||||
log.Printf("[AdminAuth] REVOKED: uid=%d tokenVer=%d dbVer=%d → 302", uid, tokenVer, currentVer)
|
|
||||||
} else {
|
|
||||||
log.Printf("[AdminAuth] DB_ERR: uid=%d err=%v → 302", uid, err)
|
|
||||||
}
|
|
||||||
common.ClearAuthCookies(c, cfg)
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uid, claims["role"], c.Request.URL.Path)
|
|
||||||
c.Set("uid", uid)
|
|
||||||
c.Set("email", claims["email"])
|
|
||||||
c.Set("username", claims["username"])
|
|
||||||
c.Set("role", claims["role"])
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireMinRole 角色权限检查(用于 API 路由)
|
// RequireMinRole 角色权限检查(用于 API 路由)
|
||||||
// 不足时返回 JSON 403
|
// 不足时返回 JSON 403
|
||||||
func RequireMinRole(minRole string) gin.HandlerFunc {
|
func RequireMinRole(minRole string) gin.HandlerFunc {
|
||||||
|
|||||||
@ -2,12 +2,12 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/repository"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@ -16,11 +16,11 @@ import (
|
|||||||
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
|
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
|
||||||
type AuthMiddleware struct {
|
type AuthMiddleware struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
userRepo *repository.UserRepo
|
userRepo tokenVersionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware 构造函数
|
// NewAuthMiddleware 构造函数
|
||||||
func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthMiddleware {
|
func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware {
|
||||||
return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
|
return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,15 +28,7 @@ func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthM
|
|||||||
// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销
|
// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销
|
||||||
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenStr, err := c.Cookie(common.CookieName)
|
claims, err := am.authenticateToken(c)
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(401, gin.H{
|
|
||||||
"success": false, "message": "请先登录",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ClearAuthCookies(c, am.cfg)
|
common.ClearAuthCookies(c, am.cfg)
|
||||||
c.AbortWithStatusJSON(401, gin.H{
|
c.AbortWithStatusJSON(401, gin.H{
|
||||||
@ -45,27 +37,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uid := uint(claims["uid"].(float64))
|
injectUserContext(c, claims)
|
||||||
tokenVer := int(claims["ver"].(float64))
|
|
||||||
|
|
||||||
// 即时吊销检查:查询 DB 当前版本号,不匹配则拒绝
|
|
||||||
currentVer, err := am.userRepo.FindTokenVersion(uid)
|
|
||||||
if err != nil || tokenVer != currentVer {
|
|
||||||
if tokenVer != currentVer {
|
|
||||||
log.Printf("[AuthRequired] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
|
|
||||||
}
|
|
||||||
common.ClearAuthCookies(c, am.cfg)
|
|
||||||
c.AbortWithStatusJSON(401, gin.H{
|
|
||||||
"success": false, "message": "登录凭证已失效,请重新登录",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注入用户信息到上下文
|
|
||||||
c.Set("uid", uid)
|
|
||||||
c.Set("email", claims["email"])
|
|
||||||
c.Set("username", claims["username"])
|
|
||||||
c.Set("role", claims["role"])
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,39 +45,70 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
|||||||
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie)
|
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie)
|
||||||
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenStr, err := c.Cookie(common.CookieName)
|
claims, err := am.authenticateToken(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("[AuthOptional] no cookie for", c.Request.URL.Path)
|
log.Printf("[AuthOptional] token invalid for %s: %v", c.Request.URL.Path, err)
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[AuthOptional] JWT parse failed for %s: %v", c.Request.URL.Path, err)
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uid := uint(claims["uid"].(float64))
|
|
||||||
tokenVer := int(claims["ver"].(float64))
|
|
||||||
|
|
||||||
// 即时吊销检查
|
|
||||||
currentVer, err := am.userRepo.FindTokenVersion(uid)
|
|
||||||
if err != nil || tokenVer != currentVer {
|
|
||||||
log.Printf("[AuthOptional] REVOKED or err: uid=%d tokenVer=%d dbVer=%d err=%v",
|
|
||||||
uid, tokenVer, currentVer, err)
|
|
||||||
// 已吊销:清除 Cookie,但仍放行(页面视图,前端自行处理)
|
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path)
|
log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path)
|
||||||
c.Set("uid", uid)
|
injectUserContext(c, claims)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON
|
||||||
|
func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
claims, err := am.authenticateToken(c)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[AdminAuth] auth failed path=%s err=%v → 302", c.Request.URL.Path, err)
|
||||||
|
common.ClearAuthCookies(c, am.cfg)
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uint(claims["uid"].(float64)), claims["role"], c.Request.URL.Path)
|
||||||
|
injectUserContext(c, claims)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version
|
||||||
|
// 成功返回 claims,失败返回 error(调用方自行决定如何响应)
|
||||||
|
func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, error) {
|
||||||
|
tokenStr, err := c.Cookie(common.CookieName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := uint(claims["uid"].(float64))
|
||||||
|
tokenVer := int(claims["ver"].(float64))
|
||||||
|
|
||||||
|
currentVer, err := am.userRepo.FindTokenVersion(uid)
|
||||||
|
if err != nil || tokenVer != currentVer {
|
||||||
|
if tokenVer != currentVer {
|
||||||
|
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
|
||||||
|
func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
|
||||||
|
c.Set("uid", uint(claims["uid"].(float64)))
|
||||||
c.Set("email", claims["email"])
|
c.Set("email", claims["email"])
|
||||||
c.Set("username", claims["username"])
|
c.Set("username", claims["username"])
|
||||||
c.Set("role", claims["role"])
|
c.Set("role", claims["role"])
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseToken 解析并验证 JWT
|
// parseToken 解析并验证 JWT
|
||||||
|
|||||||
6
internal/middleware/repository.go
Normal file
6
internal/middleware/repository.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法
|
||||||
|
type tokenVersionStore interface {
|
||||||
|
FindTokenVersion(userID uint) (int, error)
|
||||||
|
}
|
||||||
@ -19,10 +19,3 @@ type LoginRequest struct {
|
|||||||
type CheckEmailRequest struct {
|
type CheckEmailRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthResponse 认证响应(含 JWT)
|
|
||||||
type AuthResponse struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
User User `json:"user"`
|
|
||||||
}
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ type User struct {
|
|||||||
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
|
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色常量
|
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
|
||||||
const (
|
const (
|
||||||
RoleUser = "user"
|
RoleUser = "user"
|
||||||
RoleModerator = "moderator"
|
RoleModerator = "moderator"
|
||||||
@ -31,15 +31,43 @@ const (
|
|||||||
StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天)
|
StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天)
|
||||||
)
|
)
|
||||||
|
|
||||||
// roleLevel 角色层级映射(数字越大权限越高)
|
// --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
|
||||||
var roleLevel = map[string]int{
|
var roleLevel map[string]int
|
||||||
RoleUser: 0,
|
var operableRoles map[string][]string
|
||||||
RoleModerator: 1,
|
var RoleDisplayNames map[string]string
|
||||||
RoleAdmin: 2,
|
|
||||||
RoleOwner: 3,
|
// InitRoles 从配置初始化角色系统(唯一数据源)
|
||||||
|
// 必须在服务启动前调用,不提供默认值
|
||||||
|
func InitRoles(levels map[string]int, names map[string]string, operRoles map[string][]string) {
|
||||||
|
roleLevel = levels
|
||||||
|
RoleDisplayNames = names
|
||||||
|
operableRoles = operRoles
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasMinRole 检查 role 是否达到 minRole 的权限级别
|
// HasMinRole 检查 role 是否达到 minRole 的权限级别
|
||||||
func HasMinRole(role, minRole string) bool {
|
func HasMinRole(role, minRole string) bool {
|
||||||
return roleLevel[role] >= roleLevel[minRole]
|
return roleLevel[role] >= roleLevel[minRole]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanOperateRole 检查操作者是否可以操作目标角色
|
||||||
|
// 未在 operableRoles 中声明的角色默认可操作所有角色
|
||||||
|
func CanOperateRole(operatorRole, targetRole string) bool {
|
||||||
|
allowed, hasRule := operableRoles[operatorRole]
|
||||||
|
if !hasRule {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, r := range allowed {
|
||||||
|
if r == targetRole {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusDisplayNames 状态 → 中文名(服务端唯一数据源)
|
||||||
|
var StatusDisplayNames = map[string]string{
|
||||||
|
StatusActive: "正常",
|
||||||
|
StatusBanned: "已封禁",
|
||||||
|
StatusDeleted: "已注销",
|
||||||
|
StatusLocked: "已锁定",
|
||||||
|
}
|
||||||
|
|||||||
@ -139,8 +139,8 @@ func (r *UserRepo) Restore(uid uint) error {
|
|||||||
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
|
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchUsers 综合搜索(含软删除用户)
|
// buildSearchQuery 构建搜索/筛选的公共查询条件
|
||||||
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
|
func (r *UserRepo) buildSearchQuery(keyword, role, status string) *gorm.DB {
|
||||||
query := r.db.Unscoped().Model(&model.User{})
|
query := r.db.Unscoped().Model(&model.User{})
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
like := "%" + keyword + "%"
|
like := "%" + keyword + "%"
|
||||||
@ -152,26 +152,21 @@ func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int)
|
|||||||
if status != "" {
|
if status != "" {
|
||||||
query = query.Where("status = ?", status)
|
query = query.Where("status = ?", status)
|
||||||
}
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers 综合搜索(含软删除用户)
|
||||||
|
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
err := query.Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
|
err := r.buildSearchQuery(keyword, role, status).
|
||||||
|
Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
|
||||||
return users, err
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountSearchUsers 搜索结果总数(含软删除用户)
|
// CountSearchUsers 搜索结果总数(含软删除用户)
|
||||||
func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) {
|
func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) {
|
||||||
query := r.db.Unscoped().Model(&model.User{})
|
|
||||||
if keyword != "" {
|
|
||||||
like := "%" + keyword + "%"
|
|
||||||
query = query.Where("email LIKE ? OR username LIKE ?", like, like)
|
|
||||||
}
|
|
||||||
if role != "" {
|
|
||||||
query = query.Where("role = ?", role)
|
|
||||||
}
|
|
||||||
if status != "" {
|
|
||||||
query = query.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
var count int64
|
var count int64
|
||||||
err := query.Count(&count).Error
|
err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
authService := service.NewAuthService(userRepo, tokenSvc, cfg)
|
authService := service.NewAuthService(userRepo, tokenSvc, cfg)
|
||||||
rateLimiter := middleware.NewRateLimiter()
|
rateLimiter := middleware.NewRateLimiter()
|
||||||
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
|
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
|
||||||
|
settingsCtrl := controller.NewSettingsController(authService)
|
||||||
|
|
||||||
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
|
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
|
||||||
|
|
||||||
@ -50,6 +51,12 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
"ExtraCSS": "/static/css/home.css",
|
"ExtraCSS": "/static/css/home.css",
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 个人设置页(需登录,/:tab 为伪静态子页面)
|
||||||
|
pages.GET("/settings", func(c *gin.Context) {
|
||||||
|
c.Redirect(http.StatusFound, "/settings/profile")
|
||||||
|
})
|
||||||
|
pages.GET("/settings/:tab", settingsCtrl.SettingsPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证页面(已登录自动跳走)
|
// 认证页面(已登录自动跳走)
|
||||||
@ -67,7 +74,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
|
|
||||||
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
|
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
|
||||||
adminPages := r.Group("/admin")
|
adminPages := r.Group("/admin")
|
||||||
adminPages.Use(middleware.AdminAuth(cfg, userRepo))
|
adminPages.Use(authMdw.AdminAuth())
|
||||||
adminPages.Use(middleware.RequirePageRole(model.RoleModerator))
|
adminPages.Use(middleware.RequirePageRole(model.RoleModerator))
|
||||||
adminPages.Use(middleware.CSRF(cfg))
|
adminPages.Use(middleware.CSRF(cfg))
|
||||||
adminPages.Use(func(c *gin.Context) {
|
adminPages.Use(func(c *gin.Context) {
|
||||||
|
|||||||
@ -3,17 +3,16 @@ package service
|
|||||||
import (
|
import (
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminService 管理后台业务逻辑
|
// AdminService 管理后台业务逻辑
|
||||||
// 集中处理所有权限边界检查,避免胖控制器
|
// 集中处理所有权限边界检查,避免胖控制器
|
||||||
type AdminService struct {
|
type AdminService struct {
|
||||||
userRepo *repository.UserRepo
|
userRepo userAdminStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminService 构造函数
|
// NewAdminService 构造函数
|
||||||
func NewAdminService(userRepo *repository.UserRepo) *AdminService {
|
func NewAdminService(userRepo userAdminStore) *AdminService {
|
||||||
return &AdminService{userRepo: userRepo}
|
return &AdminService{userRepo: userRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,8 +131,8 @@ func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func
|
|||||||
return common.ErrUserNotFound
|
return common.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Admin 只能操作 user 角色
|
// 4. 权限检查:操作者是否有权限操作目标角色
|
||||||
if operator.Role == model.RoleAdmin && target.Role != model.RoleUser {
|
if !model.CanOperateRole(operator.Role, target.Role) {
|
||||||
return common.ErrPermissionDenied
|
return common.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/repository"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -14,13 +13,13 @@ import (
|
|||||||
|
|
||||||
// AuthService 认证业务逻辑
|
// AuthService 认证业务逻辑
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
userRepo *repository.UserRepo
|
userRepo userAuthStore
|
||||||
tokenService *TokenService
|
tokenService tokenProvider
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService 构造函数
|
// NewAuthService 构造函数
|
||||||
func NewAuthService(userRepo *repository.UserRepo, tokenSvc *TokenService, cfg *config.Config) *AuthService {
|
func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config) *AuthService {
|
||||||
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg}
|
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +174,11 @@ func (s *AuthService) CheckEmail(email string) (bool, error) {
|
|||||||
return s.userRepo.ExistsByEmail(email)
|
return s.userRepo.ExistsByEmail(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProfile 获取当前用户资料(供 SettingsController 使用)
|
||||||
|
func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
|
||||||
|
return s.userRepo.FindByID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录)
|
// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录)
|
||||||
// 适用场景:修改密码、账号被盗、管理员强制下线
|
// 适用场景:修改密码、账号被盗、管理员强制下线
|
||||||
func (s *AuthService) InvalidateSessions(userID uint) error {
|
func (s *AuthService) InvalidateSessions(userID uint) error {
|
||||||
|
|||||||
9
internal/service/provider.go
Normal file
9
internal/service/provider.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
// tokenProvider AuthService 对 TokenService 的最小依赖(ISP:2 个方法)
|
||||||
|
type tokenProvider interface {
|
||||||
|
BuildAccessToken(user *model.User) (string, error)
|
||||||
|
BuildRefreshToken(user *model.User, rememberMe bool) (string, error)
|
||||||
|
}
|
||||||
35
internal/service/repository.go
Normal file
35
internal/service/repository.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法)
|
||||||
|
// 同时满足 common.UsernameChecker(ExistsByUsername),可传入 GenerateUsername
|
||||||
|
type userAuthStore interface {
|
||||||
|
ExistsByEmail(email string) (bool, error)
|
||||||
|
Create(user *model.User) error
|
||||||
|
FindByEmail(email string) (*model.User, error)
|
||||||
|
FindByID(id uint) (*model.User, error)
|
||||||
|
Update(user *model.User) error
|
||||||
|
IncrementTokenVersion(userID uint) error
|
||||||
|
ExistsByUsername(username string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userTokenStore TokenService 所需的最小仓储接口(ISP:1 个方法)
|
||||||
|
type userTokenStore interface {
|
||||||
|
FindByIDForAuth(userID uint) (*model.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userAdminStore AdminService 所需的最小仓储接口(ISP:8 个方法)
|
||||||
|
type userAdminStore interface {
|
||||||
|
CountSearchUsers(keyword, role, status string) (int64, error)
|
||||||
|
SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error)
|
||||||
|
FindByIDUnscoped(userID uint) (*model.User, error)
|
||||||
|
UpdateStatus(uid uint, status string) error
|
||||||
|
SoftDelete(uid uint) error
|
||||||
|
Restore(uid uint) error
|
||||||
|
ExistsByEmailExclude(email string, excludeUID uint) (bool, error)
|
||||||
|
IncrementTokenVersion(userID uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -7,7 +7,6 @@ import (
|
|||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/repository"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
@ -16,11 +15,11 @@ import (
|
|||||||
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
|
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
|
||||||
type TokenService struct {
|
type TokenService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
userRepo *repository.UserRepo
|
userRepo userTokenStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTokenService 构造函数
|
// NewTokenService 构造函数
|
||||||
func NewTokenService(cfg *config.Config, userRepo *repository.UserRepo) *TokenService {
|
func NewTokenService(cfg *config.Config, userRepo userTokenStore) *TokenService {
|
||||||
return &TokenService{cfg: cfg, userRepo: userRepo}
|
return &TokenService{cfg: cfg, userRepo: userRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,11 +109,6 @@ func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *mod
|
|||||||
return "", nil, common.ErrTokenRevoked
|
return "", nil, common.ErrTokenRevoked
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防止封禁用户通过 refresh 续期
|
|
||||||
if user.Status == model.StatusBanned {
|
|
||||||
return "", nil, common.ErrUserBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := ts.BuildAccessToken(user)
|
accessToken, err := ts.BuildAccessToken(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{{template "layout/nav.html" .}}
|
{{template "layout/nav.html" .}}
|
||||||
|
|
||||||
|
<div class="home-layout">
|
||||||
<main class="hero">
|
<main class="hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Meta<span>Lab</span></h1>
|
<h1>Meta<span>Lab</span></h1>
|
||||||
@ -15,6 +16,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-card user-card">
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
<div class="user-card-avatar">{{slice .Username 0 1}}</div>
|
||||||
|
<h3 class="user-card-name">{{.Username}}</h3>
|
||||||
|
<p class="user-card-desc">欢迎加入 MetaLab</p>
|
||||||
|
<a href="/settings/profile" class="user-card-link">个人设置</a>
|
||||||
|
{{else}}
|
||||||
|
<div class="user-card-avatar">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="user-card-name">未登录</h3>
|
||||||
|
<p class="user-card-desc">登录后解锁社区功能</p>
|
||||||
|
<a href="/auth/login" class="user-card-btn">登录</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
<script src="/static/js/common.js"></script>
|
<script src="/static/js/common.js"></script>
|
||||||
|
|||||||
@ -9,4 +9,5 @@
|
|||||||
{{if .ExtraCSS}}
|
{{if .ExtraCSS}}
|
||||||
<link rel="stylesheet" href="{{.ExtraCSS}}">
|
<link rel="stylesheet" href="{{.ExtraCSS}}">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<script src="/shared/static/js/utils.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<ul class="nav-links" id="navLinks">
|
<ul class="nav-links" id="navLinks">
|
||||||
<li><a href="/">首页</a></li>
|
<li><a href="/">首页</a></li>
|
||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
|
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
|
||||||
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li><a href="/auth/login" class="nav-login">登录</a></li>
|
<li><a href="/auth/login" class="nav-login">登录</a></li>
|
||||||
|
|||||||
105
templates/MetaLab-2026/html/settings/index.html
Normal file
105
templates/MetaLab-2026/html/settings/index.html
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
|
||||||
|
<div class="settings-layout">
|
||||||
|
<aside class="settings-sidebar">
|
||||||
|
<nav class="settings-nav">
|
||||||
|
<a href="/settings/profile"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "profile"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
|
||||||
|
</svg>
|
||||||
|
个人资料
|
||||||
|
</a>
|
||||||
|
<a href="/settings/account"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "account"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
<circle cx="12" cy="16" r="1"/>
|
||||||
|
</svg>
|
||||||
|
账号信息
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="settings-main">
|
||||||
|
{{if eq .ActiveTab "profile"}}
|
||||||
|
<!-- 个人资料 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>个人资料</h2>
|
||||||
|
<p class="settings-card-desc">你的公开信息和基本资料</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">用户名</span>
|
||||||
|
<span class="info-value">{{.User.Username}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">头像</span>
|
||||||
|
<span class="info-value">
|
||||||
|
{{if .User.Avatar}}
|
||||||
|
<img src="{{.User.Avatar}}" alt="avatar" class="avatar-preview">
|
||||||
|
{{else}}
|
||||||
|
<span class="info-muted">未设置</span>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">简介</span>
|
||||||
|
<span class="info-value{{if not .User.Bio}} info-muted{{end}}">
|
||||||
|
{{if .User.Bio}}{{.User.Bio}}{{else}}未填写{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">角色</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<span class="role-tag role-{{.User.Role}}">{{.RoleName}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<!-- 账号信息 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>账号信息</h2>
|
||||||
|
<p class="settings-card-desc">账号安全和基本状态</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">用户 ID</span>
|
||||||
|
<span class="info-value info-mono">{{.User.ID}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">邮箱</span>
|
||||||
|
<span class="info-value">{{.User.Email}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">账号状态</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<span class="status-badge status-{{.User.Status}}">{{.StatusName}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">注册时间</span>
|
||||||
|
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">最后更新</span>
|
||||||
|
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -26,9 +26,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {}
|
||||||
scroll-snap-type: y mandatory;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
|||||||
@ -3,16 +3,23 @@
|
|||||||
仅在首页加载,通过 ExtraCSS 注入
|
仅在首页加载,通过 ExtraCSS 注入
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- Two-column layout ---------- */
|
||||||
|
.home-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Hero ---------- */
|
/* ---------- Hero ---------- */
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 100vh;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%);
|
background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%);
|
||||||
scroll-snap-align: start;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
@ -105,7 +112,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans .section-title {
|
.plans .section-title {
|
||||||
@ -185,7 +191,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.features-grid {
|
||||||
@ -220,7 +225,6 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-vision {
|
.timeline-vision {
|
||||||
@ -316,6 +320,84 @@
|
|||||||
font-size: .95rem;
|
font-size: .95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Sidebar ---------- */
|
||||||
|
.sidebar {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4rem 20px 4rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- User Card --- */
|
||||||
|
.user-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto .8rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-desc {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-link:hover {
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .5rem 1.8rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-btn:hover {
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Responsive (首页专属) ---------- */
|
/* ---------- Responsive (首页专属) ---------- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
@ -327,16 +409,15 @@
|
|||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 2rem 20px 1.5rem;
|
padding: 2rem 20px 1.5rem;
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
}
|
}
|
||||||
.timeline-item {
|
.timeline-item {
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
}
|
||||||
.timeline::before {
|
.timeline::before {
|
||||||
left: 20px;
|
left: 20px;
|
||||||
|
|||||||
215
templates/MetaLab-2026/static/css/settings.css
Normal file
215
templates/MetaLab-2026/static/css/settings.css
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
/* ============================================================
|
||||||
|
MetaLab Settings Styles — 个人设置页样式
|
||||||
|
左侧导航 + 右侧卡片信息展示,只读
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- Page Body ---------- */
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
.settings-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 20px;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Left Sidebar Nav ---------- */
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .92rem;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item:hover {
|
||||||
|
background: rgba(52, 152, 219, .06);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.active {
|
||||||
|
background: rgba(52, 152, 219, .1);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Right Content Area ---------- */
|
||||||
|
.settings-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-header {
|
||||||
|
padding: 1.5rem 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-desc {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-body {
|
||||||
|
padding: 1.25rem 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Info Rows ---------- */
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: .9rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: .88rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: .92rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-muted {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-mono {
|
||||||
|
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
|
||||||
|
font-size: .85rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Avatar Preview ---------- */
|
||||||
|
.avatar-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Role Tag ---------- */
|
||||||
|
.role-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-user { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.role-moderator { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.role-admin { background: #fce4ec; color: #c62828; }
|
||||||
|
.role-owner { background: #fff3e0; color: #e65100; }
|
||||||
|
|
||||||
|
/* ---------- Status Badge ---------- */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.status-banned { background: #ffebee; color: #c62828; }
|
||||||
|
.status-deleted { background: #fff3e0; color: #e65100; }
|
||||||
|
.status-locked { background: #eceff1; color: #546e7a; }
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem 16px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
align-self: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
padding: .5rem .9rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-header {
|
||||||
|
padding: 1.25rem 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-body {
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,11 +19,8 @@
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- CSRF token helper (reads from <meta> tag rendered by server) ----
|
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
|
||||||
window.MetaLab.csrfToken = function () {
|
window.MetaLab.csrfToken = getCSRFToken;
|
||||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
return meta ? meta.getAttribute('content') : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
|
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
|
||||||
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
|
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
|
||||||
|
|||||||
@ -16,14 +16,14 @@
|
|||||||
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
|
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
|
||||||
<div class="admin-header-right">
|
<div class="admin-header-right">
|
||||||
<span class="admin-user">{{.Username}}</span>
|
<span class="admin-user">{{.Username}}</span>
|
||||||
<a href="/" class="admin-back-btn">← 返回前台</a>
|
<a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align:-2px;margin-right:3px;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
<aside class="admin-sidebar">
|
<aside class="admin-sidebar">
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin"}}active{{end}}">
|
<a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin/"}}active{{end}}">
|
||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></span>
|
||||||
管理首页
|
管理首页
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/shared/static/js/utils.js"></script>
|
||||||
<script src="/admin/static/js/common.js"></script>
|
<script src="/admin/static/js/common.js"></script>
|
||||||
{{if .ExtraJS}}
|
{{if .ExtraJS}}
|
||||||
<script src="{{.ExtraJS}}"></script>
|
<script src="{{.ExtraJS}}"></script>
|
||||||
|
|||||||
@ -9,17 +9,15 @@
|
|||||||
<input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off">
|
<input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off">
|
||||||
<select id="roleFilter" class="filter-select">
|
<select id="roleFilter" class="filter-select">
|
||||||
<option value="">全部角色</option>
|
<option value="">全部角色</option>
|
||||||
<option value="user">普通用户</option>
|
{{range $val, $label := .RoleNames}}
|
||||||
<option value="moderator">版主</option>
|
<option value="{{$val}}">{{$label}}</option>
|
||||||
<option value="admin">管理员</option>
|
{{end}}
|
||||||
<option value="owner">站长</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select id="statusFilter" class="filter-select">
|
<select id="statusFilter" class="filter-select">
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
<option value="active">正常</option>
|
{{range $val, $label := .StatusNames}}
|
||||||
<option value="banned">已封禁</option>
|
<option value="{{$val}}">{{$label}}</option>
|
||||||
<option value="deleted">已注销</option>
|
{{end}}
|
||||||
<option value="locked">已锁定</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button id="searchBtn" class="btn btn-primary">搜索</button>
|
<button id="searchBtn" class="btn btn-primary">搜索</button>
|
||||||
</div>
|
</div>
|
||||||
@ -46,5 +44,17 @@
|
|||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="pagination-bar" id="paginationBar"></div>
|
<div class="pagination-bar" id="paginationBar"></div>
|
||||||
<script>window.__currentUid = {{.UID}}; window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};</script>
|
<script>
|
||||||
|
window.__currentUid = {{.UID}};
|
||||||
|
window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};
|
||||||
|
// 由服务端 model.RoleDisplayNames / StatusDisplayNames 作为唯一数据源注入
|
||||||
|
window.__ROLE_NAMES = {
|
||||||
|
{{range $val, $label := .RoleNames}}"{{$val}}": "{{$label}}",
|
||||||
|
{{end}}
|
||||||
|
};
|
||||||
|
window.__STATUS_NAMES = {
|
||||||
|
{{range $val, $label := .StatusNames}}"{{$val}}": "{{$label}}",
|
||||||
|
{{end}}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
{{template "admin/layout/footer.html" .}}
|
{{template "admin/layout/footer.html" .}}
|
||||||
|
|||||||
@ -3,10 +3,10 @@
|
|||||||
单一职责:仅用户管理页面特有样式
|
单一职责:仅用户管理页面特有样式
|
||||||
===================================================== */
|
===================================================== */
|
||||||
|
|
||||||
.data-table .col-uid { width: 60px; }
|
.data-table .col-uid { width: 60px; white-space: nowrap; }
|
||||||
.data-table .col-username { width: 120px; }
|
.data-table .col-username { width: 120px; white-space: nowrap; }
|
||||||
.data-table .col-email { min-width: 180px; }
|
.data-table .col-email { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.data-table .col-role { width: 90px; }
|
.data-table .col-role { width: 100px; white-space: nowrap; }
|
||||||
.data-table .col-status { width: 90px; }
|
.data-table .col-status { width: 90px; white-space: nowrap; }
|
||||||
.data-table .col-time { width: 140px; }
|
.data-table .col-time { width: 140px; white-space: nowrap; }
|
||||||
.data-table .col-actions { width: 160px; }
|
.data-table .col-actions { width: 180px; white-space: nowrap; }
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
===================================================== */
|
===================================================== */
|
||||||
|
|
||||||
// --- AJAX 封装 ---
|
// --- AJAX 封装 ---
|
||||||
|
// 注意:getCSRFToken() 由共享 utils.js 提供
|
||||||
const api = {
|
const api = {
|
||||||
async get(url) {
|
async get(url) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@ -36,11 +37,6 @@ const api = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCSRFToken() {
|
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
return meta ? meta.getAttribute('content') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Toast 通知 ---
|
// --- Toast 通知 ---
|
||||||
function showToast(message, type) {
|
function showToast(message, type) {
|
||||||
const container = document.querySelector('.toast-container') || createToastContainer();
|
const container = document.querySelector('.toast-container') || createToastContainer();
|
||||||
@ -110,13 +106,15 @@ function formatTime(isoStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 角色标签 ---
|
// --- 角色标签 ---
|
||||||
|
// 数据源由服务端 model.RoleDisplayNames 注入为 window.__ROLE_NAMES
|
||||||
function roleLabel(role) {
|
function roleLabel(role) {
|
||||||
const map = { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' };
|
var map = window.__ROLE_NAMES || { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' };
|
||||||
return `<span class="role-tag role-${role}">${map[role] || role}</span>`;
|
return '<span class="role-tag role-' + role + '">' + (map[role] || role) + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 状态标签 ---
|
// --- 状态标签 ---
|
||||||
|
// 数据源由服务端 model.StatusDisplayNames 注入为 window.__STATUS_NAMES
|
||||||
function statusLabel(status) {
|
function statusLabel(status) {
|
||||||
const map = { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' };
|
var map = window.__STATUS_NAMES || { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' };
|
||||||
return `<span class="badge badge-${status}">${map[status] || status}</span>`;
|
return '<span class="badge badge-' + status + '">' + (map[status] || status) + '</span>';
|
||||||
}
|
}
|
||||||
|
|||||||
14
templates/shared/static/js/utils.js
Normal file
14
templates/shared/static/js/utils.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* ============================================================
|
||||||
|
MetaLab 共享工具函数 — 前后台公用,消除 DRY 重复
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 <meta name="csrf-token"> 读取 CSRF 令牌
|
||||||
|
* 由服务端 CSRF 中间件注入到页面模板的 <meta> 标签中
|
||||||
|
* @returns {string} CSRF 令牌,未找到时返回空字符串
|
||||||
|
*/
|
||||||
|
function getCSRFToken() {
|
||||||
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return meta ? meta.getAttribute('content') : '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user