Compare commits
17 Commits
99721f562c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b997528bad | |||
| 426bedda65 | |||
| d4a833f149 | |||
| 3f4d827926 | |||
| 1eb3cc7c86 | |||
| 20067f7640 | |||
| ecbae7c4fa | |||
| b10828c851 | |||
| e7092254ab | |||
| 3d69dae799 | |||
| c3c7390498 | |||
| 2449a27eb5 | |||
| e7185f58fb | |||
| 786f463613 | |||
| 518a4248d0 | |||
| 58d6a45b38 | |||
| 742b79475f |
28
.env.example
28
.env.example
@ -1,6 +1,24 @@
|
|||||||
# 复制为 .env 后填入真实值
|
# ==================================
|
||||||
# .env 会覆盖 config.yaml 中的同名字段
|
# MCE 环境变量配置模板
|
||||||
|
# 复制为 .env 后填入实际值
|
||||||
|
# cp .env.example .env
|
||||||
|
# ==================================
|
||||||
|
|
||||||
DATABASE_PASSWORD=your_password_here
|
# --- 数据库 ---
|
||||||
JWT_SECRET=generate-a-random-64-char-string-here
|
DATABASE_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=your_redis_password_here
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=metazone
|
||||||
|
DATABASE_PASSWORD=
|
||||||
|
DATABASE_DBNAME=metalab_dev
|
||||||
|
DATABASE_SSLMODE=disable
|
||||||
|
|
||||||
|
# --- Redis(enabled=false 时以下可忽略) ---
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# --- 服务 ---
|
||||||
|
SERVER_PORT=8080
|
||||||
|
SERVER_MODE=debug
|
||||||
|
|||||||
36
.gitea/workflows/ci.yml
Normal file
36
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint + Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
|
||||||
|
restore-keys: ${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Install tools
|
||||||
|
run: |
|
||||||
|
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
go install mvdan.cc/gofumpt@latest
|
||||||
|
go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
|
||||||
|
- name: CI (strict)
|
||||||
|
run: bash scripts/ci.sh --strict
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,6 +23,9 @@ storage/logs/
|
|||||||
# 设计文档(本地使用,不纳入版本控制)
|
# 设计文档(本地使用,不纳入版本控制)
|
||||||
_Design/
|
_Design/
|
||||||
|
|
||||||
|
# 自用模板(不纳入版本控制)
|
||||||
|
templates/jay-pub/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
17
Makefile
17
Makefile
@ -1,28 +1,33 @@
|
|||||||
.PHONY: lint fmt check test
|
.PHONY: lint fmt check test
|
||||||
|
|
||||||
|
GOBIN := $(HOME)/go/bin
|
||||||
|
BIN_GOFUMPT := $(GOBIN)/gofumpt
|
||||||
|
BIN_GOIMPORTS := $(GOBIN)/goimports
|
||||||
|
BIN_LINT := $(GOBIN)/golangci-lint
|
||||||
|
|
||||||
# === 代码格式化 ===
|
# === 代码格式化 ===
|
||||||
fmt:
|
fmt:
|
||||||
@echo "==> gofumpt..."
|
@echo "==> gofumpt..."
|
||||||
@gofumpt -l -w internal/ cmd/
|
@$(BIN_GOFUMPT) -l -w internal/ cmd/
|
||||||
@echo "==> goimports..."
|
@echo "==> goimports..."
|
||||||
@goimports -w internal/ cmd/
|
@$(BIN_GOIMPORTS) -w internal/ cmd/
|
||||||
|
|
||||||
# === 格式检查(CI 用,只读不改) ===
|
# === 格式检查(CI 用,只读不改) ===
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@echo "==> gofumpt check..."
|
@echo "==> gofumpt check..."
|
||||||
@test -z "$$(gofumpt -l internal/ cmd/)" || (echo "gofumpt: 以下文件格式不正确:" && gofumpt -l internal/ cmd/ && exit 1)
|
@test -z "$$($(BIN_GOFUMPT) -l internal/ cmd/)" || (echo "gofumpt: 以下文件格式不正确:" && $(BIN_GOFUMPT) -l internal/ cmd/ && exit 1)
|
||||||
@echo "==> goimports check..."
|
@echo "==> goimports check..."
|
||||||
@test -z "$$(goimports -l internal/ cmd/)" || (echo "goimports: 以下文件 import 不规范:" && goimports -l internal/ cmd/ && exit 1)
|
@test -z "$$($(BIN_GOIMPORTS) -l internal/ cmd/)" || (echo "goimports: 以下文件 import 不规范:" && $(BIN_GOIMPORTS) -l internal/ cmd/ && exit 1)
|
||||||
|
|
||||||
# === 静态分析(报告模式,不阻断) ===
|
# === 静态分析(报告模式,不阻断) ===
|
||||||
lint:
|
lint:
|
||||||
@echo "==> golangci-lint (report only)..."
|
@echo "==> golangci-lint (report only)..."
|
||||||
@golangci-lint run ./... || true
|
@$(BIN_LINT) run ./... || true
|
||||||
|
|
||||||
# === 静态分析(阻断模式,CI 用) ===
|
# === 静态分析(阻断模式,CI 用) ===
|
||||||
lint-strict:
|
lint-strict:
|
||||||
@echo "==> golangci-lint (strict)..."
|
@echo "==> golangci-lint (strict)..."
|
||||||
@golangci-lint run ./...
|
@$(BIN_LINT) run ./...
|
||||||
|
|
||||||
# === 测试 ===
|
# === 测试 ===
|
||||||
test:
|
test:
|
||||||
|
|||||||
60
README.md
60
README.md
@ -11,6 +11,10 @@
|
|||||||
|
|
||||||
<p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p>
|
<p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p>
|
||||||
|
|
||||||
|
> **⚠️ 维护状态**
|
||||||
|
> 因个人原因,本项目暂时无法继续活跃维护。现有功能可正常使用,Issues/PR 可能无法及时响应。
|
||||||
|
> 如有兴趣接手或继续开发,欢迎 Fork。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 关于项目
|
## 关于项目
|
||||||
@ -70,21 +74,69 @@ MCE 是 **MetaZone Community Engine** 的缩写,是 MetaZone 品牌下的社
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
| 依赖 | 最低版本 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Go | 1.26 | 编译运行 |
|
||||||
|
| PostgreSQL | 16 | 主数据库 |
|
||||||
|
| Redis | 7 | 会话存储(可选,关闭后使用内存存储) |
|
||||||
|
|
||||||
|
### 1. 克隆并配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
|
||||||
git clone git@git.metazone.cc:MetaZone/mce.git
|
git clone git@git.metazone.cc:MetaZone/mce.git
|
||||||
cd mce
|
cd mce
|
||||||
|
```
|
||||||
|
|
||||||
# 配置数据库和 Redis
|
复制环境变量模板并填入实际值:
|
||||||
|
|
||||||
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env 填入 PostgreSQL 和 Redis 连接信息
|
```
|
||||||
|
|
||||||
# 运行
|
编辑 `.env`,至少填入数据库密码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_HOST=127.0.0.1
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=metazone
|
||||||
|
DATABASE_PASSWORD=your_password
|
||||||
|
DATABASE_DBNAME=metalab_dev
|
||||||
|
|
||||||
|
REDIS_ENABLED=true # 可选,false 则使用内存存储
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
> 所有连接信息统一在 `.env` 管理。`config.yaml` 保留默认值兜底,通常无需修改。
|
||||||
|
|
||||||
|
### 2. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 按序执行迁移 SQL
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/001_uid_to_id.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/002_announcements.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/003_post_attributes.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/004_categories_tags.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/005_scheduled_posts.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
首次部署后注册的第一个用户自动获得站长(owner)权限。
|
||||||
|
|
||||||
|
### 3. 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
go run cmd/server/main.go
|
go run cmd/server/main.go
|
||||||
|
# 或编译后运行
|
||||||
|
go build -o mce cmd/server/main.go && ./mce
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 `http://localhost:8080`。
|
启动后访问 `http://localhost:8080`。
|
||||||
|
|
||||||
|
> 生产环境部署详见 [`docs/deployment.md`](docs/deployment.md)。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/mce/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
"metazone.cc/mce/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/mce/internal/repository"
|
"metazone.cc/mce/internal/repository"
|
||||||
"metazone.cc/mce/internal/router"
|
"metazone.cc/mce/internal/router"
|
||||||
@ -22,6 +28,16 @@ func main() {
|
|||||||
// 配置
|
// 配置
|
||||||
cfg := config.Load("config.yaml")
|
cfg := config.Load("config.yaml")
|
||||||
|
|
||||||
|
// 设置时区
|
||||||
|
if cfg.Server.Timezone != "" {
|
||||||
|
loc, err := time.LoadLocation(cfg.Server.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告:时区 %s 无效,使用系统时区: %v", cfg.Server.Timezone, err)
|
||||||
|
} else {
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 从配置初始化角色系统(OCP:新增角色无需修改源代码)
|
// 从配置初始化角色系统(OCP:新增角色无需修改源代码)
|
||||||
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
|
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
|
||||||
|
|
||||||
@ -58,26 +74,49 @@ func main() {
|
|||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
|
// 站点设置管理器(DB 持久化 + 内存缓存 — 必须在模板加载前初始化)
|
||||||
common.ComputeAssetHashes("templates/MetaLab-2026")
|
siteSettings, err := config.NewSiteSettings(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化站点设置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取当前主题名称
|
||||||
|
themeName := siteSettings.ThemeName()
|
||||||
|
log.Printf("当前主题: %s", themeName)
|
||||||
|
|
||||||
|
// 计算静态资源哈希
|
||||||
|
common.ComputeAssetHashes("templates/" + themeName)
|
||||||
|
|
||||||
// 模板加载(前端主题 + 管理后台)
|
// 模板加载(前端主题 + 管理后台)
|
||||||
tmpl, err := theme.LoadTemplates(
|
tmpl, err := loadThemeTemplates(themeName)
|
||||||
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
|
|
||||||
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("加载模板失败: " + err.Error())
|
log.Fatalf("加载模板失败: %v", err)
|
||||||
}
|
}
|
||||||
r.SetHTMLTemplate(tmpl)
|
r.SetHTMLTemplate(tmpl)
|
||||||
|
|
||||||
// 静态资源
|
// 静态资源 — 前台使用动态主题目录
|
||||||
r.Static("/static", "./templates/MetaLab-2026/static")
|
r.GET("/static/*filepath", func(c *gin.Context) {
|
||||||
|
serveDynamicStatic(c, "templates", siteSettings, c.Param("filepath"))
|
||||||
|
})
|
||||||
r.Static("/admin/static", "./templates/admin/static")
|
r.Static("/admin/static", "./templates/admin/static")
|
||||||
r.Static("/shared/static", "./templates/shared/static")
|
r.Static("/shared/static", "./templates/shared/static")
|
||||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
r.Static("/uploads/posts", "./storage/uploads/posts")
|
r.Static("/uploads/posts", "./storage/uploads/posts")
|
||||||
|
|
||||||
|
// 主题重载函数(切换主题后实时生效)
|
||||||
|
themeReloader := func(newTheme string) error {
|
||||||
|
log.Printf("切换主题: %s", newTheme)
|
||||||
|
// 更新静态资源哈希
|
||||||
|
common.ComputeAssetHashes("templates/" + newTheme)
|
||||||
|
// 重新加载模板
|
||||||
|
newTmpl, err := loadThemeTemplates(newTheme)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.SetHTMLTemplate(newTmpl)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
||||||
var redisClient *redis.Client
|
var redisClient *redis.Client
|
||||||
if cfg.Redis.Enabled {
|
if cfg.Redis.Enabled {
|
||||||
@ -92,15 +131,15 @@ func main() {
|
|||||||
log.Println("Redis 未启用,使用内存存储")
|
log.Println("Redis 未启用,使用内存存储")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 站点设置管理器(DB 持久化 + 内存缓存)
|
// 启动数据库健康检测(每 5s ping,失败自动降级)
|
||||||
siteSettings, err := config.NewSiteSettings(db)
|
middleware.StartDBHealthCheck(db)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("初始化站点设置失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg, siteSettings, redisClient)
|
router.Setup(r, db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
|
// 注入主题重载回调到站点设置 Controller
|
||||||
|
router.InjectThemeReloader(themeReloader)
|
||||||
|
|
||||||
// 定时发布调度器
|
// 定时发布调度器
|
||||||
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
|
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
|
||||||
|
|
||||||
@ -110,3 +149,41 @@ func main() {
|
|||||||
log.Fatalf("服务启动失败: %v", err)
|
log.Fatalf("服务启动失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadThemeTemplates 加载指定主题的模板(前台 + 管理后台)
|
||||||
|
func loadThemeTemplates(themeName string) (*template.Template, error) {
|
||||||
|
return theme.LoadTemplates(
|
||||||
|
theme.TemplateRoot{Dir: "templates/" + themeName + "/html", Prefix: ""},
|
||||||
|
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveDynamicStatic 根据当前站点主题动态提供静态资源
|
||||||
|
func serveDynamicStatic(c *gin.Context, baseDir string, ss *config.SiteSettings, file string) {
|
||||||
|
themeName := ss.ThemeName()
|
||||||
|
root := filepath.Join(baseDir, themeName, "static")
|
||||||
|
|
||||||
|
// 清理路径并拼接
|
||||||
|
cleanFile := filepath.Clean(file)
|
||||||
|
fullPath := filepath.Join(root, cleanFile)
|
||||||
|
|
||||||
|
// 安全检查:防止路径遍历逃逸出主题目录
|
||||||
|
absRoot, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
absPath, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil || !strings.HasPrefix(absPath, absRoot+string(os.PathSeparator)) && absPath != absRoot {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFile(c.Writer, c.Request, fullPath)
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
mode: debug # debug | release | test
|
mode: debug # debug | release | test
|
||||||
cookie_secure: false # Cookie Secure 标志(生产环境应设为 true,部分反向代理环境下可能需要灵活控制)
|
cookie_secure: false # Cookie Secure 标志(生产环境应设为 true,部分反向代理环境下可能需要灵活控制)
|
||||||
|
timezone: Asia/Shanghai # 时区(影响 time.Now() 和模板时间格式化)
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
|
|||||||
147
docs/deployment.md
Normal file
147
docs/deployment.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# 生产环境部署指南
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
| 依赖 | 最低版本 | 用途 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Go | 1.26 | 编译 |
|
||||||
|
| PostgreSQL | 16 | 主数据库(用户、帖子、通知等) |
|
||||||
|
| Redis | 7 | 会话存储(推荐) |
|
||||||
|
| Nginx | 1.24+ | 反向代理 + 静态资源 |
|
||||||
|
|
||||||
|
## 1. 编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o /usr/local/bin/mce cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 数据库初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建数据库
|
||||||
|
sudo -u postgres createuser metazone -P
|
||||||
|
sudo -u postgres createdb metalab_prod -O metazone
|
||||||
|
|
||||||
|
# 执行迁移
|
||||||
|
for f in docs/migrations/0*.sql; do
|
||||||
|
psql -U metazone -d metalab_prod -f "$f"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env`,填入生产环境值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务
|
||||||
|
SERVER_MODE=release
|
||||||
|
|
||||||
|
# 数据库(生产实例地址)
|
||||||
|
DATABASE_HOST=10.0.0.1
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=metazone
|
||||||
|
DATABASE_PASSWORD=your_secure_password
|
||||||
|
DATABASE_DBNAME=metalab_prod
|
||||||
|
DATABASE_SSLMODE=require
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
REDIS_HOST=10.0.0.2
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
> `config.yaml` 中的默认值会被 `.env` 覆盖,通常无需修改。
|
||||||
|
|
||||||
|
## 4. systemd 服务
|
||||||
|
|
||||||
|
创建 `/etc/systemd/system/mce.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=MetaZone Community Engine
|
||||||
|
After=network.target postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mce
|
||||||
|
WorkingDirectory=/opt/mce
|
||||||
|
ExecStart=/usr/local/bin/mce
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now mce
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Nginx 反向代理
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/your-domain.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||||
|
|
||||||
|
# 静态资源(Gin 不处理)
|
||||||
|
location /static/ {
|
||||||
|
root /opt/mce/templates/MetaLab-2026;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务状态
|
||||||
|
sudo systemctl status mce
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
sudo journalctl -u mce -f
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
curl -I https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首次注册站长
|
||||||
|
|
||||||
|
部署后首个注册用户自动获得 `owner`(站长)权限,可访问 `/admin` 管理后台。
|
||||||
|
|
||||||
|
## 备份建议
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库备份
|
||||||
|
pg_dump -U metazone metalab_prod | gzip > backup_$(date +%Y%m%d).sql.gz
|
||||||
|
|
||||||
|
# 上传文件备份
|
||||||
|
rsync -av /opt/mce/storage/ /backup/storage/
|
||||||
|
```
|
||||||
@ -1,562 +1,194 @@
|
|||||||
# 开发记录 — 阶段一:基础设施 + 用户认证(含"记住我"与令牌刷新)
|
# 开发记录 — MetaLab RC
|
||||||
|
|
||||||
> 最后更新:2026-05-24
|
> 最后更新:2026-06-22 | 版本:v0.1.0-rc1
|
||||||
|
|
||||||
## 一、已完成事项
|
## 一、架构总览
|
||||||
|
|
||||||
### 1. 数据库配置与连接
|
|
||||||
|
|
||||||
**问题**:PostgreSQL 使用 `peer` 认证,Go 进程以系统用户 `victor_jay` 运行,无法连接数据库用户 `metazone`。
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
- 为 `metazone` 数据库用户设置密码(`MetalabDev2026!`)
|
|
||||||
- 连接方式从 Unix socket(`/var/run/postgresql`)改为 TCP(`127.0.0.1:5432`),触发 `scram-sha-256` 密码认证
|
|
||||||
- 数据库名称:`metalab_dev`
|
|
||||||
|
|
||||||
**涉及文件**:`config.yaml`(`database.host` 改为 `127.0.0.1`)、`.env`(`DATABASE_PASSWORD`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 配置加载架构修复
|
|
||||||
|
|
||||||
**问题**:Viper 的 `SetConfigFile(".env")` 会**覆盖**之前设置的 `config.yaml` 路径,导致所有 YAML 配置被清空,数据库名称为空。
|
|
||||||
|
|
||||||
**根本原因**:Viper 不支持同时加载多个不同类型的配置文件。
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
|
|
||||||
`internal/config/config.go` 中的加载流程:
|
|
||||||
```
|
|
||||||
1. loadEnvFile(".env") → 逐行读取,通过 os.Setenv() 注入环境变量
|
|
||||||
2. viper 只读取 config.yaml
|
|
||||||
3. bindEnvOverride() → 将 DATABASE_PASSWORD、JWT_SECRET 显式绑定到对应配置键
|
|
||||||
4. viper.AutomaticEnv() → 自动读取其余环境变量
|
|
||||||
```
|
|
||||||
|
|
||||||
环境变量优先级:**系统环境变量 > .env 文件**(`.env` 仅在对应环境变量未设置时生效)
|
|
||||||
|
|
||||||
**涉及文件**:`internal/config/config.go`(重写 `Load()` 函数)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 数据模型设计
|
|
||||||
|
|
||||||
#### 3.1 公共基础模型 (`common.go`)
|
|
||||||
|
|
||||||
| 字段 | 类型 | GORM 标签 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ID` | `uint` | `primarykey;column:uid` | 主键,数据库列名为 `uid`,自增 bigint(非 UUID) |
|
|
||||||
| `CreatedAt` | `time.Time` | — | 自动管理 |
|
|
||||||
| `UpdatedAt` | `time.Time` | — | 自动管理 |
|
|
||||||
| `DeletedAt` | `gorm.DeletedAt` | `index;json:"-"` | 软删除,JSON 不暴露 |
|
|
||||||
|
|
||||||
#### 3.2 用户模型 (`user.go`)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Email` | `varchar(255)` | `uniqueIndex; not null` | 注册和登录的唯一凭证 |
|
|
||||||
| `PasswordHash` | `varchar(255)` | `not null; json:"-"` | bcrypt(12),JSON 永不暴露 |
|
|
||||||
| `Username` | `varchar(16)` | `uniqueIndex; not null` | 全局唯一,10 位随机 `[a-z0-9]` 自动生成 |
|
|
||||||
| `Avatar` | `varchar(500)` | `default:''` | 预留头像字段 |
|
|
||||||
| `Bio` | `text` | — | 预留个人简介 |
|
|
||||||
| `Role` | `varchar(20)` | `default:user; index` | `user` / `moderator` / `admin` |
|
|
||||||
| `Status` | `varchar(20)` | `default:active; index` | `active` / `banned` |
|
|
||||||
|
|
||||||
**关键设计决策:**
|
|
||||||
|
|
||||||
| 决策项 | 选择 | 原因 |
|
|
||||||
|---|---|---|
|
|
||||||
| 主键方案 | 自增 bigint(`uid` 列)而非 UUID | 社区应用,索引性能更好,URL 简洁 `/users/42`,`JOIN` 体积小 |
|
|
||||||
| 角色控制 | `role string` 而非 `is_admin bool` | 可扩展(已有 user/moderator/admin 三级),符合最佳实践 |
|
|
||||||
| 用户名生成 | `crypto/rand` 生成 10 位 `[a-z0-9]` | 3,656 万亿种组合,千万用户碰撞概率 < 0.01% |
|
|
||||||
| 用户名长度 | `varchar(16)` | 作为最大长度限制(暂未支持中文字符名) |
|
|
||||||
| 密码哈希 | bcrypt(12) | 行业标准,成本 12 平衡安全与性能 |
|
|
||||||
| 软删除 | GORM `DeletedAt` | 保留数据,误删可恢复 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 用户注册
|
|
||||||
|
|
||||||
**流程**:
|
|
||||||
```
|
|
||||||
POST /api/auth/register
|
|
||||||
→ 验证 Email/Password/ConfirmPassword(binding 标签)
|
|
||||||
→ Service.Register()
|
|
||||||
→ validatePassword():≥8位 + 含字母 + 含数字
|
|
||||||
→ userRepo.FindByEmail():邮箱查重
|
|
||||||
→ bcrypt.GenerateFromPassword(,12):密码哈希
|
|
||||||
→ generateUsername():生成 10 位随机用户名,重试 20 次去重
|
|
||||||
→ userRepo.Create():写入数据库
|
|
||||||
→ buildToken():生成 access JWT(HS256,15 分钟)
|
|
||||||
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT(HS256,30 天)
|
|
||||||
→ SetAuthCookies(accessToken, refreshToken) → 设置 Cookie
|
|
||||||
→ 返回 JSON { success, message, data: user }
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点**:注册即登录,Cookie 自动设置,前端无需额外操作。勾选"记住我"则下发 refresh token + 标记 Cookie,未勾选则不下发。
|
|
||||||
|
|
||||||
**涉及文件**:
|
|
||||||
- `internal/controller/auth_controller.go` — `Register()` POST handler
|
|
||||||
- `internal/service/auth_service.go` — `Register()`、`validatePassword()`、`generateUsername()`、`buildToken()`、`buildRefreshToken()`
|
|
||||||
- `internal/repository/user_repo.go` — `Create()`、`FindByEmail()`
|
|
||||||
- `internal/model/dto.go` — `RegisterRequest`(binding 校验标签 + `remember_me`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 用户登录
|
|
||||||
|
|
||||||
**流程**:
|
|
||||||
```
|
|
||||||
POST /api/auth/login
|
|
||||||
→ 验证 Email/Password
|
|
||||||
→ Service.Login()
|
|
||||||
→ userRepo.FindByEmail():查找用户
|
|
||||||
→ 检查 Status != banned(封禁用户拒绝登录)
|
|
||||||
→ bcrypt.CompareHashAndPassword():密码校验
|
|
||||||
→ buildToken():生成 access JWT
|
|
||||||
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT
|
|
||||||
→ SetAuthCookies(accessToken, refreshToken)
|
|
||||||
→ 返回 JSON { success, message, data: user }
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误处理**:根据错误类型返回不同 HTTP 状态码和消息:
|
|
||||||
- 401:邮箱不存在 / 密码错误
|
|
||||||
- 403:用户已封禁
|
|
||||||
- 500:服务器内部错误
|
|
||||||
|
|
||||||
**涉及文件**:`auth_controller.go`(`Login()`)、`auth_service.go`(`Login()`、`ErrUserBanned`、`buildRefreshToken()`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. JWT 认证中间件
|
|
||||||
|
|
||||||
| 中间件 | 用途 | 注入上下文的值 |
|
|
||||||
|---|---|---|
|
|
||||||
| `AuthRequired` | 严格认证,失败返回 401 | `uid`、`email`、`username`、`role` |
|
|
||||||
| `AuthOptional` | 可选认证,有 token 就注入,没有也放行 | 同上(无 token 时不注入) |
|
|
||||||
|
|
||||||
**Cookie 配置**:
|
|
||||||
|
|
||||||
| Cookie | 名称 | HttpOnly | Secure | SameSite | 过期 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Access token | `mlb_token` | ✅ | ✅(debug 模式 HTTP) | Lax | 15 min |
|
|
||||||
| Refresh token | `mlb_refresh` | ✅ | ✅ | Lax | 30 day(记住我时) |
|
|
||||||
| 记住我标记 | `mlb_rm` | ❌ | ✅ | — | 30 day(记住我时) |
|
|
||||||
|
|
||||||
**中间件公开函数**:
|
|
||||||
|
|
||||||
| 函数 | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| `SetAuthCookies(c, accessToken, refreshToken, cfg)` | 设置 access + refresh(+ mlb_rm 标记) |
|
|
||||||
| `SetAccessCookie(c, accessToken, cfg)` | 仅刷新 access cookie(续期调用) |
|
|
||||||
| `ClearAuthCookies(c, cfg)` | 清除全部三个 Cookie(登出/过期) |
|
|
||||||
|
|
||||||
**涉及文件**:`internal/middleware/auth.go`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. 登录状态注入(SSR 模板渲染)
|
|
||||||
|
|
||||||
`internal/common/helper.go` 中的 `BuildPageData()`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func BuildPageData(c *gin.Context, extra gin.H) gin.H
|
|
||||||
```
|
|
||||||
|
|
||||||
- 将 `extra` 键值对复制到 `gin.H`
|
|
||||||
- 若 Gin 上下文中存在 `username`(由 `AuthOptional` 中间件注入),自动添加:
|
|
||||||
- `IsLoggedIn: true`
|
|
||||||
- `Username: "..."`
|
|
||||||
- 所有页面 Controller 通过此函数构建模板数据
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. 页面路由与模板
|
|
||||||
|
|
||||||
| 路由 | 方法 | 中间件 | 模板 | 说明 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `/` | GET | `AuthOptional` | `home/index.html` | 已登录显示首页,未登录重定向 `/auth/login` |
|
|
||||||
| `/auth/register` | GET | `AuthOptional` | `auth/register.html` | 已登录重定向 `/` |
|
|
||||||
| `/auth/login` | GET | `AuthOptional` | `auth/login.html` | 已登录重定向 `/` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. NAV 导航栏逻辑
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if .IsLoggedIn}}
|
|
||||||
→ 显示用户名链接(指向 /settings)+ "退出"按钮
|
|
||||||
{{else}}
|
|
||||||
→ 显示"登录"按钮(指向 /auth/login)
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**设计决策**:NAV 只显示"登录"按钮,不显示独立"注册"按钮。注册入口在登录页面内部提供(作为开发者社区,用户应能理解)。已登录用户可通过"退出"按钮清除所有 Cookie 并返回首页。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. 模板加载器重构
|
|
||||||
|
|
||||||
**问题**:原加载器使用 `info.Name()`(纯文件名),导致 `home/index.html` 注册为 `index.html`,与路由引用的 `home_index.html` 不匹配,模板渲染失败。
|
|
||||||
|
|
||||||
**解决**:改为使用相对路径作为模板名:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
layout/header.html
|
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
|
||||||
layout/nav.html
|
↓
|
||||||
layout/footer.html
|
Service 核心逻辑
|
||||||
auth/register.html
|
↓
|
||||||
auth/login.html
|
Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
|
||||||
home/index.html
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`{{template}}` 引用全部更新为这些相对路径名。
|
| 层次 | 职责 | 单文件上限 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| Router | 路由映射 + 依赖注入 | ≤ 60 行 |
|
||||||
|
| Controller | 参数绑定 + 调用服务 + 返回 | ≤ 120 行 |
|
||||||
|
| Middleware | 认证/授权/安全头/限流 | ≤ 60 行 |
|
||||||
|
| Service | 业务逻辑 | ≤ 300 行 |
|
||||||
|
| Repository | 纯数据库操作 | ≤ 200 行 |
|
||||||
|
| Model | 纯结构体 | ≤ 80 行 |
|
||||||
|
|
||||||
**涉及文件**:`internal/theme/loader.go`、所有 `.html` 模板文件中的 `{{template}}` 引用
|
## 二、已完成阶段
|
||||||
|
|
||||||
---
|
### Phase 1:基础设施 + 用户认证
|
||||||
|
|
||||||
### 11. "记住我" + 刷新令牌(Refresh Token)
|
- PostgreSQL + AutoMigrate + 部分唯一索引(软删除用户邮箱复用)
|
||||||
|
- Gin + 模板渲染 + 静态资源哈希缓存破坏
|
||||||
|
- 用户注册/登录/登出 + bcrypt(12) 密码哈希
|
||||||
|
- 服务端 Session(滑动窗口续期):Redis + 内存 FallbackStore 自动降级
|
||||||
|
- 全局 `SecurityHeaders` 中间件(CSP nonce、HSTS、X-Frame-Options、X-Content-Type-Options)
|
||||||
|
- `InjectSiteInfo` 中间件注入站点品牌信息到所有 SSR 页面
|
||||||
|
- 角色系统(user/moderator/admin/owner,OCP 可扩展)
|
||||||
|
- `BuildPageData()` / `BuildAdminPageData()` 统一模板数据注入
|
||||||
|
- CSRF Double Submit Cookie 保护
|
||||||
|
- 敏感操作限流(`SensitiveRateLimit`:改密/注销/签到)+ 登录限流(账户+IP 双维度滑动窗口)
|
||||||
|
- 维护模式(仅 owner 可访问,其余用户重定向登录页)
|
||||||
|
|
||||||
#### 11.1 设计目标
|
### Phase 2:公告系统
|
||||||
|
|
||||||
| 场景 | 行为 |
|
- 公告 CRUD + 管理后台管理页面
|
||||||
|---|---|
|
- 前台首页公告区域
|
||||||
| 用户勾选"记住我" | 双 Cookie:`mlb_token`(15min) + `mlb_refresh`(30d) |
|
|
||||||
| 用户未勾选 | 单 Cookie:`mlb_token`(15min),过期需重新登录 |
|
|
||||||
| 页面持续开着 | 前端每 10 分钟静默调用 `/api/auth/refresh` 续期 access token |
|
|
||||||
| 关闭浏览器 15+ min 后回来 | access token 过期 → JS 自动刷新 → 页面 reload 恢复登录态 |
|
|
||||||
| 30 天内任意时间回来 | refresh token 仍有效,自动静默恢复登录 |
|
|
||||||
|
|
||||||
#### 11.2 Cookie 架构
|
### Phase 3:文章属性
|
||||||
|
|
||||||
| Cookie 名 | 作用 | HttpOnly | JS 可读 | 过期时间 | 说明 |
|
- Post 模型:Visibility(public/private)、PostType(original/reprint)、ReprintSource
|
||||||
|---|---|---|---|---|---|
|
- 创作声明(7 种)、禁止转载标记
|
||||||
| `mlb_token` | access token | ✅ | ❌ | 15 min | JWT,含 `uid/email/username/role/exp/iat` |
|
- 文章置顶(category/global)+ 管理后台 Pin/Unpin API
|
||||||
| `mlb_refresh` | refresh token | ✅ | ❌ | 30 day(勾选记住我时) | JWT,含 `uid/email/username/role/exp/iat/purpose:"refresh"` |
|
- 原创/转载:写文章页条件联动
|
||||||
| `mlb_rm` | 记住我标记 | ❌ | ✅ | 30 day(勾选记住我时) | 值为 `"1"`,仅标记位,非凭据 |
|
- 模板函数 `declarationLabel()`
|
||||||
|
|
||||||
**设计决策 — `mlb_rm` 标记 Cookie**:
|
### Phase 4:分类系统 + 标签系统
|
||||||
- 本质是**非安全标记位**(not a credential),用于 JS 判断"是否该尝试刷新"
|
|
||||||
- 如果不存在(未勾选记住我),`common.js` 跳过所有刷新逻辑 → **不会产生 401 控制台报错**
|
|
||||||
- 值仅为 `"1"`,即使被 XSS 读取也无法用于任何认证操作
|
|
||||||
|
|
||||||
#### 11.3 配置 (`config.yaml` + `config.go`)
|
- Category 两级树形结构 + 管理后台页面
|
||||||
|
- Tag 系统 + 落地页 `/tags/{slug}`
|
||||||
|
- 写文章页分类选择 + 标签输入
|
||||||
|
|
||||||
```yaml
|
### Phase 5:定时发布
|
||||||
jwt:
|
|
||||||
access_expire: 15 # 分钟
|
- Post 模型 `ScheduledAt` + 审核逻辑
|
||||||
refresh_expire: 168 # 小时 (7 天) — 备用字段,当前未启用
|
- Scheduler goroutine 每分钟扫描发布到期文章
|
||||||
remember_expire: 720 # 小时 (30 天) — refresh token 有效期
|
- 前端日期时间选择器(min=now+30min)
|
||||||
|
|
||||||
|
### Phase 6:收尾与审计修复(2026-06-22)
|
||||||
|
|
||||||
|
**安全加固:**
|
||||||
|
| 项目 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| CSP | `'unsafe-inline'` 移除,nonce 替代(`'unsafe-eval'` 保留供 Vditor) |
|
||||||
|
| HSTS | 始终启用 `max-age=31536000; includeSubDomains; preload` |
|
||||||
|
| 密码强度 | 四级(low/medium/high/very_high)+ 连续字符检查 |
|
||||||
|
| 注册限流 | IP 维度 1min×5 次 |
|
||||||
|
| CSRF Token 日志 | 仅记录长度,不记明文 |
|
||||||
|
| DB 健康降级 | 5s ping + 全局 `dbHealthy` 标志 + 中间件拦截返回固定错误页 |
|
||||||
|
| 文件上传 | `image.Decode` 强制解码验证(替代 magic bytes) |
|
||||||
|
|
||||||
|
**代码质量:**
|
||||||
|
| 项目 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| golangci-lint | 57 告警 → 0,CI strict 模式零告警 |
|
||||||
|
| 包注释 | 16 个包全部添加 |
|
||||||
|
| 错误消息 | 全部小写开头 |
|
||||||
|
| errorlint | `==` 比较 → `errors.Is` |
|
||||||
|
| unused 代码 | `hasUnicode`/`energyStore`/`current`/`energyAdminUseCase` 移除 |
|
||||||
|
| gofumpt | 全量格式化 |
|
||||||
|
|
||||||
|
**架构重构:**
|
||||||
|
| 项目 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `router/api.go` | 198 行拆为 7 个域文件(auth/settings/posts/comments/reactions/social/studio) |
|
||||||
|
| 管理后台站点设置 | 单页 310 行拆为 4 个子页(brand/security/registration/content)+ 子菜单,旧路由 301 重定向 |
|
||||||
|
| 前台用户设置 | 1201 行拆为 6 个独立模板 + 6 个独立 JS 文件 |
|
||||||
|
| Controller Exp 读取 | `common.GetGinExp()` 封装,不再直接读 context |
|
||||||
|
| UID 解析 | `common.ParseUIDParam()` 统一,follow/admin controller 改用 |
|
||||||
|
| BaseModel 统一 | Post/Comment/Announcement/Category/Folder/FolderItem 嵌入 BaseModel,`primaryKey` → `primarykey` |
|
||||||
|
| 时区 | `config.yaml server.timezone` → `time.Local` 初始化 |
|
||||||
|
|
||||||
|
**Footer 合规信息:**
|
||||||
|
- `site_settings.go` 扩展 8 个字段(operator/ICP/公安/文网文/算法备案/隐私/条款)
|
||||||
|
- `footer.html` 渲染合规行(`safeHTML` 模板函数,空值跳过)
|
||||||
|
- 管理后台合规信息输入区
|
||||||
|
|
||||||
|
**注册准则控制:**
|
||||||
|
- 三层控制:`show_guidelines` / `force_guidelines` / `guidelines_timer`
|
||||||
|
- 管理后台注册设置区
|
||||||
|
|
||||||
|
## 三、文件清单
|
||||||
|
|
||||||
|
```text
|
||||||
|
mce/
|
||||||
|
├── cmd/server/main.go # 入口:配置加载、DB/Redis、模板、路由、调度器
|
||||||
|
├── config.yaml # 默认配置
|
||||||
|
├── .golangci.yml # golangci-lint v2 配置
|
||||||
|
├── .gitea/workflows/ci.yml # CI 流水线(strict 模式)
|
||||||
|
├── scripts/ci.sh # CI 脚本(支持 --strict)
|
||||||
|
├── Makefile # fmt/lint/ci/test 命令
|
||||||
|
├── internal/
|
||||||
|
│ ├── common/ # 通用工具:响应格式、Context 提取、模板数据构建、错误哨兵
|
||||||
|
│ ├── config/ # 配置加载 + SiteSettings DB 持久化内存缓存
|
||||||
|
│ ├── model/ # 所有数据模型(25 个)
|
||||||
|
│ ├── repository/ # GORM 数据访问(17 个 repo)
|
||||||
|
│ ├── service/ # 业务逻辑(18 个 service,含 repository.go 接口定义)
|
||||||
|
│ ├── controller/ # Gin handler(38 个 controller 文件)
|
||||||
|
│ │ └── admin/ # 管理后台 controller(7 个)
|
||||||
|
│ ├── middleware/ # 中间件:认证/CSRF/安全头/限流/维护/DB健康
|
||||||
|
│ ├── router/ # 路由注册 + 依赖注入(按域拆分)
|
||||||
|
│ ├── session/ # 服务端 Session:Redis + Memory + Fallback
|
||||||
|
│ ├── scheduler/ # 定时发布调度器
|
||||||
|
│ ├── cache/ # 首页缓存
|
||||||
|
│ └── theme/ # 模板加载器 + safeHTML 函数
|
||||||
|
├── templates/
|
||||||
|
│ ├── MetaLab-2026/ # 前端主题
|
||||||
|
│ │ ├── html/ # Go 模板(按功能分目录)
|
||||||
|
│ │ └── static/ # CSS/JS/图片 + Vditor 编辑器
|
||||||
|
│ ├── admin/ # 管理后台模板
|
||||||
|
│ └── shared/ # 共享静态资源
|
||||||
|
└── docs/ # 设计文档
|
||||||
```
|
```
|
||||||
|
|
||||||
`JWTConfig` 新增字段:
|
## 四、关键设计决策
|
||||||
```go
|
|
||||||
RememberExpire int `mapstructure:"remember_expire"` // 小时
|
|
||||||
```
|
|
||||||
**关键**:`mapstructure` 标签**不可省略**,否则 Viper 无法将 YAML 键映射到结构体字段,导致过期时间全为 0。
|
|
||||||
|
|
||||||
#### 11.4 请求 DTO (`dto.go`)
|
|
||||||
|
|
||||||
`LoginRequest` 和 `RegisterRequest` 都新增了 `RememberMe` 字段:
|
|
||||||
```go
|
|
||||||
RememberMe bool `json:"remember_me"`
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 11.5 后端业务逻辑 (`auth_service.go`)
|
|
||||||
|
|
||||||
**变更的方法:**
|
|
||||||
|
|
||||||
| 方法 | 旧返回值 | 新返回值 |
|
|
||||||
|---|---|---|
|
|
||||||
| `Register()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
|
|
||||||
| `Login()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
|
|
||||||
|
|
||||||
当 `req.RememberMe == false` 时,`refreshToken` 返回空字符串 `""`。
|
|
||||||
|
|
||||||
**新增方法:**
|
|
||||||
|
|
||||||
| 方法 | 签名 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `buildRefreshToken(user)` | `(string, error)` | 构建 30 天过期 JWT,`purpose` claim 为 `"refresh"` |
|
|
||||||
| `RefreshAccessToken(refreshTokenStr)` | `(string, error)` | 校验 refresh token → 返回新 access token |
|
|
||||||
|
|
||||||
**`RefreshAccessToken` 校验流程**:
|
|
||||||
```
|
|
||||||
1. 解析 refresh token JWT
|
|
||||||
→ 失败:ErrTokenExpired(登录已过期)
|
|
||||||
2. 校验 claims["purpose"] == "refresh"
|
|
||||||
→ 不匹配:ErrTokenInvalid(防止 access token 被用于刷新)
|
|
||||||
3. 从 claims 提取 uid/email/username/role → 构建新 access token
|
|
||||||
```
|
|
||||||
|
|
||||||
**安全点**:`purpose: "refresh"` 声明确保**access token 无法充当 refresh token**,防止短生命周期令牌被滥用为长期凭据。
|
|
||||||
|
|
||||||
#### 11.6 Session 恢复实现(前端 JS)
|
|
||||||
|
|
||||||
`common.js` 中的自动刷新逻辑:
|
|
||||||
|
|
||||||
```
|
|
||||||
页面加载(非 /auth/* 路径)
|
|
||||||
→ 检查 document.cookie 是否存在 mlb_rm
|
|
||||||
→ 不存在 → 跳过所有刷新(无 401 报错)
|
|
||||||
→ 存在 → fetch POST /api/auth/refresh
|
|
||||||
→ 成功:
|
|
||||||
1. 启动 10 分钟定时器
|
|
||||||
2. 页面未显示登录态(无 logoutBtn)?
|
|
||||||
→ sessionStorage 防重 → window.location.reload()
|
|
||||||
重载后 access token 存在 → SSR 正确渲染登录状态
|
|
||||||
→ 失败 → 停止定时器(用户需手动重新登录)
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键实现细节**:
|
|
||||||
- 用 `fetch()` 而非 `XMLHttpRequest`:`fetch` 对 HTTP 非 2xx 不会触发浏览器控制台静默日志
|
|
||||||
- `sessionStorage` 防无限循环:`mlb_refreshed` 标志确保 reload 只执行一次
|
|
||||||
- 不在认证页面执行:`/auth/login`、`/auth/register` 路径直接跳过
|
|
||||||
|
|
||||||
#### 11.7 界面
|
|
||||||
|
|
||||||
登录页和注册页各增加一个复选框:
|
|
||||||
```html
|
|
||||||
<div class="form-group remember-group">
|
|
||||||
<label class="remember-label">
|
|
||||||
<input type="checkbox" id="rememberMe">
|
|
||||||
保持登录 30 天
|
|
||||||
</label>
|
|
||||||
<span class="remember-hint">非共享设备推荐</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
**默认不勾选**——公共环境(教室、网吧、共享电脑)不会意外留下长期凭据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. 登出功能
|
|
||||||
|
|
||||||
#### 12.1 后端 API
|
|
||||||
|
|
||||||
**路由**:`POST /api/auth/logout`
|
|
||||||
|
|
||||||
**处理流程**(`auth_controller.go` → `Logout()`):
|
|
||||||
```
|
|
||||||
1. middleware.ClearAuthCookies(c, cfg)
|
|
||||||
→ 清除 mlb_token、mlb_refresh、mlb_rm 三个 Cookie(MaxAge=-1)
|
|
||||||
2. 返回 JSON { success: true, message: "已退出登录" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 12.2 前端
|
|
||||||
|
|
||||||
**NAV 导航栏**(`nav.html`):
|
|
||||||
```html
|
|
||||||
{{if .IsLoggedIn}}
|
|
||||||
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
|
|
||||||
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**JS 处理**(`common.js`):
|
|
||||||
```
|
|
||||||
点击"退出" → POST /api/auth/logout → Cookie 清除完毕 → 跳转首页
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. 刷新令牌 API
|
|
||||||
|
|
||||||
**路由**:`POST /api/auth/refresh`
|
|
||||||
|
|
||||||
**处理流程**(`auth_controller.go` → `RefreshToken()`):
|
|
||||||
```
|
|
||||||
1. 从 Cookie 读取 mlb_refresh
|
|
||||||
→ 为空:401 "请重新登录"
|
|
||||||
2. 调用 authService.RefreshAccessToken(refreshToken)
|
|
||||||
→ 失败:ClearAuthCookies() + 401 "登录已过期"
|
|
||||||
3. 成功:SetAccessCookie() → 下发新 access token
|
|
||||||
→ 返回 200 { success: true }
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:此端点**只刷新 access token**,不刷新 refresh token。refresh token 有 30 天固定有效期,到期后用户需手动登录。这避免了无限续期带来的安全隐患(长期不活跃的 refresh token 最终会自然过期)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 14. 个人设置页与头像上传
|
|
||||||
|
|
||||||
**涉及文件**:参考 [`docs/settings.md`](settings.md) 获取完整实现文档。
|
|
||||||
|
|
||||||
**功能概要**:
|
|
||||||
|
|
||||||
| 功能 | 实现文件 |
|
|
||||||
|------|----------|
|
|
||||||
| 用户名编辑 | `auth_service.go` — `UpdateProfile()`,正则 `[\p{Han}a-zA-Z0-9_-]` 白名单 |
|
|
||||||
| 个性签名编辑 | 同上,0-128 字符 |
|
|
||||||
| 头像处理管线 | `avatar_service.go`(新文件)— 解码→裁切→CatmullRom缩放→WebP二分编码→原子写入 |
|
|
||||||
| 前端裁切弹窗 | `settings/index.html` — 固定框 + 图片平移/缩放 + 坐标映射 |
|
|
||||||
| CSS样式 | `settings.css` — 浅色主题裁切弹窗、Toast通知、输入框样式 |
|
|
||||||
| CSP放宽 | `security.go` — `img-src 'self' data:`(裁切预览用data URI) |
|
|
||||||
| 静态路由 | `main.go` — `/uploads/avatars` → `./storage/uploads/avatars` |
|
|
||||||
|
|
||||||
**设计决策**:
|
|
||||||
|
|
||||||
| 决策 | 选择 | 原因 |
|
| 决策 | 选择 | 原因 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 用户名校验 | 白名单 regex | 比黑名单更安全,直接排除 `<>&"'` 等 XSS 向量 |
|
| Web 框架 | Gin | 性能好,生态成熟 |
|
||||||
| 字符计数 | `utf8.RuneCountInString` | 确保与 PostgreSQL `varchar(16)` 语义一致 |
|
| ORM | GORM v2 | AutoMigrate + 软删除 + 回调 |
|
||||||
| 头像编码 | WebP + 二分查找质量 | 目标 ≤100KB,兼顾质量与体积 |
|
| 数据库 | PostgreSQL | 社区级应用首选 |
|
||||||
| 原子写入 | `.tmp` → `os.Rename` | 防止写入中断产生损坏文件 |
|
| 会话 | 服务端 Session | 支持踢出设备、登录管理 |
|
||||||
| 裁切方式 | 固定框 + 图片平移/缩放 | 框大小不变,zoom使框覆盖更小区域,真正支持精确定位 |
|
| 存储 | Redis + Memory FallbackStore | Redis 不可达自动降级,恢复自动切回 |
|
||||||
| GIF支持 | 不支持 | 裁切后无法保持动画,无实际用途 |
|
| 模板 | Go html/template | 无前端框架依赖,SSR 直出 |
|
||||||
| 最小分辨率 | 128×128 | 防止马赛克图被拉升到 512×512 |
|
| 编辑器 | Vditor v3.11.2(本地部署) | Markdown 所见即所得 |
|
||||||
| CSP data: URI | 显式允许 img-src | 裁切预览使用 FileReader → data URI |
|
| 图表 | Chart.js v4.4.0(本地化) | 趋势可视化 |
|
||||||
|
| 密码 | bcrypt(12) | 行业标准 |
|
||||||
|
| 架构 | 分层 + ISP 接口隔离 | 依赖倒置,Controller 不依赖完整 Service |
|
||||||
|
| 重构 | 不向后兼容 | 全新项目,废弃旧代码不做兼容层 |
|
||||||
|
| Lint | golangci-lint v2.12.2(17 个 linter) | CI strict 模式零告警 |
|
||||||
|
|
||||||
---
|
## 五、数据模型一览
|
||||||
|
|
||||||
## 二、解决方案建议
|
| 模型 | BaseModel | 说明 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| User | ✅ | 用户(邮箱/用户名/密码/角色/状态/经验) |
|
||||||
|
| Post | ✅ | 帖子/文章(标题/正文/状态/分类/标签/属性/计数器) |
|
||||||
|
| Comment | ✅ | 评论(含 is_deleted 自定义软删除) |
|
||||||
|
| AuditSubmission | ✅ | 审核记录 |
|
||||||
|
| Notification | ✅ | 通知 |
|
||||||
|
| Announcement | ✅ | 公告 |
|
||||||
|
| Category | ✅ | 文章分类(两级树形) |
|
||||||
|
| Folder | ✅ | 收藏夹 |
|
||||||
|
| FolderItem | ✅ | 收藏记录 |
|
||||||
|
| Tag | ❌ | 标签 |
|
||||||
|
| PostTag | ❌ | 文章-标签关联(联合主键) |
|
||||||
|
| PostLike | ❌ | 点赞(联合主键) |
|
||||||
|
| PostDislike | ❌ | 踩(联合主键) |
|
||||||
|
| UserFollow | ❌ | 关注关系(联合主键) |
|
||||||
|
| CommentMention | ❌ | @提及映射 |
|
||||||
|
| SiteSetting | ❌ | 站点设置(Key-Value) |
|
||||||
|
| EnergyLog / FundLog / PostEnergizeLog | ❌ | 域能/资金流日志 |
|
||||||
|
| UserCheckIn / UserTask | ✅ | 签到/任务 |
|
||||||
|
| DailyExpSummary / DailyLikeSummary | ❌ | 每日统计聚合 |
|
||||||
|
| PostReadLog / PostGuestReadLog | ❌ | 阅读日志 |
|
||||||
|
| CommunityFund | ❌ | 公户余额(单行表) |
|
||||||
|
| Level | ❌ | 等级配置(内存) |
|
||||||
|
|
||||||
### 2.1 PostgreSQL 密码认证(问题一记录)
|
## 六、待做事项
|
||||||
|
|
||||||
**系统信息**:
|
| 优先级 | 功能 | 状态 |
|
||||||
- 分布:Linux Mint
|
|--------|------|------|
|
||||||
- 数据库用户:`metazone`
|
| P2 | B8: BaseModel 主键列名不一致 | ✅ 已完成 |
|
||||||
- 连接方式:TCP `127.0.0.1:5432`(非 Unix socket)
|
| P3 | 集成测试 | ⬜ 暂缓 |
|
||||||
- 认证方式:`scram-sha-256`
|
| P3 | `git tag v0.1.0-rc1` | ⬜ 暂缓 |
|
||||||
- 密码:`MetalabDev2026!`(存储于 `.env` → `DATABASE_PASSWORD`)
|
| — | 黑名单系统 | ⬜ 未来迭代 |
|
||||||
- 数据库:`metalab_dev`
|
| — | 首页推荐算法 | ⬜ 未来迭代 |
|
||||||
|
| — | 模板审计修复 | ⬜ 未来迭代 |
|
||||||
**验证命令**:
|
|
||||||
```bash
|
|
||||||
# 测试连接
|
|
||||||
PGPASSWORD='MetalabDev2026!' psql -h 127.0.0.1 -U metazone -d metalab_dev -c "SELECT current_user, version();"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 .env 文件结构
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 必填
|
|
||||||
DATABASE_PASSWORD=MetalabDev2026!
|
|
||||||
JWT_SECRET=p2BVUsELbny4W4sYNvoyFesyglD2TlPRT+bzpDk8Dq/E2s9tZFzT6jC8N3AvQpzE
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env` 已加入 `.gitignore`,提供 `.env.example` 模板文件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、代码规范新增/修改
|
|
||||||
|
|
||||||
### 3.1 新增规范
|
|
||||||
|
|
||||||
| 编号 | 规范 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| C01 | **`BuildPageData()` 统一模板数据注入** | 所有页面 Controller 必须通过此函数构建 `gin.H`,禁止手动拼装 `IsLoggedIn`/`Username` |
|
|
||||||
| C02 | **Cookie 认证优先** | 使用 HttpOnly Cookie(`mlb_token`)传递 JWT,而非 `Authorization` Header,兼顾安全与 SSR |
|
|
||||||
| C03 | **主键列名:`uid`** | Go 侧字段名 `ID`,GORM 标签 `column:uid`,JSON 序列化 `"uid"` |
|
|
||||||
| C04 | **用户名:10 位随机 `[a-z0-9]`** | 使用 `crypto/rand`(非 `math/rand`),20 次重试去重 |
|
|
||||||
| C05 | **配置 env 注入模式** | `.env` 文件通过 `os.Setenv()` 注入环境变量,Viper 通过 `BindEnv` + `AutomaticEnv` 读取。不再使用 viper 的 `.env` 文件加载 |
|
|
||||||
| C06 | **模板名:相对路径** | `layout/header.html`、`auth/register.html` 等。禁止用纯文件名避免冲突 |
|
|
||||||
|
|
||||||
### 3.2 修改的已有规范
|
|
||||||
|
|
||||||
| 编号 | 变更 | 旧 | 新 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| M01 | 用户标识字段 | `nickname`(非唯一) | `username`(全局唯一,varchar(16)) |
|
|
||||||
| M02 | JSON 序列化字段名 | `"id"` | `"uid"` |
|
|
||||||
| M03 | `BaseModel.ID` GORM 列名 | `id`(默认) | `uid`(`column:uid`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、当前文件清单
|
|
||||||
|
|
||||||
### 后端(Go)
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/
|
|
||||||
├── config/
|
|
||||||
│ └── config.go # Config 结构体 + Load() + loadEnvFile() + DSN()
|
|
||||||
│ # JWTConfig 含 AccessExpire/RefreshExpire/RememberExpire
|
|
||||||
├── model/
|
|
||||||
│ ├── common.go # BaseModel(uid 主键 + 时间戳 + 软删除)
|
|
||||||
│ ├── user.go # User 结构体 + Role/Status 常量
|
|
||||||
│ └── dto.go # RegisterRequest / LoginRequest 含 RememberMe
|
|
||||||
├── repository/
|
|
||||||
│ └── user_repo.go # UserRepo(Create/FindByEmail/FindByID/FindByUsername/ExistsByEmail/ExistsByUsername)
|
|
||||||
├── service/
|
|
||||||
│ └── auth_service.go # AuthService(Login/Register/RefreshAccessToken + buildToken/buildRefreshToken + 8 个错误哨兵)
|
|
||||||
├── controller/
|
|
||||||
│ └── auth_controller.go # AuthController(RegisterPage/LoginPage/Register/Login/Logout/RefreshToken/CheckEmail)
|
|
||||||
├── middleware/
|
|
||||||
│ └── auth.go # AuthRequired/AuthOptional/SetAuthCookies/SetAccessCookie/ClearAuthCookies/parseToken
|
|
||||||
│ # 三个 Cookie:mlb_token / mlb_refresh / mlb_rm
|
|
||||||
├── router/
|
|
||||||
│ └── router.go # Setup() — 路由注册 + 依赖注入链(含 /auth/logout、/auth/refresh)
|
|
||||||
├── common/
|
|
||||||
│ └── helper.go # BuildPageData() — SSR 登录状态注入
|
|
||||||
└── theme/
|
|
||||||
└── loader.go # LoadTemplates() — 相对路径模板加载 + LoadContent()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端(模板 + 静态资源)
|
|
||||||
|
|
||||||
```
|
|
||||||
templates/MetaLab-2026/
|
|
||||||
├── html/
|
|
||||||
│ ├── layout/
|
|
||||||
│ │ ├── header.html # HTML head(title/meta/CSS 引入)
|
|
||||||
│ │ ├── nav.html # 导航栏(已登录显示用户名+退出按钮 / 未登录显示登录按钮)
|
|
||||||
│ │ └── footer.html # 页脚
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ ├── register.html # 注册页(含准则模态框 + 60s 倒计时 + 记住我复选框)
|
|
||||||
│ │ └── login.html # 登录页(简洁表单 + 记住我复选框)
|
|
||||||
│ └── home/
|
|
||||||
│ └── index.html # 首页(Hero 区域 + 欢迎信息)
|
|
||||||
├── static/
|
|
||||||
│ ├── css/
|
|
||||||
│ │ ├── common.css # 全局样式 + NAV 样式(含 .nav-logout)
|
|
||||||
│ │ └── auth.css # 认证页样式(表单、模态框、Toast、.remember-group/.remember-label/.remember-hint)
|
|
||||||
│ └── js/
|
|
||||||
│ ├── common.js # 通用工具(Toast + 登出 + 自动令牌刷新 + mlb_rm 检测)
|
|
||||||
│ ├── register.js # 注册页逻辑(表单验证/AJAX 提交/准则模态框/倒计时/记住我)
|
|
||||||
│ └── login.js # 登录页逻辑(表单验证/AJAX 提交/Toast/记住我)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
|
|
||||||
```
|
|
||||||
config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCrypt)
|
|
||||||
.env # 敏感信息(密码/JWT密钥)→ .gitignore
|
|
||||||
.env.example # 敏感信息模板(无真实值)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、待做事项
|
|
||||||
|
|
||||||
按优先级排序:
|
|
||||||
|
|
||||||
| 优先级 | 功能 | 状态 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **P0** | 邮箱验证码 | 待做 | 注册时发送验证码(`auth/verify-email`),预留了路由和接口 |
|
|
||||||
| ~~**P0**~~ | ~~登出功能~~ | ✅ 已完成 | `POST /api/auth/logout` → 清除三个 Cookie + 跳转首页 |
|
|
||||||
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
|
|
||||||
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
|
|
||||||
| **P1** | 用户设置页 | ✅ 已完成 | `/settings/profile`、`/settings/account` — 用户名修改、个性签名、头像上传与裁切 |
|
|
||||||
| **P2** | 管理后台 | 待做 | `/admin` — 用户管理、内容审核、主题切换 |
|
|
||||||
| ~~**P2**~~ | ~~刷新令牌~~ | ✅ 已完成 | 记住我 + access/refresh 双 Token + 自动续期 + 登出 |
|
|
||||||
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` → `MetaLab-2027` 切换 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、约定速查
|
|
||||||
|
|
||||||
| 类别 | 约定 |
|
|
||||||
|---|---|
|
|
||||||
| 数据库主键 | Go: `ID uint`,DB: `uid`(bigint 自增),JSON: `"uid"` |
|
|
||||||
| Access Token Cookie | `mlb_token`,JWT(HS256),15 min,HttpOnly + Secure + SameSite=Lax |
|
|
||||||
| Refresh Token Cookie | `mlb_refresh`,JWT(HS256),30 day(记住我时),HttpOnly + Secure + SameSite=Lax,含 `purpose:"refresh"` 声明 |
|
|
||||||
| 记住我标记 Cookie | `mlb_rm`,值 `"1"`,30 day(记住我时),非 HttpOnly(JS 可读),非凭据 |
|
|
||||||
| 密码哈希 | bcrypt(12),Go 字段 `PasswordHash`,JSON 不暴露 |
|
|
||||||
| 用户名 | 10 位 `[a-z0-9]`,`crypto/rand` 生成,全局唯一 |
|
|
||||||
| 角色 | `user` / `moderator` / `admin`(string,非 bool) |
|
|
||||||
| 配置 | `.env` → `os.Setenv` → viper `BindEnv` + `AutomaticEnv` |
|
|
||||||
| 模板名 | 相对路径:`layout/header.html`、`auth/register.html` |
|
|
||||||
| API 响应 | `common.Ok(ctx, data)` / `common.Error(ctx, code, msg)` |
|
|
||||||
| 页面数据 | `common.BuildPageData(c, extra)` — 自动注入 `IsLoggedIn` / `Username` |
|
|
||||||
| 包依赖方向 | Router → Controller → Service → Repository → Model(不可反向) |
|
|
||||||
| 令牌刷新 | `POST /api/auth/refresh`,前端每 10 min 静默调用 |
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
|
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
||||||
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
||||||
@ -19,3 +23,28 @@ func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGinExp 从 Gin context 中提取当前用户的经验值
|
||||||
|
// 如果用户未登录或 exp 不存在,ok 返回 false
|
||||||
|
func GetGinExp(c *gin.Context) (exp int, ok bool) {
|
||||||
|
if v, exists := c.Get("exp"); exists {
|
||||||
|
if e, isInt := v.(int); isInt {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUIDParam 从 URL 路径参数中提取 uint 类型的 UID
|
||||||
|
// 统一 UID 解析逻辑,避免各 Controller 重复 strconv.ParseUint
|
||||||
|
func ParseUIDParam(c *gin.Context, paramName string) (uint, error) {
|
||||||
|
s := c.Param(paramName)
|
||||||
|
if s == "" {
|
||||||
|
return 0, ErrInvalidParam
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidParam
|
||||||
|
}
|
||||||
|
return uint(v), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -58,4 +58,7 @@ var (
|
|||||||
// 评论相关
|
// 评论相关
|
||||||
ErrCommentNotFound = errors.New("评论不存在")
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
ErrCommentEmpty = errors.New("评论内容不能为空")
|
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||||
|
|
||||||
|
// 通用参数校验
|
||||||
|
ErrInvalidParam = errors.New("参数无效")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"mime/multipart"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SaveUploadedFile 将 multipart.File 内容写入目标路径
|
|
||||||
func SaveUploadedFile(file multipart.File, dst string) error {
|
|
||||||
out, err := os.Create(dst) //nolint:gosec // dst 为服务器内部路径
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = out.Close() }()
|
|
||||||
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := file.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
|
||||||
return writeErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
@ -19,22 +20,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// assetHashes 静态资源文件哈希表(URL 路径 → 短哈希,文件不变哈希不变)
|
// assetHashes 静态资源文件哈希表(URL 路径 → 短哈希,文件不变哈希不变)
|
||||||
var assetHashes map[string]string
|
var (
|
||||||
|
assetHashes map[string]string
|
||||||
|
assetMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
|
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
|
||||||
|
// 构建本地 map 后原子替换,避免并发读写冲突
|
||||||
func ComputeAssetHashes(templateDir string) {
|
func ComputeAssetHashes(templateDir string) {
|
||||||
assetHashes = make(map[string]string)
|
mu := make(map[string]string)
|
||||||
// 前端主题:templates/MetaLab-2026/static → URL /static/
|
// 前端主题:templates/MetaLab-2026/static → URL /static/
|
||||||
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
|
walkStaticDirInto(mu, filepath.Join(templateDir, "static"), "/static")
|
||||||
// 共享资源:templates/shared/static → URL /shared/static/
|
// 共享资源:templates/shared/static → URL /shared/static/
|
||||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
||||||
// 管理后台:templates/admin/static → URL /admin/static/
|
// 管理后台:templates/admin/static → URL /admin/static/
|
||||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
||||||
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
|
|
||||||
|
assetMu.Lock()
|
||||||
|
assetHashes = mu
|
||||||
|
assetMu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[AssetHashes] computed %d file hashes", len(mu))
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32,存入 assetHashes
|
// walkStaticDirInto 遍历目录,计算每个 .js/.css 的 CRC32,存入目标 map
|
||||||
func walkStaticDir(dir, urlPrefix string) {
|
func walkStaticDirInto(dst map[string]string, dir, urlPrefix string) {
|
||||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil || d.IsDir() {
|
if err != nil || d.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
@ -52,14 +62,17 @@ func walkStaticDir(dir, urlPrefix string) {
|
|||||||
// 生成 URL 路径:urlPrefix + 相对于 dir 的子路径
|
// 生成 URL 路径:urlPrefix + 相对于 dir 的子路径
|
||||||
rel, _ := filepath.Rel(dir, path)
|
rel, _ := filepath.Rel(dir, path)
|
||||||
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
|
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
|
||||||
assetHashes[urlPath] = shortHash
|
dst[urlPath] = shortHash
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssetV 返回指定静态资源的哈希版本号(模板函数)
|
// AssetV 返回指定静态资源的哈希版本号(模板函数,并发安全)
|
||||||
func AssetV(path string) string {
|
func AssetV(path string) string {
|
||||||
if h, ok := assetHashes[path]; ok {
|
assetMu.RLock()
|
||||||
|
h, ok := assetHashes[path]
|
||||||
|
assetMu.RUnlock()
|
||||||
|
if ok {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type ServerConfig struct {
|
|||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
Mode string `mapstructure:"mode"`
|
Mode string `mapstructure:"mode"`
|
||||||
CookieSecure bool `mapstructure:"cookie_secure"`
|
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||||
|
Timezone string `mapstructure:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@ -136,10 +137,24 @@ func loadEnvFile(path string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindEnvOverride 将环境变量映射到 config 的嵌套键
|
// bindEnvOverride 将环境变量映射到 config 的嵌套键(.env 覆盖 config.yaml 默认值)
|
||||||
func bindEnvOverride(v *viper.Viper) {
|
func bindEnvOverride(v *viper.Viper) {
|
||||||
|
// 数据库
|
||||||
|
_ = v.BindEnv("database.host", "DATABASE_HOST")
|
||||||
|
_ = v.BindEnv("database.port", "DATABASE_PORT")
|
||||||
|
_ = v.BindEnv("database.user", "DATABASE_USER")
|
||||||
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
||||||
|
_ = v.BindEnv("database.dbname", "DATABASE_DBNAME")
|
||||||
|
_ = v.BindEnv("database.sslmode", "DATABASE_SSLMODE")
|
||||||
|
// Redis
|
||||||
|
_ = v.BindEnv("redis.enabled", "REDIS_ENABLED")
|
||||||
|
_ = v.BindEnv("redis.host", "REDIS_HOST")
|
||||||
|
_ = v.BindEnv("redis.port", "REDIS_PORT")
|
||||||
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
|
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
|
||||||
|
_ = v.BindEnv("redis.db", "REDIS_DB")
|
||||||
|
// 服务
|
||||||
|
_ = v.BindEnv("server.port", "SERVER_PORT")
|
||||||
|
_ = v.BindEnv("server.mode", "SERVER_MODE")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SiteSettings 站点设置管理器(DB 持久化 + 内存缓存,不重启生效)
|
// SiteSettings 站点设置管理器(DB 持久化 + 内存缓存,不重启生效)
|
||||||
@ -69,15 +71,22 @@ func (s *SiteSettings) GetBool(key string, defaultVal bool) bool {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set 写入设置(同步写 DB + 更新内存)
|
// Set 写入设置(同步写 DB + 更新内存,使用 UPSERT 兼容首次保存)
|
||||||
func (s *SiteSettings) Set(key, value string) error {
|
func (s *SiteSettings) Set(key, value string) error {
|
||||||
entry := model.SiteSetting{Key: key, Value: value}
|
entry := model.SiteSetting{Key: key, Value: value}
|
||||||
if err := s.db.Save(&entry).Error; err != nil {
|
result := s.db.Clauses(clause.OnConflict{
|
||||||
return err
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||||
|
}).Create(&entry)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[SiteSettings] Set FAIL key=%s value=%s err=%v", key, value, result.Error)
|
||||||
|
return result.Error
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
oldVal, existed := s.data[key]
|
||||||
s.data[key] = value
|
s.data[key] = value
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
log.Printf("[SiteSettings] Set OK key=%s old=%q new=%q existed=%v rows=%d", key, oldVal, value, existed, result.RowsAffected)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,3 +199,8 @@ func (s *SiteSettings) GuidelinesTimer() int {
|
|||||||
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
||||||
return s.GetBool("maintenance.enabled", false)
|
return s.GetBool("maintenance.enabled", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeName 当前选用的主题目录名,默认 "MetaLab-2026"
|
||||||
|
func (s *SiteSettings) ThemeName() string {
|
||||||
|
return s.Get("site.theme", "MetaLab-2026")
|
||||||
|
}
|
||||||
|
|||||||
@ -134,11 +134,7 @@ func (ac *AdminController) ResetToken(c *gin.Context) {
|
|||||||
|
|
||||||
// parseUIDParam 从 URL 路径参数解析 uid
|
// parseUIDParam 从 URL 路径参数解析 uid
|
||||||
func parseUIDParam(c *gin.Context) (uint, error) {
|
func parseUIDParam(c *gin.Context) (uint, error) {
|
||||||
uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
return common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return uint(uid), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleServiceError 统一处理 service 层返回的错误
|
// handleServiceError 统一处理 service 层返回的错误
|
||||||
|
|||||||
@ -23,12 +23,13 @@ type auditUseCase interface {
|
|||||||
Reject(reviewerID uint, submissionID uint, reason string) error
|
Reject(reviewerID uint, submissionID uint, reason string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:4 个方法)
|
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:5 个方法)
|
||||||
type siteSettingUseCase interface {
|
type siteSettingUseCase interface {
|
||||||
GetSettings() map[string]string
|
GetSettings() map[string]string
|
||||||
UpdateSetting(key, value string) error
|
UpdateSetting(key, value string) error
|
||||||
UpdateBoolSetting(key string, value bool) error
|
UpdateBoolSetting(key string, value bool) error
|
||||||
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
||||||
|
UpdateTheme(themeName string, reload service.ThemeReloader) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/mce/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
"metazone.cc/mce/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -14,6 +16,7 @@ import (
|
|||||||
type SiteSettingController struct {
|
type SiteSettingController struct {
|
||||||
settingService siteSettingUseCase
|
settingService siteSettingUseCase
|
||||||
defaults *service.AuditDefaults
|
defaults *service.AuditDefaults
|
||||||
|
themeReload service.ThemeReloader // 主题重载回调(切换后实时生效)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSiteSettingController 构造函数
|
// NewSiteSettingController 构造函数
|
||||||
@ -21,12 +24,63 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
|
|||||||
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteSettingsPage 站点设置管理页面
|
// SetThemeReloader 注入主题重载回调(main.go 中设置)
|
||||||
|
func (sc *SiteSettingController) SetThemeReloader(reload service.ThemeReloader) {
|
||||||
|
sc.themeReload = reload
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
|
||||||
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
|
c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand")
|
||||||
"Title": "站点设置",
|
}
|
||||||
|
|
||||||
|
// BrandPage 品牌与合规设置页
|
||||||
|
func (sc *SiteSettingController) BrandPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/brand.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 品牌与合规",
|
||||||
"ExtraCSS": "/admin/static/css/site-settings.css",
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
"ExtraJS": "/admin/static/js/site-settings.js",
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "brand",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityPage 安全与维护设置页
|
||||||
|
func (sc *SiteSettingController) SecurityPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/security.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 安全与维护",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "security",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationPage 注册策略设置页
|
||||||
|
func (sc *SiteSettingController) RegistrationPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/registration.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 注册策略",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "registration",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentPage 审核与内容设置页
|
||||||
|
func (sc *SiteSettingController) ContentPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/content.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 审核与内容",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "content",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemePage 外观主题设置页
|
||||||
|
func (sc *SiteSettingController) ThemePage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/theme.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 外观主题",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings-theme.js",
|
||||||
|
"ActiveSetting": "theme",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +88,10 @@ func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
|||||||
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
||||||
all := sc.settingService.GetSettings()
|
all := sc.settingService.GetSettings()
|
||||||
audit := sc.settingService.GetAuditSettings(sc.defaults)
|
audit := sc.settingService.GetAuditSettings(sc.defaults)
|
||||||
|
log.Printf("[GetSettings] all=%d keys audit=%+v", len(all), audit)
|
||||||
|
if v, ok := all["registration.enabled"]; ok {
|
||||||
|
log.Printf("[GetSettings] registration.enabled=%q", v)
|
||||||
|
}
|
||||||
common.Ok(c, gin.H{
|
common.Ok(c, gin.H{
|
||||||
"all": all,
|
"all": all,
|
||||||
"audit": audit,
|
"audit": audit,
|
||||||
@ -110,3 +168,60 @@ func (sc *SiteSettingController) GetBoolSetting(c *gin.Context) {
|
|||||||
common.Ok(c, gin.H{"value": d})
|
common.Ok(c, gin.H{"value": d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetThemes 获取所有可用主题列表
|
||||||
|
func (sc *SiteSettingController) GetThemes(c *gin.Context) {
|
||||||
|
themes, err := theme.DiscoverThemes("templates")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if themes == nil {
|
||||||
|
themes = []theme.ThemeInfo{}
|
||||||
|
}
|
||||||
|
// 读取当前主题
|
||||||
|
all := sc.settingService.GetSettings()
|
||||||
|
currentTheme := "MetaLab-2026"
|
||||||
|
if t, ok := all["site.theme"]; ok && t != "" {
|
||||||
|
currentTheme = t
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"themes": themes,
|
||||||
|
"current": currentTheme,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTheme 切换主题
|
||||||
|
func (sc *SiteSettingController) UpdateTheme(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Theme string `json:"theme" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证主题是否存在
|
||||||
|
themes, err := theme.DiscoverThemes("templates")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, t := range themes {
|
||||||
|
if t.Dir == req.Theme {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
common.Error(c, http.StatusBadRequest, "主题不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.settingService.UpdateTheme(req.Theme, sc.themeReload); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "主题切换失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "主题已切换")
|
||||||
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
|||||||
if guidelinesHTML != "" {
|
if guidelinesHTML != "" {
|
||||||
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
||||||
} else {
|
} else {
|
||||||
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
content, err := theme.LoadContent("templates/" + ac.siteSettings.ThemeName() + "/guidelines.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -28,12 +28,11 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LV4+ 检查(Exp ≥ 1500)
|
// LV4+ 检查(Exp ≥ 1500)
|
||||||
expVal, exists := c.Get("exp")
|
exp, ok := common.GetGinExp(c)
|
||||||
if !exists {
|
if !ok {
|
||||||
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exp := expVal.(int)
|
|
||||||
if exp < 1500 {
|
if exp < 1500 {
|
||||||
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -29,13 +29,13 @@ func (fc *FollowController) Toggle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
|
isFollowing, err := fc.followSvc.Toggle(uid, targetUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "不能关注自己" {
|
if err.Error() == "不能关注自己" {
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
@ -46,7 +46,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取最新状态
|
// 获取最新状态
|
||||||
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
|
status, _ := fc.followSvc.GetStatus(uid, targetUID)
|
||||||
|
|
||||||
common.Ok(c, gin.H{
|
common.Ok(c, gin.H{
|
||||||
"is_following": isFollowing,
|
"is_following": isFollowing,
|
||||||
@ -56,7 +56,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
|
|||||||
|
|
||||||
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
|
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
|
||||||
func (fc *FollowController) GetStatus(c *gin.Context) {
|
func (fc *FollowController) GetStatus(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -64,7 +64,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
|
|||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
|
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
|
||||||
|
|
||||||
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
|
status, err := fc.followSvc.GetStatus(uid, targetUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
return
|
return
|
||||||
@ -75,7 +75,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
|
|||||||
|
|
||||||
// Followers 粉丝列表 GET /api/users/:uid/followers
|
// Followers 粉丝列表 GET /api/users/:uid/followers
|
||||||
func (fc *FollowController) Followers(c *gin.Context) {
|
func (fc *FollowController) Followers(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -86,7 +86,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
|
|||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c) // 允许未登录
|
uid, _, _ := common.GetGinUser(c) // 允许未登录
|
||||||
|
|
||||||
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
|
result, err := fc.followSvc.ListFollowers(targetUID, uid, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
return
|
return
|
||||||
@ -97,7 +97,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
|
|||||||
|
|
||||||
// Following 关注列表 GET /api/users/:uid/following
|
// Following 关注列表 GET /api/users/:uid/following
|
||||||
func (fc *FollowController) Following(c *gin.Context) {
|
func (fc *FollowController) Following(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -108,7 +108,7 @@ func (fc *FollowController) Following(c *gin.Context) {
|
|||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c)
|
uid, _, _ := common.GetGinUser(c)
|
||||||
|
|
||||||
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
|
result, err := fc.followSvc.ListFollowing(targetUID, uid, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
return
|
return
|
||||||
@ -129,7 +129,7 @@ func NewFollowPageController(followSvc followUseCase) *FollowPageController {
|
|||||||
|
|
||||||
// FollowersPage 粉丝列表页面
|
// FollowersPage 粉丝列表页面
|
||||||
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -138,7 +138,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c)
|
uid, _, _ := common.GetGinUser(c)
|
||||||
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
|
result, err := fpc.followSvc.ListFollowers(targetUID, uid, page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
}
|
}
|
||||||
@ -159,7 +159,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
|||||||
|
|
||||||
// FollowingPage 关注列表页面
|
// FollowingPage 关注列表页面
|
||||||
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -168,7 +168,7 @@ func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c)
|
uid, _, _ := common.GetGinUser(c)
|
||||||
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
|
result, err := fpc.followSvc.ListFollowing(targetUID, uid, page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
data := gin.H{
|
data := gin.H{
|
||||||
"Title": "个人设置",
|
"Title": "个人设置",
|
||||||
"ExtraCSS": "/static/css/settings.css",
|
"ExtraCSS": "/static/css/settings.css",
|
||||||
|
"ExtraJS": "/static/js/settings-" + tab + ".js",
|
||||||
"ActiveTab": tab,
|
"ActiveTab": tab,
|
||||||
"User": user,
|
"User": user,
|
||||||
"RoleName": model.RoleDisplayNames[user.Role],
|
"RoleName": model.RoleDisplayNames[user.Role],
|
||||||
@ -113,5 +114,22 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
data["CurrentSID"] = currentSID
|
data["CurrentSID"] = currentSID
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
|
// 按 tab 渲染独立模板
|
||||||
|
tplName := "settings/index.html"
|
||||||
|
switch tab {
|
||||||
|
case "profile":
|
||||||
|
tplName = "settings/profile.html"
|
||||||
|
case "account":
|
||||||
|
tplName = "settings/account.html"
|
||||||
|
case "sessions":
|
||||||
|
tplName = "settings/sessions.html"
|
||||||
|
case "energy":
|
||||||
|
tplName = "settings/energy.html"
|
||||||
|
case "favorites":
|
||||||
|
tplName = "settings/favorites.html"
|
||||||
|
case "notify":
|
||||||
|
tplName = "settings/notify.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, tplName, common.BuildPageData(c, data))
|
||||||
}
|
}
|
||||||
|
|||||||
84
internal/middleware/db_health.go
Normal file
84
internal/middleware/db_health.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dbHealthy 全局数据库健康状态(原子读写,默认为健康)
|
||||||
|
var dbHealthy atomic.Bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dbHealthy.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDBHealthCheck 启动定时 DB 健康检测(在 main goroutine 中调用)
|
||||||
|
// 每 5 秒 ping 一次,失败则标记不健康,恢复则重新标记健康
|
||||||
|
func StartDBHealthCheck(db *gorm.DB) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
dbHealthy.Store(false)
|
||||||
|
log.Printf("[DBHealth] 获取底层连接失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
if dbHealthy.Swap(false) {
|
||||||
|
log.Printf("[DBHealth] 数据库不可达,整站进入降级模式: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !dbHealthy.Swap(true) {
|
||||||
|
log.Println("[DBHealth] 数据库已恢复,退出降级模式")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBHealthGuard 数据库健康检查中间件
|
||||||
|
// 检测到 DB 不可达时,返回固定维护页面,避免暴露内部堆栈信息
|
||||||
|
func DBHealthGuard() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if dbHealthy.Load() {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
// 静态资源和 API 直接返回错误,不渲染页面
|
||||||
|
if isStaticPath(path) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "服务暂时不可用,请稍后重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 页面请求返回友好 HTML
|
||||||
|
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = c.Writer.WriteString(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="UTF-8"><title>服务暂时不可用</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
div{text-align:center;padding:2rem} h1{color:#333} p{color:#666}</style></head>
|
||||||
|
<body><div><h1>服务暂时不可用</h1><p>数据库连接异常,请稍后刷新页面重试</p></div></body></html>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStaticPath 判断是否为静态资源或 API 路径
|
||||||
|
func isStaticPath(path string) bool {
|
||||||
|
for _, prefix := range []string{"/static/", "/shared/", "/admin/static/", "/api/"} {
|
||||||
|
if len(path) >= len(prefix) && path[:len(prefix)] == prefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -3,8 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"metazone.cc/mce/internal/config"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -28,12 +27,13 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
c.Set("csp_nonce", nonce)
|
c.Set("csp_nonce", nonce)
|
||||||
|
|
||||||
// Content-Security-Policy
|
// Content-Security-Policy
|
||||||
// script-src/style-src: 本站 + nonce(替代 unsafe-inline)
|
// script-src: 本站 + nonce(替代 unsafe-inline),保留 unsafe-eval 供 Vditor 使用
|
||||||
|
// style-src: 本站(所有内联样式已使用 nonce)
|
||||||
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
fmt.Sprintf("script-src 'self' 'unsafe-eval' 'nonce-%s'; ", nonce)+
|
||||||
"style-src 'self' 'unsafe-inline'; "+
|
"style-src 'self' 'nonce-"+nonce+"'; "+
|
||||||
"img-src 'self' data: blob:; "+
|
"img-src 'self' data: blob:; "+
|
||||||
"connect-src 'self'")
|
"connect-src 'self'")
|
||||||
|
|
||||||
@ -46,10 +46,8 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
// 引用策略
|
// 引用策略
|
||||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
// HSTS:仅在 release 模式启用,避免开发环境 localhost 证书问题
|
// HSTS:始终启用(max-age=1年,含子域名,允许 preload 列表收录)
|
||||||
if config.App != nil && config.App.Server.Mode == "release" {
|
|
||||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import "time"
|
|||||||
|
|
||||||
// Announcement 公告
|
// Announcement 公告
|
||||||
type Announcement struct {
|
type Announcement struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
|
|
||||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
Type string `gorm:"type:varchar(20);default:info" json:"type"` // info / warning / success / error
|
Type string `gorm:"type:varchar(20);default:info" json:"type"` // info / warning / success / error
|
||||||
@ -12,6 +13,4 @@ type Announcement struct {
|
|||||||
StartAt *time.Time `json:"start_at,omitempty"`
|
StartAt *time.Time `json:"start_at,omitempty"`
|
||||||
EndAt *time.Time `json:"end_at,omitempty"`
|
EndAt *time.Time `json:"end_at,omitempty"`
|
||||||
Sort int `gorm:"default:0" json:"sort"`
|
Sort int `gorm:"default:0" json:"sort"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Category 文章分类(两级树形结构)
|
// Category 文章分类(两级树形结构)
|
||||||
type Category struct {
|
type Category struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
|
|
||||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
|
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
|
||||||
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
|
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
|
||||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||||
Sort int `gorm:"default:0" json:"sort"`
|
Sort int `gorm:"default:0" json:"sort"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
|
|
||||||
// 非 DB 字段(树形填充)
|
// 非 DB 字段(树形填充)
|
||||||
Children []Category `gorm:"-" json:"children,omitempty"`
|
Children []Category `gorm:"-" json:"children,omitempty"`
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import "time"
|
|||||||
|
|
||||||
// Comment 评论模型
|
// Comment 评论模型
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
|
|
||||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||||
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||||
@ -14,8 +15,6 @@ type Comment struct {
|
|||||||
LikesCount int `gorm:"default:0" json:"likes_count"`
|
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
|
|
||||||
// 非数据库字段(联表查询填充)
|
// 非数据库字段(联表查询填充)
|
||||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||||
@ -26,7 +25,6 @@ type Comment struct {
|
|||||||
// RepliesCount 回复数(非DB字段,查询填充)
|
// RepliesCount 回复数(非DB字段,查询填充)
|
||||||
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
||||||
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
||||||
// key=创建时的原始@用户名,value={uid,当前显示名},用户改名后链接仍有效且显示新名
|
|
||||||
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// CommunityFund 公户余额(单行表,id=1)
|
// CommunityFund 公户余额(单行表,id=1)
|
||||||
type CommunityFund struct {
|
type CommunityFund struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
// time imported by common.go
|
||||||
|
|
||||||
// Folder 收藏夹
|
// Folder 收藏夹
|
||||||
type Folder struct {
|
type Folder struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
|
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
||||||
IsPublic bool `gorm:"default:false" json:"is_public"`
|
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
|
|
||||||
// 联表查询填充(非数据库字段)
|
// 联表查询填充(非数据库字段)
|
||||||
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
||||||
@ -19,11 +18,11 @@ type Folder struct {
|
|||||||
|
|
||||||
// FolderItem 收藏记录
|
// FolderItem 收藏记录
|
||||||
type FolderItem struct {
|
type FolderItem struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
|
|
||||||
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
||||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
|
|
||||||
// 联表查询填充
|
// 联表查询填充
|
||||||
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Post 帖子/文章模型
|
// Post 帖子/文章模型
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
|
|
||||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||||
@ -38,13 +35,8 @@ type Post struct {
|
|||||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||||
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||||
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
|
|
||||||
// 非数据库字段(联表查询填充)
|
// 非数据库字段(联表查询填充)
|
||||||
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
|
|
||||||
// 从 JOIN 查询的 users.username AS author_name 别名中读取
|
|
||||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,16 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
|
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
|
||||||
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/settings/brand", d.siteSettingCtrl.BrandPage,
|
||||||
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/settings/security", d.siteSettingCtrl.SecurityPage,
|
||||||
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/settings/registration", d.siteSettingCtrl.RegistrationPage,
|
||||||
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage,
|
||||||
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/settings/theme", d.siteSettingCtrl.ThemePage,
|
||||||
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||||
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||||
@ -62,6 +72,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
siteSettingsAPI.PUT("", d.siteSettingCtrl.UpdateSetting)
|
siteSettingsAPI.PUT("", d.siteSettingCtrl.UpdateSetting)
|
||||||
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
||||||
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
||||||
|
siteSettingsAPI.GET("/themes", d.siteSettingCtrl.GetThemes)
|
||||||
|
siteSettingsAPI.PUT("/theme", d.siteSettingCtrl.UpdateTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 帖子管理 API(admin+) ---
|
// --- 帖子管理 API(admin+) ---
|
||||||
|
|||||||
@ -2,197 +2,17 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/mce/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
"metazone.cc/mce/internal/middleware"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupAPIRoutes 注册 API 路由
|
// setupAPIRoutes 按域分发 API 路由注册
|
||||||
func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
// --- 设置页 API(需登录) ---
|
setupAuthAPIRoutes(r, cfg, d)
|
||||||
settingsAPI := r.Group("/api/settings")
|
setupSettingsAPIRoutes(r, cfg, d)
|
||||||
settingsAPI.Use(d.authMdw.Required())
|
setupPostsAPIRoutes(r, cfg, d)
|
||||||
settingsAPI.Use(d.authMdw.BannedWriteGuard())
|
setupCommentsAPIRoutes(r, cfg, d)
|
||||||
settingsAPI.Use(middleware.CSRF(cfg))
|
setupStudioAPIRoutes(r, cfg, d)
|
||||||
{
|
setupReactionsAPIRoutes(r, cfg, d)
|
||||||
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
|
setupSocialAPIRoutes(r, cfg, d)
|
||||||
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
|
|
||||||
settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
|
|
||||||
settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
|
|
||||||
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
|
|
||||||
// 登录管理
|
|
||||||
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
|
|
||||||
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
|
|
||||||
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
|
|
||||||
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
|
|
||||||
// 通知偏好
|
|
||||||
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
|
|
||||||
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 消息中心 API(需登录) ---
|
|
||||||
messagesAPI := r.Group("/api/messages")
|
|
||||||
messagesAPI.Use(d.authMdw.Required())
|
|
||||||
messagesAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
messagesAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
|
|
||||||
messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead)
|
|
||||||
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 积分/等级 API(需登录) ---
|
|
||||||
levelAPI := r.Group("/api/level")
|
|
||||||
levelAPI.Use(d.authMdw.Required())
|
|
||||||
levelAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
levelAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
|
|
||||||
levelAPI.GET("/info", d.levelCtrl.GetLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 认证 API ---
|
|
||||||
api := r.Group("/api")
|
|
||||||
api.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
api.POST("/auth/check-email", d.authCtrl.CheckEmail)
|
|
||||||
api.POST("/auth/register", d.authCtrl.Register)
|
|
||||||
api.POST("/auth/login", d.authCtrl.Login)
|
|
||||||
api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
|
|
||||||
api.POST("/auth/logout", d.authCtrl.Logout)
|
|
||||||
api.POST("/auth/refresh", d.authCtrl.RefreshToken)
|
|
||||||
|
|
||||||
// 帖子公开 API(无需登录)
|
|
||||||
api.GET("/posts", d.postCtrl.ListAPI)
|
|
||||||
api.GET("/posts/:id", d.postCtrl.ShowAPI)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 帖子 API(需登录) ---
|
|
||||||
postsAPI := r.Group("/api/posts")
|
|
||||||
postsAPI.Use(d.authMdw.Required())
|
|
||||||
postsAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
postsAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
|
||||||
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 创作中心 API(需登录) ---
|
|
||||||
studioAPI := r.Group("/api/studio")
|
|
||||||
studioAPI.Use(d.authMdw.Required())
|
|
||||||
studioAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
studioAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
|
|
||||||
studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
|
|
||||||
studioAPI.POST("/posts", d.studioCtrl.Create)
|
|
||||||
studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
|
|
||||||
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
|
|
||||||
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 域能 API(需登录) ---
|
|
||||||
energyAPI := r.Group("/api/energy")
|
|
||||||
energyAPI.Use(d.authMdw.Required())
|
|
||||||
energyAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
energyAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
energyAPI.POST("/energize", d.energyCtrl.Energize)
|
|
||||||
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
|
|
||||||
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 评论 API(部分需登录) ---
|
|
||||||
// 公开读取
|
|
||||||
commentsPublic := r.Group("/api/posts/:id/comments")
|
|
||||||
{
|
|
||||||
commentsPublic.GET("", d.commentCtrl.ListRootComments)
|
|
||||||
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
|
|
||||||
}
|
|
||||||
// 需要登录的操作
|
|
||||||
commentsAPI := r.Group("/api")
|
|
||||||
commentsAPI.Use(d.authMdw.Required())
|
|
||||||
commentsAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
commentsAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
|
|
||||||
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
|
|
||||||
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
|
|
||||||
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
|
||||||
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 赞/踩 API ---
|
|
||||||
// 公开:查询反应状态(未登录返回 none + 点赞数)
|
|
||||||
r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
|
|
||||||
// 需登录:切换赞/踩
|
|
||||||
reactionAPI := r.Group("/api/posts/:id")
|
|
||||||
reactionAPI.Use(d.authMdw.Required())
|
|
||||||
reactionAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
reactionAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
|
|
||||||
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 关注 API ---
|
|
||||||
// 公开(Optional 认证以获取当前用户关注状态,未登录返回 false)
|
|
||||||
followPublic := r.Group("/api/users/:uid")
|
|
||||||
followPublic.Use(d.authMdw.Optional())
|
|
||||||
{
|
|
||||||
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
|
|
||||||
followPublic.GET("/followers", d.followCtrl.Followers)
|
|
||||||
followPublic.GET("/following", d.followCtrl.Following)
|
|
||||||
}
|
|
||||||
// 需登录:切换关注
|
|
||||||
followAPI := r.Group("/api/users/:uid")
|
|
||||||
followAPI.Use(d.authMdw.Required())
|
|
||||||
followAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
followAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
followAPI.POST("/follow", d.followCtrl.Toggle)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 收藏夹 API ---
|
|
||||||
// 公开:文章收藏状态(未登录返回 false)
|
|
||||||
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
|
|
||||||
// 公开:查看公开收藏夹内容
|
|
||||||
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
|
|
||||||
// 需登录:收藏操作
|
|
||||||
folderAPI := r.Group("/api/folders")
|
|
||||||
folderAPI.Use(d.authMdw.Required())
|
|
||||||
folderAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
folderAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
folderAPI.GET("", d.favoriteCtrl.ListFolders)
|
|
||||||
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
|
|
||||||
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
|
|
||||||
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
|
|
||||||
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
|
|
||||||
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
|
|
||||||
}
|
|
||||||
// 需登录:切换收藏(Toggle)
|
|
||||||
favToggleAPI := r.Group("/api/posts/:id")
|
|
||||||
favToggleAPI.Use(d.authMdw.Required())
|
|
||||||
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
favToggleAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 空间/隐私设置 API(需登录) ---
|
|
||||||
spaceAPI := r.Group("/api/space")
|
|
||||||
spaceAPI.Use(d.authMdw.Required())
|
|
||||||
spaceAPI.Use(d.authMdw.BannedWriteGuard())
|
|
||||||
spaceAPI.Use(middleware.CSRF(cfg))
|
|
||||||
{
|
|
||||||
spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Studio 趋势 API(需登录) ---
|
|
||||||
trendsAPI := r.Group("/api/studio")
|
|
||||||
trendsAPI.Use(d.authMdw.Required())
|
|
||||||
{
|
|
||||||
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
internal/router/api_auth.go
Normal file
27
internal/router/api_auth.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupAuthAPIRoutes 注册认证相关 API 路由
|
||||||
|
func setupAuthAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
api := r.Group("/api")
|
||||||
|
api.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
// 认证
|
||||||
|
api.POST("/auth/check-email", d.authCtrl.CheckEmail)
|
||||||
|
api.POST("/auth/register", d.authCtrl.Register)
|
||||||
|
api.POST("/auth/login", d.authCtrl.Login)
|
||||||
|
api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
|
||||||
|
api.POST("/auth/logout", d.authCtrl.Logout)
|
||||||
|
api.POST("/auth/refresh", d.authCtrl.RefreshToken)
|
||||||
|
|
||||||
|
// 帖子公开 API(无需登录)
|
||||||
|
api.GET("/posts", d.postCtrl.ListAPI)
|
||||||
|
api.GET("/posts/:id", d.postCtrl.ShowAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/router/api_comments.go
Normal file
31
internal/router/api_comments.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupCommentsAPIRoutes 注册评论相关 API 路由
|
||||||
|
func setupCommentsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
// 公开读取
|
||||||
|
commentsPublic := r.Group("/api/posts/:id/comments")
|
||||||
|
{
|
||||||
|
commentsPublic.GET("", d.commentCtrl.ListRootComments)
|
||||||
|
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要登录的操作
|
||||||
|
commentsAPI := r.Group("/api")
|
||||||
|
commentsAPI.Use(d.authMdw.Required())
|
||||||
|
commentsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
commentsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
|
||||||
|
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
|
||||||
|
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
|
||||||
|
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
||||||
|
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/router/api_posts.go
Normal file
20
internal/router/api_posts.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupPostsAPIRoutes 注册帖子相关 API 路由
|
||||||
|
func setupPostsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
postsAPI := r.Group("/api/posts")
|
||||||
|
postsAPI.Use(d.authMdw.Required())
|
||||||
|
postsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
postsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
||||||
|
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
internal/router/api_reactions.go
Normal file
24
internal/router/api_reactions.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupReactionsAPIRoutes 注册赞/踩相关 API 路由
|
||||||
|
func setupReactionsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
// 公开:查询反应状态
|
||||||
|
r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
|
||||||
|
|
||||||
|
// 需登录:切换赞/踩
|
||||||
|
reactionAPI := r.Group("/api/posts/:id")
|
||||||
|
reactionAPI.Use(d.authMdw.Required())
|
||||||
|
reactionAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
reactionAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
|
||||||
|
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
internal/router/api_settings.go
Normal file
51
internal/router/api_settings.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupSettingsAPIRoutes 注册设置、消息、等级相关 API 路由
|
||||||
|
func setupSettingsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
// 设置页 API
|
||||||
|
settingsAPI := r.Group("/api/settings")
|
||||||
|
settingsAPI.Use(d.authMdw.Required())
|
||||||
|
settingsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
settingsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
|
||||||
|
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
|
||||||
|
settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
|
||||||
|
settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
|
||||||
|
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
|
||||||
|
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
|
||||||
|
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
|
||||||
|
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
|
||||||
|
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
|
||||||
|
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
|
||||||
|
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息中心 API
|
||||||
|
messagesAPI := r.Group("/api/messages")
|
||||||
|
messagesAPI.Use(d.authMdw.Required())
|
||||||
|
messagesAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
messagesAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
|
||||||
|
messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead)
|
||||||
|
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分/等级 API
|
||||||
|
levelAPI := r.Group("/api/level")
|
||||||
|
levelAPI.Use(d.authMdw.Required())
|
||||||
|
levelAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
levelAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
|
||||||
|
levelAPI.GET("/info", d.levelCtrl.GetLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/router/api_social.go
Normal file
64
internal/router/api_social.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupSocialAPIRoutes 注册关注、收藏夹、空间相关 API 路由
|
||||||
|
func setupSocialAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
// 关注 — 公开(Optional 认证)
|
||||||
|
followPublic := r.Group("/api/users/:uid")
|
||||||
|
followPublic.Use(d.authMdw.Optional())
|
||||||
|
{
|
||||||
|
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
|
||||||
|
followPublic.GET("/followers", d.followCtrl.Followers)
|
||||||
|
followPublic.GET("/following", d.followCtrl.Following)
|
||||||
|
}
|
||||||
|
// 需登录:切换关注
|
||||||
|
followAPI := r.Group("/api/users/:uid")
|
||||||
|
followAPI.Use(d.authMdw.Required())
|
||||||
|
followAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
followAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
followAPI.POST("/follow", d.followCtrl.Toggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏夹 — 公开
|
||||||
|
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
|
||||||
|
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
|
||||||
|
|
||||||
|
// 收藏夹 — 需登录
|
||||||
|
folderAPI := r.Group("/api/folders")
|
||||||
|
folderAPI.Use(d.authMdw.Required())
|
||||||
|
folderAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
folderAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
folderAPI.GET("", d.favoriteCtrl.ListFolders)
|
||||||
|
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
|
||||||
|
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
|
||||||
|
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
|
||||||
|
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
|
||||||
|
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换收藏 Toggle
|
||||||
|
favToggleAPI := r.Group("/api/posts/:id")
|
||||||
|
favToggleAPI.Use(d.authMdw.Required())
|
||||||
|
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
favToggleAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空间/隐私设置
|
||||||
|
spaceAPI := r.Group("/api/space")
|
||||||
|
spaceAPI.Use(d.authMdw.Required())
|
||||||
|
spaceAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
spaceAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/router/api_studio.go
Normal file
43
internal/router/api_studio.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/mce/internal/config"
|
||||||
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupStudioAPIRoutes 注册创作中心、域能、趋势相关 API 路由
|
||||||
|
func setupStudioAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||||
|
// 创作中心
|
||||||
|
studioAPI := r.Group("/api/studio")
|
||||||
|
studioAPI.Use(d.authMdw.Required())
|
||||||
|
studioAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
studioAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
|
||||||
|
studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
|
||||||
|
studioAPI.POST("/posts", d.studioCtrl.Create)
|
||||||
|
studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
|
||||||
|
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
|
||||||
|
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域能
|
||||||
|
energyAPI := r.Group("/api/energy")
|
||||||
|
energyAPI.Use(d.authMdw.Required())
|
||||||
|
energyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
energyAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
energyAPI.POST("/energize", d.energyCtrl.Energize)
|
||||||
|
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
|
||||||
|
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 趋势
|
||||||
|
trendsAPI := r.Group("/api/studio")
|
||||||
|
trendsAPI.Use(d.authMdw.Required())
|
||||||
|
{
|
||||||
|
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,20 +3,36 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/mce/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
adminCtrl "metazone.cc/mce/internal/controller/admin"
|
||||||
"metazone.cc/mce/internal/middleware"
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 保存站点设置 Controller 引用,供 InjectThemeReloader 使用
|
||||||
|
var globalSiteSettingCtrl *adminCtrl.SiteSettingController
|
||||||
|
|
||||||
|
// InjectThemeReloader 注入主题重载回调(main.go 在路由注册后调用,使主题切换实时生效)
|
||||||
|
func InjectThemeReloader(reload service.ThemeReloader) {
|
||||||
|
if globalSiteSettingCtrl != nil {
|
||||||
|
globalSiteSettingCtrl.SetThemeReloader(reload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup 注册所有路由
|
// Setup 注册所有路由
|
||||||
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
|
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
r.Use(middleware.InjectSiteInfo(siteSettings))
|
r.Use(middleware.InjectSiteInfo(siteSettings))
|
||||||
|
r.Use(middleware.DBHealthGuard())
|
||||||
|
|
||||||
deps := buildDeps(db, cfg, siteSettings, redisClient)
|
deps := buildDeps(db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
|
// 保存站点设置 Controller 引用
|
||||||
|
globalSiteSettingCtrl = deps.siteSettingCtrl
|
||||||
|
|
||||||
// 维护模式中间件(全局,在路由匹配之前)
|
// 维护模式中间件(全局,在路由匹配之前)
|
||||||
r.Use(deps.maintenanceMdw.Handler())
|
r.Use(deps.maintenanceMdw.Handler())
|
||||||
|
|
||||||
|
|||||||
@ -80,3 +80,17 @@ func (s *SiteSettingService) GetBoolSetting(key string, defaultVal bool) bool {
|
|||||||
func ParseBool(v string) (bool, error) {
|
func ParseBool(v string) (bool, error) {
|
||||||
return strconv.ParseBool(v)
|
return strconv.ParseBool(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeReloader 主题重载回调:切换主题后实时生效
|
||||||
|
type ThemeReloader func(themeName string) error
|
||||||
|
|
||||||
|
// UpdateTheme 切换主题并触发重载
|
||||||
|
func (s *SiteSettingService) UpdateTheme(themeName string, reload ThemeReloader) error {
|
||||||
|
if err := s.settings.Set("site.theme", themeName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reload != nil {
|
||||||
|
return reload(themeName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
52
internal/theme/discovery.go
Normal file
52
internal/theme/discovery.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThemeInfo 描述一个已安装的主题。
|
||||||
|
type ThemeInfo struct {
|
||||||
|
Dir string `json:"dir"` // 目录名(如 "MetaLab-2026")
|
||||||
|
Name string `json:"name"` // 主题名称
|
||||||
|
DisplayName string `json:"display_name"` // 展示名称
|
||||||
|
Version string `json:"version"` // 版本号
|
||||||
|
Author string `json:"author"` // 作者
|
||||||
|
Description string `json:"description"` // 描述
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverThemes 扫描 templates/ 目录,返回所有包含 theme.json 的主题列表。
|
||||||
|
func DiscoverThemes(rootDir string) ([]ThemeInfo, error) {
|
||||||
|
entries, err := os.ReadDir(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes []ThemeInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
themeJSON := filepath.Join(rootDir, entry.Name(), "theme.json")
|
||||||
|
data, err := os.ReadFile(themeJSON) //nolint:gosec // 内部主题文件,非用户输入
|
||||||
|
if err != nil {
|
||||||
|
continue // 无 theme.json 则跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ThemeInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info.Dir = entry.Name()
|
||||||
|
themes = append(themes, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按名称排序,保证稳定展示
|
||||||
|
sort.Slice(themes, func(i, j int) bool {
|
||||||
|
return themes[i].Name < themes[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return themes, nil
|
||||||
|
}
|
||||||
@ -7,7 +7,6 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
export PATH="$HOME/go/bin:$PATH"
|
|
||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@ -24,8 +23,7 @@ echo "========================================"
|
|||||||
# 1. 格式检查
|
# 1. 格式检查
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> 1/3 格式检查 (gofumpt + goimports)"
|
echo "==> 1/3 格式检查 (gofumpt + goimports)"
|
||||||
test -z "$(gofumpt -l internal/ cmd/)" || { echo "gofumpt: 以下文件格式不正确:"; gofumpt -l internal/ cmd/; exit 1; }
|
make fmt-check
|
||||||
test -z "$(goimports -l internal/ cmd/)" || { echo "goimports: 以下文件 import 不规范:"; goimports -l internal/ cmd/; exit 1; }
|
|
||||||
|
|
||||||
# 2. 编译检查
|
# 2. 编译检查
|
||||||
echo ""
|
echo ""
|
||||||
@ -36,9 +34,9 @@ go build ./...
|
|||||||
echo ""
|
echo ""
|
||||||
echo "==> 3/3 静态分析 (golangci-lint)"
|
echo "==> 3/3 静态分析 (golangci-lint)"
|
||||||
if $STRICT; then
|
if $STRICT; then
|
||||||
golangci-lint run ./...
|
make lint-strict
|
||||||
else
|
else
|
||||||
golangci-lint run ./... || true
|
make lint
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
5
templates/MetaLab-2026/html/settings/_footer.html
Normal file
5
templates/MetaLab-2026/html/settings/_footer.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
templates/MetaLab-2026/html/settings/_header.html
Normal file
56
templates/MetaLab-2026/html/settings/_header.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
<div class="settings-toast" id="settingsToast"></div>
|
||||||
|
<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>
|
||||||
|
<a href="/settings/sessions"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "sessions"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
登录管理
|
||||||
|
</a>
|
||||||
|
<a href="/settings/energy"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "energy"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||||
|
</svg>
|
||||||
|
我的域能
|
||||||
|
</a>
|
||||||
|
<a href="/settings/favorites"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "favorites"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
我的收藏夹
|
||||||
|
</a>
|
||||||
|
<a href="/settings/notify"
|
||||||
|
class="settings-nav-item {{if eq .ActiveTab "notify"}}active{{end}}">
|
||||||
|
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
通知偏好
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main class="settings-main">
|
||||||
84
templates/MetaLab-2026/html/settings/account.html
Normal file
84
templates/MetaLab-2026/html/settings/account.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{{template "settings/_header.html" .}}
|
||||||
|
<!-- 账号信息 -->
|
||||||
|
<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">{{.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>
|
||||||
|
{{if .User.LastLoginAt}}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">上次登录</span>
|
||||||
|
<span class="info-value">{{.User.LastLoginAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 修改密码 -->
|
||||||
|
<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="form-group">
|
||||||
|
<label class="form-label" for="currentPassword">当前密码</label>
|
||||||
|
<input type="password" id="currentPassword" class="settings-text-input" placeholder="输入当前密码" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="newPassword">新密码</label>
|
||||||
|
<input type="password" id="newPassword" class="settings-text-input" placeholder="至少 8 位,包含大小写字母和数字" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-footer">
|
||||||
|
<button id="changePwdBtn" class="settings-save-btn">修改密码</button>
|
||||||
|
<span class="form-hint">修改成功后需重新登录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注销账号 -->
|
||||||
|
<div class="settings-card settings-card-danger">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>注销账号</h2>
|
||||||
|
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录撤销注销</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="deletePassword">确认密码</label>
|
||||||
|
<input type="password" id="deletePassword" class="settings-text-input" placeholder="输入密码确认" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="deleteReason">注销原因(选填)</label>
|
||||||
|
<textarea id="deleteReason" class="settings-textarea" maxlength="500" placeholder="可以告诉我们为什么要离开吗?"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-footer">
|
||||||
|
<button id="deleteAccountBtn" class="settings-danger-btn">确认注销</button>
|
||||||
|
<span class="form-hint">此操作不可逆,请谨慎处理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "settings/_footer.html" .}}
|
||||||
|
<script src="/static/js/settings-account.js?v={{assetV "/static/js/settings-account.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
29
templates/MetaLab-2026/html/settings/energy.html
Normal file
29
templates/MetaLab-2026/html/settings/energy.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{{template "settings/_header.html" .}}
|
||||||
|
<!-- 我的域能 -->
|
||||||
|
<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="energy-summary">
|
||||||
|
<div class="energy-stat">
|
||||||
|
<span class="energy-label">域能余额</span>
|
||||||
|
<span class="energy-value" id="energyBalance">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="energy-stat">
|
||||||
|
<span class="energy-label">经验</span>
|
||||||
|
<span class="energy-value" id="energyExp">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="energy-stat">
|
||||||
|
<span class="energy-label">等级</span>
|
||||||
|
<span class="energy-value" id="energyLevel">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="energy-log-list" id="energyLogList">
|
||||||
|
<div class="energy-log-empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "settings/_footer.html" .}}
|
||||||
|
<script src="/static/js/settings-energy.js?v={{assetV "/static/js/settings-energy.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
18
templates/MetaLab-2026/html/settings/favorites.html
Normal file
18
templates/MetaLab-2026/html/settings/favorites.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{{template "settings/_header.html" .}}
|
||||||
|
<!-- 我的收藏夹 -->
|
||||||
|
<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="fav-toolbar">
|
||||||
|
<button class="settings-save-btn" id="createFolderBtn">+ 新建收藏夹</button>
|
||||||
|
</div>
|
||||||
|
<div class="fav-list" id="favFolderList">
|
||||||
|
<div class="energy-log-empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "settings/_footer.html" .}}
|
||||||
|
<script src="/static/js/settings-favorites.js?v={{assetV "/static/js/settings-favorites.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
13
templates/MetaLab-2026/html/settings/notify.html
Normal file
13
templates/MetaLab-2026/html/settings/notify.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{{template "settings/_header.html" .}}
|
||||||
|
<!-- 通知偏好 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>通知偏好</h2>
|
||||||
|
<p class="settings-card-desc">管理你的通知推送偏好,关闭后不影响通知记录存储</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body" id="notifyPrefsBody">
|
||||||
|
<div class="energy-log-empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "settings/_footer.html" .}}
|
||||||
|
<script src="/static/js/settings-notify.js?v={{assetV "/static/js/settings-notify.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
61
templates/MetaLab-2026/html/settings/profile.html
Normal file
61
templates/MetaLab-2026/html/settings/profile.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{{template "settings/_header.html" .}}
|
||||||
|
<!-- 个人资料 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h2>个人资料</h2>
|
||||||
|
<p class="settings-card-desc">你的公开信息和基本资料</p>
|
||||||
|
</div>
|
||||||
|
{{if .PendingTypes}}
|
||||||
|
<div class="settings-audit-notice">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{{range $i, $t := .PendingTypes}}{{if $i}}、{{end}}{{index $.AuditTypes $t}}{{end}}审核中
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="settings-card-body">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">用户名</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<input type="text" id="usernameInput" class="settings-text-input" value="{{.User.Username}}" maxlength="16" autocomplete="off" data-has-completed-rename="{{.HasCompletedRename}}">
|
||||||
|
<span class="char-counter" id="usernameCounter"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">头像</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<div class="avatar-upload-wrap" id="avatarWrap">
|
||||||
|
{{if .User.Avatar}}
|
||||||
|
<img src="{{.User.Avatar}}?v={{.User.UpdatedAt.Unix}}" alt="avatar" class="avatar-preview" id="avatarPreview">
|
||||||
|
{{else}}
|
||||||
|
<div class="avatar-placeholder" id="avatarPreview">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<span class="avatar-hint">点击更换</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="avatarInput" class="avatar-input-hidden" accept="image/jpeg,image/png,image/webp">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">个性签名</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<textarea id="bioInput" class="settings-textarea" maxlength="128" placeholder="写一句话介绍自己...">{{.User.Bio}}</textarea>
|
||||||
|
<div class="char-counter" id="bioCounter"></div>
|
||||||
|
</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 class="settings-card-footer">
|
||||||
|
<button id="saveProfileBtn" class="settings-save-btn">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "settings/_footer.html" .}}
|
||||||
|
<script src="/static/js/settings-profile.js?v={{assetV "/static/js/settings-profile.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
53
templates/MetaLab-2026/html/settings/sessions.html
Normal file
53
templates/MetaLab-2026/html/settings/sessions.html
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{{template "settings/_header.html" .}}
|
||||||
|
<!-- 登录管理 -->
|
||||||
|
<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="session-list" id="sessionList">
|
||||||
|
{{range .Sessions}}
|
||||||
|
<div class="session-item{{if .IsCurrent}} session-current{{end}}" data-sid="{{.ID}}">
|
||||||
|
<div class="session-icon">
|
||||||
|
{{if eq .DeviceInfo.Device "Mobile"}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
|
||||||
|
{{else if eq .DeviceInfo.Device "Tablet"}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
|
||||||
|
{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="session-info">
|
||||||
|
<div class="session-title">
|
||||||
|
<span class="session-browser">{{.DeviceInfo.Browser}}</span>
|
||||||
|
<span class="session-os">/ {{.DeviceInfo.OS}}</span>
|
||||||
|
{{if .IsCurrent}}<span class="session-badge">当前设备</span>{{end}}
|
||||||
|
{{if not .RememberMe}}<span class="session-badge session-badge-temp">临时会话</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="session-meta">
|
||||||
|
<span class="session-ip">IP: {{.IP}}</span>
|
||||||
|
<span class="session-time">登录于 {{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
|
<span class="session-time">最后活动 {{.LastAccess.Format "2006-01-02 15:04"}}</span>
|
||||||
|
{{if not .IsCurrent}}<span class="session-ttl">剩余约 {{formatTTL .TTLMinutes}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="session-remark-row">
|
||||||
|
<input type="text" class="session-remark-input" value="{{.Remark}}" placeholder="添加备注(如 我的笔记本)" maxlength="32" data-sid="{{.ID}}" data-current="{{.IsCurrent}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-action">
|
||||||
|
{{if not .IsCurrent}}
|
||||||
|
<button class="session-kick-btn" data-sid="{{.ID}}">踢出</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Sessions}}
|
||||||
|
<div class="session-empty">暂无活跃的登录会话</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-footer">
|
||||||
|
<button id="kickOthersBtn" class="settings-danger-btn">一键踢出其他设备</button>
|
||||||
|
<span class="form-hint">将强制退出除当前设备外的所有登录会话</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "settings/_footer.html" .}}
|
||||||
|
<script src="/static/js/settings-sessions.js?v={{assetV "/static/js/settings-sessions.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
111
templates/MetaLab-2026/static/js/settings-account.js
Normal file
111
templates/MetaLab-2026/static/js/settings-account.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
(function(){'use strict';
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var toastEl = document.getElementById('settingsToast');
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
function showSettingsToast(msg, isError) {
|
||||||
|
if (!toastEl) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
|
||||||
|
void toastEl.offsetWidth;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
toastEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
// 修改密码
|
||||||
|
// ---- 修改密码 ----
|
||||||
|
var currentPwd = document.getElementById('currentPassword');
|
||||||
|
var newPwd = document.getElementById('newPassword');
|
||||||
|
var changePwdBtn = document.getElementById('changePwdBtn');
|
||||||
|
if (currentPwd && newPwd && changePwdBtn) {
|
||||||
|
changePwdBtn.addEventListener('click', function () {
|
||||||
|
if (!currentPwd.value) {
|
||||||
|
showSettingsToast('请输入当前密码', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newPwd.value || !/[a-z]/.test(newPwd.value) || !/[A-Z]/.test(newPwd.value) || !/\d/.test(newPwd.value) || newPwd.value.length < 8) {
|
||||||
|
showSettingsToast('新密码至少 8 位,包含大小写字母和数字', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePwdBtn.disabled = true;
|
||||||
|
changePwdBtn.textContent = '修改中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', '/api/settings/password', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
changePwdBtn.disabled = false;
|
||||||
|
changePwdBtn.textContent = '修改密码';
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast('密码已修改,请重新登录');
|
||||||
|
currentPwd.value = '';
|
||||||
|
newPwd.value = '';
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '修改失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('修改失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({
|
||||||
|
current_password: currentPwd.value,
|
||||||
|
new_password: newPwd.value
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 注销账号
|
||||||
|
// ---- 注销账号 ----
|
||||||
|
var deletePwd = document.getElementById('deletePassword');
|
||||||
|
var deleteReason = document.getElementById('deleteReason');
|
||||||
|
var deleteAccountBtn = document.getElementById('deleteAccountBtn');
|
||||||
|
if (deletePwd && deleteAccountBtn) {
|
||||||
|
deleteAccountBtn.addEventListener('click', function () {
|
||||||
|
if (!deletePwd.value) {
|
||||||
|
showSettingsToast('请输入密码确认', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showConfirm('注销账号', '确定要注销账号吗?\n\n注销后 7 天内重新登录可撤销注销,期满后将永久删除。').then(function(ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
deleteAccountBtn.disabled = true;
|
||||||
|
deleteAccountBtn.textContent = '注销中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/settings/delete-account', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
deleteAccountBtn.disabled = false;
|
||||||
|
deleteAccountBtn.textContent = '确认注销';
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast(res.message || '账号已注销');
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '操作失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('操作失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({
|
||||||
|
password: deletePwd.value,
|
||||||
|
reason: deleteReason ? deleteReason.value : ''
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
91
templates/MetaLab-2026/static/js/settings-energy.js
Normal file
91
templates/MetaLab-2026/static/js/settings-energy.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
(function(){'use strict';
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var toastEl = document.getElementById('settingsToast');
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
function showSettingsToast(msg, isError) {
|
||||||
|
if (!toastEl) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
|
||||||
|
void toastEl.offsetWidth;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
toastEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
// ===== 域能 Tab =====
|
||||||
|
(function () {
|
||||||
|
var balanceEl = document.getElementById('energyBalance');
|
||||||
|
var expEl = document.getElementById('energyExp');
|
||||||
|
var levelEl = document.getElementById('energyLevel');
|
||||||
|
var logListEl = document.getElementById('energyLogList');
|
||||||
|
if (!balanceEl || !logListEl) return;
|
||||||
|
|
||||||
|
// 获取域能信息
|
||||||
|
fetch('/api/energy/info')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.success && d.data) {
|
||||||
|
balanceEl.textContent = d.data.energy_display + ' 域能';
|
||||||
|
} else {
|
||||||
|
balanceEl.textContent = '获取失败';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { balanceEl.textContent = '获取失败'; });
|
||||||
|
|
||||||
|
// 获取经验和等级
|
||||||
|
if (expEl && levelEl) {
|
||||||
|
fetch('/api/level/info')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.success && d.data) {
|
||||||
|
expEl.textContent = d.data.exp;
|
||||||
|
levelEl.textContent = 'Lv' + d.data.level;
|
||||||
|
} else {
|
||||||
|
expEl.textContent = '获取失败';
|
||||||
|
levelEl.textContent = '获取失败';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
expEl.textContent = '获取失败';
|
||||||
|
levelEl.textContent = '获取失败';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取域能流水
|
||||||
|
fetch('/api/energy/logs?days=7&page=1&page_size=50')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.success || !d.data || !d.data.items || d.data.items.length === 0) {
|
||||||
|
logListEl.innerHTML = '<div class="energy-log-empty">近7天无变更记录</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
d.data.items.forEach(function (item) {
|
||||||
|
var cls = item.amount > 0 ? 'energy-log-plus' : 'energy-log-minus';
|
||||||
|
var sign = item.amount > 0 ? '+' : '';
|
||||||
|
html += '<div class="energy-log-item ' + cls + '">'
|
||||||
|
+ '<div class="energy-log-meta">'
|
||||||
|
+ '<span class="energy-log-type">' + escapeHtml(item.type_name) + '</span>'
|
||||||
|
+ '<span class="energy-log-time">' + formatTime(item.created_at) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="energy-log-info">'
|
||||||
|
+ '<span class="energy-log-desc">' + escapeHtml(item.description) + '</span>'
|
||||||
|
+ '<span class="energy-log-amount">' + sign + item.amount_display + ' 域能</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
logListEl.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
logListEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// escapeHtml / formatTime 由 shared/utils.js 统一提供
|
||||||
|
})();
|
||||||
|
})();
|
||||||
108
templates/MetaLab-2026/static/js/settings-favorites.js
Normal file
108
templates/MetaLab-2026/static/js/settings-favorites.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
(function(){'use strict';
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var toastEl = document.getElementById('settingsToast');
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
function showSettingsToast(msg, isError) {
|
||||||
|
if (!toastEl) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
|
||||||
|
void toastEl.offsetWidth;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
toastEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
// ===== 收藏夹 Tab =====
|
||||||
|
(function () {
|
||||||
|
var listEl = document.getElementById('favFolderList');
|
||||||
|
var createBtn = document.getElementById('createFolderBtn');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
// escapeHtml / api 由 shared/utils.js 统一提供
|
||||||
|
|
||||||
|
function loadFolders() {
|
||||||
|
listEl.innerHTML = '<div class="energy-log-empty">加载中...</div>';
|
||||||
|
api('GET', '/api/folders')
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.success) {
|
||||||
|
listEl.innerHTML = '<div class="energy-log-empty">' + escapeHtml(d.message || '加载失败') + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!d.data || !d.data.folders || d.data.folders.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="energy-log-empty">暂无收藏夹</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
d.data.folders.forEach(function (f) {
|
||||||
|
var isDefault = f.is_default ? ' (默认)' : '';
|
||||||
|
var pub = f.is_public ? '公开' : '私密';
|
||||||
|
html += '<div class="fav-folder-card">'
|
||||||
|
+ '<div class="fav-folder-info">'
|
||||||
|
+ '<span class="fav-folder-name">' + escapeHtml(f.name) + isDefault + '</span>'
|
||||||
|
+ '<span class="fav-folder-meta">' + (f.item_count || 0) + ' 篇文章 · ' + pub + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="fav-folder-actions">'
|
||||||
|
+ (f.is_default ? '' : '<button class="fav-folder-del" data-id="' + f.id + '">删除</button>')
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.fav-folder-del').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
showConfirm('确认', '确定要删除此收藏夹?其中的收藏内容也将被清除。').then(function(ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
api('DELETE', '/api/folders/' + id)
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.success) {
|
||||||
|
showSettingsToast('收藏夹已删除');
|
||||||
|
loadFolders();
|
||||||
|
} else {
|
||||||
|
showSettingsToast(r.message || '删除失败', true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showSettingsToast('删除失败', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
listEl.innerHTML = '<div class="energy-log-empty">加载失败,请刷新页面重试</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createBtn) {
|
||||||
|
createBtn.addEventListener('click', function () {
|
||||||
|
showPrompt('请输入收藏夹名称(最多50个字符):').then(function(name) {
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
if (name.trim().length > 50) {
|
||||||
|
showSettingsToast('收藏夹名称不能超过 50 个字符', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('POST', '/api/folders', { name: name.trim(), description: '', is_public: false })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.success) {
|
||||||
|
showSettingsToast('收藏夹已创建');
|
||||||
|
loadFolders();
|
||||||
|
} else {
|
||||||
|
showSettingsToast(r.message || '创建失败', true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showSettingsToast('创建失败', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFolders();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
90
templates/MetaLab-2026/static/js/settings-notify.js
Normal file
90
templates/MetaLab-2026/static/js/settings-notify.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
(function(){'use strict';
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var toastEl = document.getElementById('settingsToast');
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
function showSettingsToast(msg, isError) {
|
||||||
|
if (!toastEl) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
|
||||||
|
void toastEl.offsetWidth;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
toastEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
// ===== 通知偏好 Tab =====
|
||||||
|
(function () {
|
||||||
|
var bodyEl = document.getElementById('notifyPrefsBody');
|
||||||
|
if (!bodyEl) return;
|
||||||
|
|
||||||
|
function loadPrefs() {
|
||||||
|
fetch('/api/settings/notify-prefs')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.success || !d.data) {
|
||||||
|
bodyEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var prefs = d.data;
|
||||||
|
var items = [
|
||||||
|
{ key: 'comment', label: '评论通知', desc: '有人评论我的文章时通知我' },
|
||||||
|
{ key: 'comment_reply', label: '回复通知', desc: '有人回复我的评论时通知我' },
|
||||||
|
{ key: 'like_aggregated', label: '点赞通知', desc: '每日汇总的点赞通知' },
|
||||||
|
{ key: 'follow', label: '关注通知', desc: '有人关注我时通知我' }
|
||||||
|
];
|
||||||
|
var html = '';
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var checked = prefs[item.key] ? 'checked' : '';
|
||||||
|
html += '<div class="notify-pref-row">'
|
||||||
|
+ '<div class="notify-pref-info">'
|
||||||
|
+ '<span class="notify-pref-label">' + item.label + '</span>'
|
||||||
|
+ '<span class="notify-pref-desc">' + item.desc + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<label class="notify-pref-toggle">'
|
||||||
|
+ '<input type="checkbox" class="notify-pref-cb" data-key="' + item.key + '" ' + checked + '>'
|
||||||
|
+ '<span class="notify-pref-slider"></span>'
|
||||||
|
+ '</label>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
bodyEl.innerHTML = html;
|
||||||
|
|
||||||
|
// 绑定切换事件
|
||||||
|
bodyEl.querySelectorAll('.notify-pref-cb').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
var key = cb.getAttribute('data-key');
|
||||||
|
var enabled = cb.checked;
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', '/api/settings/notify-prefs', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (r.success) {
|
||||||
|
showSettingsToast('通知偏好已更新');
|
||||||
|
} else {
|
||||||
|
showSettingsToast(r.message || '更新失败', true);
|
||||||
|
cb.checked = !enabled;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('更新失败', true);
|
||||||
|
cb.checked = !enabled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({ key: key, enabled: enabled }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
bodyEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrefs();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
413
templates/MetaLab-2026/static/js/settings-profile.js
Normal file
413
templates/MetaLab-2026/static/js/settings-profile.js
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
(function(){'use strict';
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var toastEl = document.getElementById('settingsToast');
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
function showSettingsToast(msg, isError) {
|
||||||
|
if (!toastEl) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
|
||||||
|
void toastEl.offsetWidth;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
toastEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
// ===== 个人资料 Tab =====
|
||||||
|
(function () {
|
||||||
|
var usernameInput = document.getElementById('usernameInput');
|
||||||
|
var bioInput = document.getElementById('bioInput');
|
||||||
|
var btn = document.getElementById('saveProfileBtn');
|
||||||
|
if (!usernameInput || !bioInput || !btn) return;
|
||||||
|
|
||||||
|
// ---- 字符计数器 ----
|
||||||
|
var usernameCounter = document.getElementById('usernameCounter');
|
||||||
|
var bioCounterEl = document.getElementById('bioCounter');
|
||||||
|
|
||||||
|
function updateCounters() {
|
||||||
|
if (usernameCounter) {
|
||||||
|
usernameCounter.textContent = usernameInput.value.length + '/16';
|
||||||
|
}
|
||||||
|
if (bioCounterEl) {
|
||||||
|
bioCounterEl.textContent = bioInput.value.length + '/128';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameInput.addEventListener('input', updateCounters);
|
||||||
|
bioInput.addEventListener('input', updateCounters);
|
||||||
|
updateCounters();
|
||||||
|
|
||||||
|
// ---- 原始值(用于判断是否变更) ----
|
||||||
|
var origUsername = usernameInput.value.trim();
|
||||||
|
var origBio = bioInput.value;
|
||||||
|
var hasCompletedRename = usernameInput.getAttribute('data-has-completed-rename') === 'true';
|
||||||
|
|
||||||
|
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
|
||||||
|
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var username = usernameInput.value.trim();
|
||||||
|
var bio = bioInput.value;
|
||||||
|
|
||||||
|
// 用户名校验
|
||||||
|
if (!username) {
|
||||||
|
showSettingsToast('用户名不能为空', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username.length > 16) {
|
||||||
|
showSettingsToast('用户名不能超过 16 个字符', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!usernameRe.test(username)) {
|
||||||
|
showSettingsToast('用户名仅支持中英文、数字、下划线、连字符', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 个性签名校验
|
||||||
|
if (bio.length > 128) {
|
||||||
|
showSettingsToast('个性签名不能超过 128 个字符', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无变更判断
|
||||||
|
if (username === origUsername && bio === origBio) {
|
||||||
|
showSettingsToast('内容未变更,无需保存');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户名有变更时弹出消耗确认
|
||||||
|
if (username !== origUsername) {
|
||||||
|
var confirmMsg = hasCompletedRename
|
||||||
|
? '确认修改用户名?\n\n本次将消耗 6 域能,是否继续?'
|
||||||
|
: '确认修改用户名?\n\n首次更名无需消耗域能。';
|
||||||
|
showConfirm('修改用户名', confirmMsg).then(function(ok) {
|
||||||
|
if (ok) doSave(username, bio);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
doSave(username, bio);
|
||||||
|
});
|
||||||
|
|
||||||
|
function doSave(username, bio) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '保存中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', '/api/settings/profile', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '保存';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast(res.message || '个人资料已更新');
|
||||||
|
origUsername = username;
|
||||||
|
origBio = bio;
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '保存失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('保存失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({ username: username, bio: bio }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 头像裁切上传 ----
|
||||||
|
var avatarWrap = document.getElementById('avatarWrap');
|
||||||
|
var avatarInput = document.getElementById('avatarInput');
|
||||||
|
var avatarPreview = document.getElementById('avatarPreview');
|
||||||
|
var cropOverlay = document.getElementById('cropOverlay');
|
||||||
|
var cropStage = document.getElementById('cropStage');
|
||||||
|
var cropImageEl = document.getElementById('cropImageEl');
|
||||||
|
var cropFrame = document.getElementById('cropFrame');
|
||||||
|
var cropMask = document.getElementById('cropMask');
|
||||||
|
var cropCancelBtn = document.getElementById('cropCancelBtn');
|
||||||
|
var cropConfirmBtn = document.getElementById('cropConfirmBtn');
|
||||||
|
|
||||||
|
var pendingFile = null;
|
||||||
|
var imgNW, imgNH; // 原图像素尺寸
|
||||||
|
var imgDW, imgDH; // 图片缩放后显示尺寸(zoom=1 基准)
|
||||||
|
var zoom = 1; // 图片缩放系数
|
||||||
|
var minZoom = 1; // 最小缩放系数(超大图限制选区不超过 3840px)
|
||||||
|
var panX = 0, panY = 0; // 图片平移偏移量
|
||||||
|
var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
|
||||||
|
var CROP_MAX_PX = 3840; // 选区最大像素限制
|
||||||
|
|
||||||
|
// 更新裁切框固定参数(图片加载时计算一次,之后不变)
|
||||||
|
function initFrame() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
fSize = Math.min(imgDW, imgDH); // 短边长度填满可见区域
|
||||||
|
fLeft = Math.round((sw - fSize) / 2);
|
||||||
|
fTop = Math.round((sh - fSize) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 约束 panX/panY,确保裁切框始终在图片范围内
|
||||||
|
function clampPan() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var iw = imgDW * zoom, ih = imgDH * zoom;
|
||||||
|
var cx = Math.round((sw - iw) / 2); // 图片居中时的基准偏移
|
||||||
|
var cy = Math.round((sh - ih) / 2);
|
||||||
|
panX = Math.max(fLeft + fSize - cx - iw, Math.min(panX, fLeft - cx));
|
||||||
|
panY = Math.max(fTop + fSize - cy - ih, Math.min(panY, fTop - cy));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCrop() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var iw = imgDW * zoom, ih = imgDH * zoom;
|
||||||
|
var ox = Math.round((sw - iw) / 2) + panX;
|
||||||
|
var oy = Math.round((sh - ih) / 2) + panY;
|
||||||
|
|
||||||
|
cropImageEl.style.width = iw + 'px';
|
||||||
|
cropImageEl.style.height = ih + 'px';
|
||||||
|
cropImageEl.style.left = ox + 'px';
|
||||||
|
cropImageEl.style.top = oy + 'px';
|
||||||
|
|
||||||
|
cropFrame.style.left = fLeft + 'px';
|
||||||
|
cropFrame.style.top = fTop + 'px';
|
||||||
|
cropFrame.style.width = fSize + 'px';
|
||||||
|
cropFrame.style.height = fSize + 'px';
|
||||||
|
|
||||||
|
cropMask.style.left = fLeft + 'px';
|
||||||
|
cropMask.style.top = fTop + 'px';
|
||||||
|
cropMask.style.width = fSize + 'px';
|
||||||
|
cropMask.style.height = fSize + 'px';
|
||||||
|
|
||||||
|
// 实时显示选区像素尺寸
|
||||||
|
var p = getCropParams();
|
||||||
|
var infoEl = document.getElementById('cropInfo');
|
||||||
|
if (infoEl) infoEl.textContent = p.size + ' × ' + p.size + ' px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCrop(file) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
cropImageEl.src = e.target.result;
|
||||||
|
cropImageEl.onload = function () {
|
||||||
|
imgNW = cropImageEl.naturalWidth;
|
||||||
|
imgNH = cropImageEl.naturalHeight;
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var fit = Math.min(sw / imgNW, sh / imgNH);
|
||||||
|
imgDW = Math.round(imgNW * fit);
|
||||||
|
imgDH = Math.round(imgNH * fit);
|
||||||
|
// 限制选框最大像素:原图短边超过 3840 时,抬高最小缩放
|
||||||
|
minZoom = Math.min(imgNW, imgNH) > CROP_MAX_PX
|
||||||
|
? Math.min(imgNW, imgNH) / CROP_MAX_PX
|
||||||
|
: 1;
|
||||||
|
zoom = minZoom;
|
||||||
|
panX = 0;
|
||||||
|
panY = 0;
|
||||||
|
initFrame();
|
||||||
|
renderCrop();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
cropOverlay.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCrop() {
|
||||||
|
cropOverlay.classList.remove('active');
|
||||||
|
avatarInput.value = '';
|
||||||
|
pendingFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCropParams() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var iw = imgDW * zoom, ih = imgDH * zoom;
|
||||||
|
var ox = Math.round((sw - iw) / 2) + panX;
|
||||||
|
var oy = Math.round((sh - ih) / 2) + panY;
|
||||||
|
var cx = (fLeft + fSize / 2 - ox) / zoom;
|
||||||
|
var cy = (fTop + fSize / 2 - oy) / zoom;
|
||||||
|
var cs = fSize / zoom;
|
||||||
|
var sx = imgNW / imgDW, sy = imgNH / imgDH;
|
||||||
|
return {
|
||||||
|
x: Math.round((cx - cs / 2) * sx),
|
||||||
|
y: Math.round((cy - cs / 2) * sy),
|
||||||
|
size: Math.round(cs * sx)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 图片平移 ----
|
||||||
|
var panning = false, panStartX = 0, panStartY = 0, panInitX = 0, panInitY = 0;
|
||||||
|
|
||||||
|
function startPan(clientX, clientY) {
|
||||||
|
panning = true;
|
||||||
|
panStartX = clientX;
|
||||||
|
panStartY = clientY;
|
||||||
|
panInitX = panX;
|
||||||
|
panInitY = panY;
|
||||||
|
cropStage.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePan(clientX, clientY) {
|
||||||
|
panX = panInitX + (clientX - panStartX);
|
||||||
|
panY = panInitY + (clientY - panStartY);
|
||||||
|
clampPan();
|
||||||
|
renderCrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
cropStage.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
startPan(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', function (e) {
|
||||||
|
if (!panning) return;
|
||||||
|
movePan(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', function () {
|
||||||
|
panning = false;
|
||||||
|
cropStage.style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
|
||||||
|
cropStage.addEventListener('touchstart', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var t = e.touches[0];
|
||||||
|
startPan(t.clientX, t.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('touchmove', function (e) {
|
||||||
|
if (!panning) return;
|
||||||
|
var t = e.touches[0];
|
||||||
|
movePan(t.clientX, t.clientY);
|
||||||
|
}, { passive: true });
|
||||||
|
document.addEventListener('touchend', function () {
|
||||||
|
panning = false;
|
||||||
|
cropStage.style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
|
||||||
|
cropStage.addEventListener('wheel', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (panning) return;
|
||||||
|
var newZoom = Math.max(minZoom, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3));
|
||||||
|
if (newZoom === zoom) return;
|
||||||
|
zoom = newZoom;
|
||||||
|
clampPan();
|
||||||
|
renderCrop();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
cropCancelBtn.addEventListener('click', closeCrop);
|
||||||
|
cropOverlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === cropOverlay) closeCrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 客户端裁剪 + 压缩:Canvas 裁出选区并一步到位缩放到 512px,上传体积最小
|
||||||
|
function cropOnClient(file, x, y, size) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var url = URL.createObjectURL(file);
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
var out = 512; // 直接缩放到服务端目标输出尺寸
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = out;
|
||||||
|
canvas.height = out;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, x, y, size, size, 0, 0, out, out);
|
||||||
|
canvas.toBlob(function (blob) {
|
||||||
|
resolve({
|
||||||
|
file: new File([blob], 'avatar.jpg', { type: blob.type || 'image/jpeg' }),
|
||||||
|
cropX: 0,
|
||||||
|
cropY: 0,
|
||||||
|
cropSize: out
|
||||||
|
});
|
||||||
|
}, 'image/jpeg', 0.88);
|
||||||
|
};
|
||||||
|
img.onerror = function () {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
// Canvas 处理失败时回退:直接上传原文件 + 原坐标
|
||||||
|
resolve({ file: file, cropX: x, cropY: y, cropSize: size });
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cropConfirmBtn.addEventListener('click', function () {
|
||||||
|
if (!pendingFile) return;
|
||||||
|
var params = getCropParams();
|
||||||
|
|
||||||
|
cropConfirmBtn.disabled = true;
|
||||||
|
cropConfirmBtn.textContent = '处理中...';
|
||||||
|
showSettingsToast('上传中...');
|
||||||
|
|
||||||
|
cropOnClient(pendingFile, params.x, params.y, params.size).then(function (result) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('avatar', result.file, result.file.name);
|
||||||
|
formData.append('crop_x', result.cropX);
|
||||||
|
formData.append('crop_y', result.cropY);
|
||||||
|
formData.append('crop_size', result.cropSize);
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/settings/avatar', true);
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
cropConfirmBtn.disabled = false;
|
||||||
|
cropConfirmBtn.textContent = '确认';
|
||||||
|
closeCrop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast(res.message || '头像已更新');
|
||||||
|
var newAvatarUrl = res.data && res.data.url && (res.data.url + '?t=' + Date.now());
|
||||||
|
if (newAvatarUrl && avatarPreview.tagName === 'IMG') {
|
||||||
|
avatarPreview.src = newAvatarUrl;
|
||||||
|
}
|
||||||
|
// 同步更新 NAV 头像
|
||||||
|
if (newAvatarUrl) {
|
||||||
|
var navLink = document.querySelector('.nav-avatar-link');
|
||||||
|
if (navLink) {
|
||||||
|
var navImg = navLink.querySelector('img.nav-avatar');
|
||||||
|
if (navImg) {
|
||||||
|
navImg.src = newAvatarUrl;
|
||||||
|
navImg.style.display = '';
|
||||||
|
var fb = navLink.querySelector('.nav-avatar-default');
|
||||||
|
if (fb) fb.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
var fb2 = navLink.querySelector('.nav-avatar-default');
|
||||||
|
if (fb2) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = newAvatarUrl;
|
||||||
|
img.alt = '';
|
||||||
|
img.className = 'nav-avatar';
|
||||||
|
img.onerror = function() { this.style.display = 'none'; this.nextElementSibling.style.display = 'flex'; };
|
||||||
|
navLink.insertBefore(img, fb2);
|
||||||
|
fb2.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '上传失败', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsToast('上传失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(formData);
|
||||||
|
}).catch(function () {
|
||||||
|
cropConfirmBtn.disabled = false;
|
||||||
|
cropConfirmBtn.textContent = '确认';
|
||||||
|
showSettingsToast('处理失败,请重试', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (avatarWrap && avatarInput) {
|
||||||
|
avatarWrap.addEventListener('click', function () {
|
||||||
|
avatarInput.click();
|
||||||
|
});
|
||||||
|
avatarInput.addEventListener('change', function () {
|
||||||
|
var file = avatarInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
pendingFile = file;
|
||||||
|
openCrop(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(); // 结束个人资料 Tab 子 IIFE
|
||||||
|
})();
|
||||||
151
templates/MetaLab-2026/static/js/settings-sessions.js
Normal file
151
templates/MetaLab-2026/static/js/settings-sessions.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
(function(){'use strict';
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var toastEl = document.getElementById('settingsToast');
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
function showSettingsToast(msg, isError) {
|
||||||
|
if (!toastEl) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
|
||||||
|
void toastEl.offsetWidth;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
toastEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
// ===== 登录管理 Tab =====
|
||||||
|
(function () {
|
||||||
|
var kickBtns = document.querySelectorAll('.session-kick-btn');
|
||||||
|
var kickOthersBtn = document.getElementById('kickOthersBtn');
|
||||||
|
var remarkInputs = document.querySelectorAll('.session-remark-input');
|
||||||
|
if (!kickBtns.length && !kickOthersBtn && !remarkInputs.length) return;
|
||||||
|
|
||||||
|
// ---- 踢出单个设备 ----
|
||||||
|
kickBtns.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var sid = btn.getAttribute('data-sid');
|
||||||
|
if (!sid) return;
|
||||||
|
showConfirm('确认', '确定要踢出该设备吗?').then(function(ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '踢出中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('DELETE', '/api/settings/sessions/' + encodeURIComponent(sid), true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '踢出';
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast(res.message || '已踢出该设备');
|
||||||
|
var item = btn.closest('.session-item');
|
||||||
|
if (item) {
|
||||||
|
item.style.transition = 'opacity .3s ease, transform .3s ease';
|
||||||
|
item.style.opacity = '0';
|
||||||
|
item.style.transform = 'translateX(20px)';
|
||||||
|
setTimeout(function () { item.remove(); }, 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '操作失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('操作失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 一键踢出其他设备 ----
|
||||||
|
if (kickOthersBtn) {
|
||||||
|
kickOthersBtn.addEventListener('click', function () {
|
||||||
|
var otherCount = document.querySelectorAll('.session-item:not(.session-current)').length;
|
||||||
|
if (otherCount === 0) {
|
||||||
|
showSettingsToast('没有其他在线设备');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showConfirm('确认', '确定要踢出所有其他设备(共 ' + otherCount + ' 台)吗?').then(function(ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
kickOthersBtn.disabled = true;
|
||||||
|
kickOthersBtn.textContent = '处理中...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/settings/sessions/destroy-others', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
kickOthersBtn.disabled = false;
|
||||||
|
kickOthersBtn.textContent = '一键踢出其他设备';
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast(res.message || '已踢出所有其他设备');
|
||||||
|
var others = document.querySelectorAll('.session-item:not(.session-current)');
|
||||||
|
others.forEach(function (item, i) {
|
||||||
|
setTimeout(function () {
|
||||||
|
item.style.transition = 'opacity .3s ease, transform .3s ease';
|
||||||
|
item.style.opacity = '0';
|
||||||
|
item.style.transform = 'translateX(20px)';
|
||||||
|
setTimeout(function () { item.remove(); }, 300);
|
||||||
|
}, i * 100);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '操作失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('操作失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 备注保存 ----
|
||||||
|
var remarkTimers = {};
|
||||||
|
remarkInputs.forEach(function (input) {
|
||||||
|
input.addEventListener('blur', function () {
|
||||||
|
var sid = input.getAttribute('data-sid');
|
||||||
|
var remark = input.value.trim();
|
||||||
|
if (!sid) return;
|
||||||
|
saveRemark(sid, remark);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveRemark(sid, remark) {
|
||||||
|
if (remarkTimers[sid]) clearTimeout(remarkTimers[sid]);
|
||||||
|
remarkTimers[sid] = setTimeout(function () {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', '/api/settings/sessions/' + encodeURIComponent(sid) + '/remark', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast('备注已保存');
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '保存失败', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSettingsToast('保存失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({ remark: remark }));
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
})();
|
||||||
@ -68,7 +68,7 @@
|
|||||||
评论管理
|
评论管理
|
||||||
</a>
|
</a>
|
||||||
{{if .CanManageSettings}}
|
{{if .CanManageSettings}}
|
||||||
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
|
<a href="/admin/settings/brand" class="sidebar-item {{if or (eq .CurrentPath "/admin/site-settings") (eq .CurrentPath "/admin/settings/brand") (eq .CurrentPath "/admin/settings/security") (eq .CurrentPath "/admin/settings/registration") (eq .CurrentPath "/admin/settings/content")}}active{{end}}">
|
||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
站点设置
|
站点设置
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script>
|
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
<script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
<script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
{{if .ExtraJS}}
|
{{if .ExtraJS}}
|
||||||
<script src="{{.ExtraJS}}" nonce="{{.CSPNonce}}"></script>
|
<script src="{{.ExtraJS}}?v={{assetV .ExtraJS}}" nonce="{{.CSPNonce}}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
7
templates/admin/html/site-settings/_menu.html
Normal file
7
templates/admin/html/site-settings/_menu.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<div class="settings-subnav">
|
||||||
|
<a href="/admin/settings/brand" class="subnav-item {{if eq .ActiveSetting "brand"}}active{{end}}">品牌与合规</a>
|
||||||
|
<a href="/admin/settings/security" class="subnav-item {{if eq .ActiveSetting "security"}}active{{end}}">安全与维护</a>
|
||||||
|
<a href="/admin/settings/registration" class="subnav-item {{if eq .ActiveSetting "registration"}}active{{end}}">注册策略</a>
|
||||||
|
<a href="/admin/settings/content" class="subnav-item {{if eq .ActiveSetting "content"}}active{{end}}">审核与内容</a>
|
||||||
|
<a href="/admin/settings/theme" class="subnav-item {{if eq .ActiveSetting "theme"}}active{{end}}">外观主题</a>
|
||||||
|
</div>
|
||||||
152
templates/admin/html/site-settings/brand.html
Normal file
152
templates/admin/html/site-settings/brand.html
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>站点设置</h1>
|
||||||
|
<p class="page-desc">管理站点基本配置和合规信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/site-settings/_menu.html" .}}
|
||||||
|
|
||||||
|
<!-- 基本设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">基本设置</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">站点名称</span>
|
||||||
|
<span class="setting-desc">全站品牌名(导航栏、标题、页脚等位置)</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-name" class="setting-input" placeholder="MetaLab" value="{{.SiteName}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.name" data-input="setting-site-name">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">站点标语</span>
|
||||||
|
<span class="setting-desc">首页 Hero 区域的副标题</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-tagline" class="setting-input" placeholder="下一代开发者社区" value="{{.SiteTagline}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.tagline" data-input="setting-site-tagline">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">联系邮箱</span>
|
||||||
|
<span class="setting-desc">申诉/联系邮箱,显示在社区准则等页面</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-email" class="setting-input" placeholder="metazone@foxmail.com" value="{{.ContactEmail}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.contact_email" data-input="setting-site-email">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">附加信息</span>
|
||||||
|
<span class="setting-desc">页脚附加信息,留空则隐藏(如 "Framework: MetaGenesis")</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-framework" class="setting-input" placeholder="留空则不显示" value="{{.Framework}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.framework" data-input="setting-site-framework">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">版权起始年份</span>
|
||||||
|
<span class="setting-desc">页脚版权标起始年(如 2024 → © 2024-当前年)</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-copyright" class="setting-input" placeholder="2024" value="{{.CopyrightStart}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.copyright_start" data-input="setting-site-copyright">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 合规信息 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">合规信息</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">运营主体</span>
|
||||||
|
<span class="setting-desc">页脚展示的运营方名称</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-operator" class="setting-input" placeholder="留空则不显示" value="{{.OperatorName}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.operator_name" data-input="setting-site-operator">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">隐私政策</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,如 <a href="/privacy">隐私政策</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-privacy" class="setting-input" placeholder="留空则不显示" value="{{.PrivacyURL}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.privacy_url" data-input="setting-site-privacy">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">服务条款</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,如 <a href="/terms">服务条款</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-terms" class="setting-input" placeholder="留空则不显示" value="{{.TermsURL}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.terms_url" data-input="setting-site-terms">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">ICP 备案</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,如 <a href="https://beian.miit.gov.cn">京ICP备XXXX号</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-icp" class="setting-input" placeholder="留空则不显示" value="{{.ICPNumber}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.icp_number" data-input="setting-site-icp">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">ICP 经营许可证</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,留空则不显示</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-icp-license" class="setting-input" placeholder="留空则不显示" value="{{.ICPLicense}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.icp_license" data-input="setting-site-icp-license">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">公安备案</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,留空则不显示</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-police" class="setting-input" placeholder="留空则不显示" value="{{.PoliceNumber}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.police_number" data-input="setting-site-police">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">文网文</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,留空则不显示</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-wenwangwen" class="setting-input" placeholder="留空则不显示" value="{{.WenWangWen}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.wenwangwen" data-input="setting-site-wenwangwen">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">算法备案</span>
|
||||||
|
<span class="setting-desc">纯手写 HTML,留空则不显示</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="text" id="setting-site-algorithm-filing" class="setting-input" placeholder="留空则不显示" value="{{.AlgorithmFiling}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="site.algorithm_filing" data-input="setting-site-algorithm-filing">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
83
templates/admin/html/site-settings/content.html
Normal file
83
templates/admin/html/site-settings/content.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>站点设置</h1>
|
||||||
|
<p class="page-desc">审核配置与内容管理</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/site-settings/_menu.html" .}}
|
||||||
|
|
||||||
|
<!-- 审核设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">审核设置</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">启用审核系统</span>
|
||||||
|
<span class="setting-desc">启用后,用户名、头像、签名修改将进入审核流程</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="audit-enabled" data-key="audit.enabled">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">用户名审核</span>
|
||||||
|
<span class="setting-desc">更改用户名需要管理员审核</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="audit-username" data-key="audit.username_audit">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">头像审核</span>
|
||||||
|
<span class="setting-desc">更改头像需要管理员审核</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="audit-avatar" data-key="audit.avatar_audit">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">签名审核</span>
|
||||||
|
<span class="setting-desc">更改个人签名需要管理员审核</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="audit-bio" data-key="audit.bio_audit">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容管理 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">内容管理</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item setting-item-vertical">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">社区准则</span>
|
||||||
|
<span class="setting-desc">注册时需用户阅读并同意,支持 HTML 格式</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-block">
|
||||||
|
<textarea id="setting-site-guidelines" class="setting-textarea" rows="12" placeholder="留空则使用默认准则文件"></textarea>
|
||||||
|
<button class="btn btn-sm btn-primary btn-save-block" data-key="site.guidelines" data-input="setting-site-guidelines">保存准则</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通用设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">其他设置</div>
|
||||||
|
<div class="section-body" id="general-settings">
|
||||||
|
<div class="settings-empty" id="settings-empty">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
@ -227,7 +227,7 @@
|
|||||||
<span class="setting-desc">强制阅读时的倒计时秒数</span>
|
<span class="setting-desc">强制阅读时的倒计时秒数</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-input-wrap">
|
<div class="setting-input-wrap">
|
||||||
<input type="number" id="setting-guidelines-timer" class="setting-input" placeholder="60" min="1" style="width:100px" value="{{.GuidelinesTimer}}">
|
<input type="number" id="setting-guidelines-timer" class="setting-input setting-input-narrow" placeholder="60" min="1" value="{{.GuidelinesTimer}}">
|
||||||
<button class="btn btn-sm btn-primary btn-save" data-key="registration.guidelines_timer" data-input="setting-guidelines-timer">保存</button>
|
<button class="btn btn-sm btn-primary btn-save" data-key="registration.guidelines_timer" data-input="setting-guidelines-timer">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
templates/admin/html/site-settings/registration.html
Normal file
55
templates/admin/html/site-settings/registration.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>站点设置</h1>
|
||||||
|
<p class="page-desc">注册策略与准则配置</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/site-settings/_menu.html" .}}
|
||||||
|
|
||||||
|
<!-- 注册设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">注册设置</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">允许注册</span>
|
||||||
|
<span class="setting-desc">关闭后,新用户将无法注册</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="registration-enabled" data-key="registration.enabled">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">弹窗显示准则</span>
|
||||||
|
<span class="setting-desc">注册时弹窗展示社区准则(关闭后仅显示复选框)</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="registration-show-guidelines" data-key="registration.show_guidelines">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">强制阅读准则</span>
|
||||||
|
<span class="setting-desc">弹窗模式下要求倒计时+滚动到底部(关闭后弹窗可直接确认)</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="registration-force-guidelines" data-key="registration.force_guidelines">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">阅读倒计时</span>
|
||||||
|
<span class="setting-desc">强制阅读时的倒计时秒数</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<input type="number" id="setting-guidelines-timer" class="setting-input setting-input-narrow" placeholder="60" min="1" value="{{.GuidelinesTimer}}">
|
||||||
|
<button class="btn btn-sm btn-primary btn-save" data-key="registration.guidelines_timer" data-input="setting-guidelines-timer">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
47
templates/admin/html/site-settings/security.html
Normal file
47
templates/admin/html/site-settings/security.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>站点设置</h1>
|
||||||
|
<p class="page-desc">安全策略与维护模式</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/site-settings/_menu.html" .}}
|
||||||
|
|
||||||
|
<!-- 安全设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">安全设置</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">密码强度</span>
|
||||||
|
<span class="setting-desc">注册和修改密码时的最低强度要求</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<select id="setting-password-strength" class="setting-select" data-key="password.strength">
|
||||||
|
<option value="low">低 — 仅要求 8 位</option>
|
||||||
|
<option value="medium" selected>中 — 字母+数字/符号(两类)</option>
|
||||||
|
<option value="high">高 — 大小写+数字+符号(至少三类)+ 禁止连续字符</option>
|
||||||
|
<option value="very_high">极高 — 四类全有 + 禁止连续字符</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-primary btn-save-select" data-key="password.strength" data-input="setting-password-strength">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 维护设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">维护设置</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">启用维护模式</span>
|
||||||
|
<span class="setting-desc">开启后仅站长可登录和访问,其他用户将看到维护提示</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="maintenance-enabled" data-key="maintenance.enabled">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
43
templates/admin/html/site-settings/theme.html
Normal file
43
templates/admin/html/site-settings/theme.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>站点设置</h1>
|
||||||
|
<p class="page-desc">管理与切换站点外观主题</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/site-settings/_menu.html" .}}
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">主题选择</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">当前主题</span>
|
||||||
|
<span class="setting-desc">切换主题后立即生效,无需重启服务</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<select id="theme-selector" class="setting-select">
|
||||||
|
<option value="">加载中...</option>
|
||||||
|
</select>
|
||||||
|
<button id="theme-save-btn" class="btn btn-sm btn-primary" disabled>保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">主题信息</span>
|
||||||
|
<span class="setting-desc">选中主题的详细描述</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-info-detail" id="theme-info">
|
||||||
|
<span class="setting-value">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="theme-status" class="settings-section">
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<span id="theme-status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
@ -123,3 +123,37 @@
|
|||||||
.toggle-switch input:disabled + .toggle-slider {
|
.toggle-switch input:disabled + .toggle-slider {
|
||||||
opacity: 0.4; cursor: not-allowed;
|
opacity: 0.4; cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- 下拉选择器 + 保存按钮 --- */
|
||||||
|
.setting-select {
|
||||||
|
padding: 6px 10px; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; font-size: 13px; min-width: 220px;
|
||||||
|
outline: none; background: #fff; cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.setting-select:focus { border-color: var(--primary); }
|
||||||
|
.btn-save-select { min-width: 56px; text-align: center; }
|
||||||
|
.btn-save-select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* --- 主题信息详情 --- */
|
||||||
|
.setting-info-detail {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
font-size: 13px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 隐藏工具类 --- */
|
||||||
|
#theme-status { display: none; }
|
||||||
|
#theme-status.visible { display: block; }
|
||||||
|
|
||||||
|
/* --- 窄输入框 --- */
|
||||||
|
.setting-input-narrow { width: 100px; }
|
||||||
|
|
||||||
|
/* --- 主题状态提示 --- */
|
||||||
|
.status-success .section-body {
|
||||||
|
background: #e8f5e9; border-left: 3px solid #4caf50;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
.status-error .section-body {
|
||||||
|
background: #fce4ec; border-left: 3px solid #f44336;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|||||||
108
templates/admin/static/js/site-settings-theme.js
Normal file
108
templates/admin/static/js/site-settings-theme.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/* =====================================================
|
||||||
|
MetaLab 管理面板 - 主题切换
|
||||||
|
立即生效,无需重启
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var selector = document.getElementById('theme-selector');
|
||||||
|
var saveBtn = document.getElementById('theme-save-btn');
|
||||||
|
var infoEl = document.getElementById('theme-info');
|
||||||
|
var statusSection = document.getElementById('theme-status');
|
||||||
|
var statusMsg = document.getElementById('theme-status-msg');
|
||||||
|
var themes = [];
|
||||||
|
var currentTheme = '';
|
||||||
|
|
||||||
|
async function loadThemes() {
|
||||||
|
try {
|
||||||
|
var res = await api.get('/api/admin/site-settings/themes');
|
||||||
|
if (!res.success) {
|
||||||
|
showToast('加载主题列表失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
themes = res.data.themes || [];
|
||||||
|
currentTheme = res.data.current || 'MetaLab-2026';
|
||||||
|
|
||||||
|
// 填充下拉
|
||||||
|
selector.innerHTML = '';
|
||||||
|
themes.forEach(function(t) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = t.dir;
|
||||||
|
opt.textContent = t.display_name || t.name;
|
||||||
|
if (t.dir === currentTheme) {
|
||||||
|
opt.selected = true;
|
||||||
|
updateInfo(t);
|
||||||
|
}
|
||||||
|
selector.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
|
||||||
|
// 选择变化时更新信息
|
||||||
|
selector.addEventListener('change', function() {
|
||||||
|
var selected = selector.value;
|
||||||
|
var found = themes.find(function(t) { return t.dir === selected; });
|
||||||
|
if (found) {
|
||||||
|
updateInfo(found);
|
||||||
|
}
|
||||||
|
saveBtn.disabled = (selected === currentTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
saveBtn.addEventListener('click', function() {
|
||||||
|
applyTheme(selector.value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
showToast('加载主题列表失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInfo(theme) {
|
||||||
|
var parts = [];
|
||||||
|
if (theme.display_name) parts.push(theme.display_name);
|
||||||
|
if (theme.version) parts.push('v' + theme.version);
|
||||||
|
if (theme.author) parts.push('作者:' + theme.author);
|
||||||
|
if (theme.description) parts.push(theme.description);
|
||||||
|
infoEl.innerHTML = '<span class="setting-value">' + escapeHtml(parts.join(' · ')) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTheme(themeDir) {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '切换中...';
|
||||||
|
showStatus('正在切换主题...', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await api.put('/api/admin/site-settings/theme', { theme: themeDir });
|
||||||
|
if (res.success) {
|
||||||
|
currentTheme = themeDir;
|
||||||
|
saveBtn.textContent = '已生效';
|
||||||
|
showToast('主题已切换,即时生效', 'success');
|
||||||
|
showStatus('✅ 主题已切换为:<strong>' + themeDir + '</strong>,所有访客可见新主题', 'success');
|
||||||
|
} else {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
showToast(res.message || '切换失败', 'error');
|
||||||
|
showStatus('❌ 切换失败:' + (res.message || '未知错误'), 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
showToast('切换失败', 'error');
|
||||||
|
showStatus('❌ 切换失败,请重试', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg, type) {
|
||||||
|
statusSection.className = 'settings-section visible';
|
||||||
|
if (type === 'success') {
|
||||||
|
statusSection.classList.add('status-success');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
statusSection.classList.add('status-error');
|
||||||
|
}
|
||||||
|
statusMsg.innerHTML = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThemes();
|
||||||
|
})();
|
||||||
@ -36,7 +36,10 @@
|
|||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
var res = await api.get('/api/admin/site-settings');
|
var res = await api.get('/api/admin/site-settings');
|
||||||
if (!res.success) return;
|
if (!res.success) {
|
||||||
|
showToast(res.message || '加载设置失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 所有开关
|
// 所有开关
|
||||||
var audit = res.data.audit || {};
|
var audit = res.data.audit || {};
|
||||||
@ -47,7 +50,7 @@
|
|||||||
|
|
||||||
if (t.id === 'maintenance-enabled') {
|
if (t.id === 'maintenance-enabled') {
|
||||||
el.checked = allSettings[t.key] === 'true';
|
el.checked = allSettings[t.key] === 'true';
|
||||||
} else if (t.id === 'registration-enabled') {
|
} else if (t.key.indexOf('registration.') === 0) {
|
||||||
el.checked = allSettings[t.key] !== 'false';
|
el.checked = allSettings[t.key] !== 'false';
|
||||||
} else {
|
} else {
|
||||||
el.checked = audit[t.key.split('.')[1]] === true;
|
el.checked = audit[t.key.split('.')[1]] === true;
|
||||||
@ -60,6 +63,12 @@
|
|||||||
// 文本输入保存按钮
|
// 文本输入保存按钮
|
||||||
bindSaveButtons();
|
bindSaveButtons();
|
||||||
|
|
||||||
|
// 密码强度下拉(服务端渲染默认值,需从 API 覆盖以显示最新保存值)
|
||||||
|
var strengthSelect = document.getElementById('setting-password-strength');
|
||||||
|
if (strengthSelect && allSettings['password.strength']) {
|
||||||
|
strengthSelect.value = allSettings['password.strength'];
|
||||||
|
}
|
||||||
|
|
||||||
// 社区准则文本域
|
// 社区准则文本域
|
||||||
var guidelinesEl = document.getElementById('setting-site-guidelines');
|
var guidelinesEl = document.getElementById('setting-site-guidelines');
|
||||||
if (guidelinesEl) {
|
if (guidelinesEl) {
|
||||||
@ -138,6 +147,7 @@
|
|||||||
// --- 渲染其他设置(过滤已有专属UI的key) ---
|
// --- 渲染其他设置(过滤已有专属UI的key) ---
|
||||||
function renderOtherSettings(all) {
|
function renderOtherSettings(all) {
|
||||||
var container = document.getElementById('general-settings');
|
var container = document.getElementById('general-settings');
|
||||||
|
if (!container) return; // 部分页面无此容器
|
||||||
var filtered = [];
|
var filtered = [];
|
||||||
Object.keys(all).forEach(function(k) {
|
Object.keys(all).forEach(function(k) {
|
||||||
if (!DEDICATED_KEYS[k]) filtered.push(k);
|
if (!DEDICATED_KEYS[k]) filtered.push(k);
|
||||||
|
|||||||
Reference in New Issue
Block a user