diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md
index a131be8..8897b36 100644
--- a/.cursor/project_memory.md
+++ b/.cursor/project_memory.md
@@ -25,6 +25,89 @@
---
+## 官方运行时(用户 2026-09-18 冻结,跨会话必须延续,未开工)
+
+管理员可在后台一键更新 **不做本期**。先做「官方运行时」:一种可替换的交付形态。运行时契约冻住后很少改;论坛功能走新镜像 / 新包。用户主路径是 Docker `pull` + `up`。有空再按下列阶段开发,未轮到的阶段不要提前做。
+
+### 已对齐的产品结论
+
+- **两层分开**:运行时 = Dockerfile / Compose / volume / 环境变量;应用 = Go + Next。反代不进官方 Compose,由部署方自备。用户日常更新应用,不重装运行时。
+- **官方运行时 = Docker Compose 一体交付**(`web` + `api` + Postgres)。预编译包是同一目录布局的第二种皮,排在 Docker 之后。
+- **真 SSR 不能扔**:Next.js 16 必须有 Node 进程,禁止 `output:'export'` 塞进 Go 当静态站。
+- **必须同一 Host**:页面、`/api/`、`/api/ws`、`/uploads` 同域;`__Host-` cookie 与 WS Origin 不允许 API 另开子域。
+- **数据与程序分离**:Postgres volume、`data/`(上传与 JWT 密钥)、配置(env / 挂载的 `app.ini`)一律不进镜像。
+- **本地开发不动**:仓库根现有 `docker-compose.yml` 只起 Postgres,配合 `start.bat` / `cd backend && go run ./cmd/jiang13` + `frontend` `npm run dev`。官方运行时用另一份 Compose(如 `deploy/docker-compose.yml`),禁止把开发流程改成「本地也必须打应用镜像」。
+- **更新方式**:Docker 用户 `docker compose pull && docker compose up -d`;启动时既有 `AutoMigrate` 即可。接受短暂停机,上线前仍禁止为双版本并存写兼容垫片。
+- **明确不做**:源码机上 `git pull` + `go build` / `next build` 当官方路径;把 Docker socket 交给 Web 进程;Cloudflare Workers 拆分部署当官方运行时(可当高级可选项,不走一键更新);后台一键替换进程(等运行时契约冻住且确有非 Docker 用户再单独立项)。
+
+### 目标拓扑(官方 Compose)
+
+```
+浏览器 ──同 Host──► 宿主机反代(Nginx/Caddy,自备)
+ ├─ 页面 / RSC → 127.0.0.1:3000(web)
+ ├─ /api /api/ws → 127.0.0.1:3001(api)
+ └─ /uploads → 127.0.0.1:3001(api)
+Postgres volume ──► postgres:17
+data volume ──► Go 的 dataDir(与本地 backend/data 语义一致)
+```
+
+- Go 工作目录 / `JIANG13_WORK_PATH` 对准容器内数据根,启动日志须含 `workPath` + `dataDir`。
+- `DEV_MODE=false`;`JWT_SECRET`、DSN、站点 URL 走环境变量,镜像不含真实 `app.ini`。
+- 健康检查:Go `/health` 与 Next `/healthz`;反代须透传 `Upgrade` / `Connection`,保留 `Host` / `Origin`。
+- Compose **不**跑 Nginx/Caddy。示例配置:`deploy/nginx.example.conf`。
+
+### 落地进度(2026-09-18)
+
+阶段 1–3 已落地:`deploy/` 为 `web` + `api` + `postgres`,不含反代;宿主机 Nginx 示例见 `deploy/nginx.example.conf`。仓库根 `docker-compose.yml` / `start.bat` 未改。阶段 4–6 仍未做。
+
+### 开发阶段(按序,未点名不做)
+
+**阶段 1 — 版本与契约(开工第一步)**
+
+- 前后端同一 semver:Go `ldflags` 注入;前端构建期写入(勿再用页脚空占位当正式版本)。
+- 写死运行时契约:服务名、端口、volume 名、环境变量表、数据目录含义。契约变更视为运行时大版本,须 changelog。
+- Release 产物:带 tag 的镜像 + 校验和;changelog;**最低可升级版本**(跨大版本禁止跳迁)。
+
+**阶段 2 — 官方 Docker 运行时(主交付)**
+
+- 多阶段 Dockerfile:编 Go 二进制、编 Next `standalone`,运行镜像不含源码与工具链。
+- `deploy/docker-compose.yml`:`web` + `api` + `postgres`。反代不进 Compose,由部署方用 Nginx 等同源反代。
+- volume:`pgdata`、`appdata`(映射到 Go `dataDir`)。配置只挂 env / 可选 `app.ini`。
+- 首次 `up` 即迁移 + 可登录;文档只写 Compose 步骤,不要求宿主机装 Go/Node。
+- 回归:同 Host cookie、WS 握手、头像 `/uploads`、重启容器数据还在。
+
+**阶段 3 — 文档化更新(Docker 用户的官方升级)**
+
+- 标准命令:`docker compose pull && docker compose up -d`。
+- 说明:短暂停机、备份(pg dump + `appdata`)、失败则改回上一 tag。
+- Compose 文件本身尽量向前兼容(新 env 有默认);真要改文件时 changelog 写「请合并 compose」,与「只 pull 应用镜像」分开。
+
+**阶段 4 — 预编译包(可选,布局与镜像内一致)**
+
+- 包内:`jiang13`(或 `.exe`)、Next standalone、示例配置、watchdog/supervisor。
+- Windows 不能覆盖正在运行的 exe,必须靠外层 watchdog 换进程;Linux 可 `exec` 或 systemd。
+- 与 Docker 共用版本号与数据目录语义;更新仍是换文件 + 重启,不是本机编译。
+
+**阶段 5 — 后台「检查更新」(只读,非一键)**
+
+- 仅 `owner`:当前版本、频道(稳定/测试)、changelog、最低可升级版本。
+- Docker 用户展示官方 `pull && up` 说明;不支持的部署方式如实说「请按文档升级」。
+- 不做下载、不做替换、不暴露 Docker socket。
+
+**阶段 6 — 面板一键更新(明确延后,勿提前做)**
+
+- 仅官方 Docker(updater sidecar,禁止 app 挂 docker.sock)或官方 watchdog 包。
+- 站长 + CSRF + 验签 + 审计;先备份再切;健康检查失败回滚。
+- 源码部署 / Workers / 用户自管 systemd:永远只显示「当前部署不支持在线更新」。
+
+### 开工时注意
+
+- 先改契约与 `deploy/`,不要改 `jiang13-bbs-local-dev` 的 cwd / 端口铁律。
+- 镜像构建在 CI 或维护者机器;不要把 `node_modules`、`.next`、`backend/data`、真实 `app.ini` 打进 git 或镜像。
+- 生产 `DEV_MODE=false`;反代必须同源。Workers 前端不是官方运行时验收范围。
+
+---
+
## Next.js 16 关键约束
- params/searchParams/cookies()/headers()/draftMode() 必须 await
diff --git a/.cursor/rules/jiang13-bbs-local-dev.mdc b/.cursor/rules/jiang13-bbs-local-dev.mdc
index bec1d0a..e2fb3a4 100644
--- a/.cursor/rules/jiang13-bbs-local-dev.mdc
+++ b/.cursor/rules/jiang13-bbs-local-dev.mdc
@@ -64,3 +64,7 @@ npm.cmd run dev
## 遗留目录
`backend/cmd/jiang13/data/` 是错误 cwd 的产物,禁止当数据源。发现后把 `uploads/` 合并进 `backend/data/uploads/`,再删遗留目录。
+
+## 官方运行时(与本地开发分开)
+
+生产交付在 `deploy/`(Next standalone + Go + Postgres,反代自备),契约见 `deploy/CONTRACT.md`。仓库根 `docker-compose.yml` **只**给本地 Postgres 用。禁止把 `start.bat` / `go run` / `npm run dev` 改成必须打应用镜像;禁止对开发库执行 `deploy` 目录的 `docker compose down -v`。
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..948b87b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+.git
+.cursor
+**/.env
+**/.env.local
+**/.env.*.local
+frontend
+frontend/node_modules
+frontend/.next
+backend/data
+backend/cmd/jiang13/data
+backend/app.ini
+backend/**/*_test.go
+*.md
+*.exe
+*.test
diff --git a/README.md b/README.md
index bfd98f2..b12b00f 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
| 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 |
| 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 |
| 认证 | JWT (HttpOnly Cookie) + Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 |
-| 部署 | Docker Compose(数据库)· 可部署至 Cloudflare Workers(前端) |
+| 部署 | 官方:`deploy/` Docker Compose(Next SSR + Go + Postgres,反代自备);本地开发仅 Compose 起数据库 |
---
@@ -91,7 +91,8 @@ jiang13-bbs/
│ ├── middleware.ts # SSR 登录态静默轮转
│ ├── next.config.ts # Rewrite 代理 / 图片缓存
│ └── .env.example
-├── docker-compose.yml # PostgreSQL 容器
+├── deploy/ # 官方运行时(web + api + Postgres,反代自备)
+├── docker-compose.yml # 仅本地开发 Postgres
├── LICENSE # 专有软件许可协议
└── .gitignore
```
@@ -199,13 +200,25 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
## 部署
+**官方运行时是 `deploy/docker-compose.yml`(Next SSR + Go + Postgres,不含反代)。** 说明与升级命令见 [deploy/README.md](./deploy/README.md),Nginx 示例见 [deploy/nginx.example.conf](./deploy/nginx.example.conf),契约见 [deploy/CONTRACT.md](./deploy/CONTRACT.md)。
+
+本地开发仍用仓库根 `start.bat` / `start.sh` 与根目录 `docker-compose.yml`(只起 Postgres)。不要把开发流程改成必须打应用镜像。
+
+更新应用(镜像已发布时):
+
+```bash
+cd deploy
+docker compose pull
+docker compose up -d
+```
+
+后台一键更新、预编译包不在本期。Cloudflare Workers 不是官方运行时。
+
### 生产环境要点
-1. **后端:** 设置 `DEV_MODE=false`,指定强随机 `JWT_SECRET`,通过反向代理(Nginx/Caddy)提供 HTTPS。
-2. **前端:** 设置 `BACKEND_URL` 为后端 HTTPS 地址,`npm run build` 后部署。Cloudflare Workers 部署需保持 `middleware.ts` 文件名不变(OpenNext 不识别 `proxy.ts`)。
-3. **数据库:** 修改默认密码,启用 SSL 连接。
-4. **CORS:** 生产环境修改 `router.go` 中的 `AllowOrigins` 为实际域名。
-5. **WebSocket:** 必须与页面同一 Host(见下节),不要把 API 单独放到另一个子域。
+1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。
+2. **CORS:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。
+3. **自管部署:** 仍须同源反代。Workers 前端须保持 `middleware.ts` 文件名(OpenNext 不识别 `proxy.ts`),实时通道由反代承接 `/api/ws`。
### WebSocket(开发 vs 生产)
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go
index 0bffc7e..8e55be4 100644
--- a/backend/cmd/jiang13/main.go
+++ b/backend/cmd/jiang13/main.go
@@ -8,6 +8,7 @@ import (
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/router"
"github.com/freefire/jiang13-bbs/service"
+ "github.com/freefire/jiang13-bbs/version"
)
func main() {
@@ -33,7 +34,7 @@ func main() {
}
addr := fmt.Sprintf(":%d", cfg.Port)
- log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v workPath=%s dataDir=%s)", addr, cfg.DevMode, cfg.WorkPath, cfg.DataDir)
+ log.Printf("[jiang13-bbs] 服务启动于 %s (version=%s commit=%s dev=%v workPath=%s dataDir=%s)", addr, version.Version, version.Commit, cfg.DevMode, cfg.WorkPath, cfg.DataDir)
if err := r.Run(addr); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
diff --git a/backend/config/config.go b/backend/config/config.go
index 7b11840..0502ca1 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -12,12 +12,14 @@ import (
// Config 应用全局配置
type Config struct {
- WorkPath string
- Port int
- DataDir string
- JWTSecret string
- DBDSN string
- DevMode bool
+ WorkPath string
+ Port int
+ DataDir string
+ JWTSecret string
+ DBDSN string
+ DevMode bool
+ SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
+ CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
}
// Parse 解析配置:环境变量 > app.ini > 默认值。
@@ -84,6 +86,19 @@ func Parse() (*Config, error) {
if v := os.Getenv("DEV_MODE"); v != "" {
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
}
+ if v := strings.TrimSpace(os.Getenv("SITE_URL")); v != "" {
+ cfg.SiteURL = strings.TrimRight(v, "/")
+ }
+ if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" {
+ cfg.CORSOrigins = splitCSVOrigins(v)
+ }
+ if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" {
+ abs, err := filepath.Abs(v)
+ if err != nil {
+ return nil, fmt.Errorf("解析 DATA_DIR 失败: %w", err)
+ }
+ cfg.DataDir = abs
+ }
if err := rejectStrayDataDir(cfg.DataDir); err != nil {
return nil, err
@@ -108,6 +123,40 @@ func Parse() (*Config, error) {
return cfg, nil
}
+// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
+// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
+// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。
+func (c *Config) AllowOrigin(origin string) bool {
+ if origin == "" {
+ return false
+ }
+ origin = strings.TrimRight(origin, "/")
+ if c.DevMode {
+ return origin == "http://localhost:3000" || origin == "http://127.0.0.1:3000"
+ }
+ if c.SiteURL != "" && origin == c.SiteURL {
+ return true
+ }
+ for _, o := range c.CORSOrigins {
+ if origin == o {
+ return true
+ }
+ }
+ return false
+}
+
+func splitCSVOrigins(v string) []string {
+ parts := strings.Split(v, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ s := strings.TrimRight(strings.TrimSpace(p), "/")
+ if s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
// resolveWorkPath 定位后端根目录(app.ini 与 data/ 所在处)。
// 优先级:JIANG13_WORK_PATH > 从 cwd 向上找 app.ini > 源码树 backend/ > cwd。
func resolveWorkPath() (string, error) {
diff --git a/backend/config/config_test.go b/backend/config/config_test.go
index c38d621..f97c052 100644
--- a/backend/config/config_test.go
+++ b/backend/config/config_test.go
@@ -88,3 +88,54 @@ func TestRejectStrayDataDir(t *testing.T) {
t.Fatal(err)
}
}
+
+func TestAllowOriginDevAndProd(t *testing.T) {
+ dev := &Config{DevMode: true}
+ if !dev.AllowOrigin("http://localhost:3000") || !dev.AllowOrigin("http://127.0.0.1:3000") {
+ t.Fatal("开发态应放行 next dev Origin")
+ }
+ if dev.AllowOrigin("https://bbs.example.com") {
+ t.Fatal("开发态不应放行生产域名")
+ }
+
+ prod := &Config{
+ DevMode: false,
+ SiteURL: "https://bbs.example.com",
+ CORSOrigins: []string{"https://mirror.example.com"},
+ }
+ if !prod.AllowOrigin("https://bbs.example.com/") {
+ t.Fatal("生产态应放行 SITE_URL")
+ }
+ if !prod.AllowOrigin("https://mirror.example.com") {
+ t.Fatal("生产态应放行 CORS_ORIGINS")
+ }
+ if prod.AllowOrigin("http://localhost:3000") {
+ t.Fatal("生产态不应放行 localhost")
+ }
+}
+
+func TestParseSiteURLAndDataDir(t *testing.T) {
+ work := t.TempDir()
+ data := t.TempDir()
+ t.Setenv("JIANG13_WORK_PATH", work)
+ t.Setenv("DEV_MODE", "false")
+ t.Setenv("SITE_URL", "https://bbs.example.com/")
+ t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ")
+ t.Setenv("DATA_DIR", data)
+ t.Setenv("JWT_SECRET", "test-secret-not-for-prod")
+
+ cfg, err := Parse()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.DevMode {
+ t.Fatal("DEV_MODE=false 应关闭开发态")
+ }
+ if cfg.SiteURL != "https://bbs.example.com" {
+ t.Fatalf("SITE_URL 未去尾斜杠: %q", cfg.SiteURL)
+ }
+ if len(cfg.CORSOrigins) != 2 || cfg.CORSOrigins[0] != "https://a.example.com" || cfg.CORSOrigins[1] != "https://b.example.com" {
+ t.Fatalf("CORS_ORIGINS 解析错误: %#v", cfg.CORSOrigins)
+ }
+ assertPath(t, cfg.DataDir, data)
+}
diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go
index 132e418..bd3c0d8 100644
--- a/backend/handler/admin_user.go
+++ b/backend/handler/admin_user.go
@@ -26,6 +26,8 @@ func (h *Handlers) AdminListUsers(c *gin.Context) {
Keyword: strings.TrimSpace(c.Query("q")),
Role: c.Query("role"),
Status: c.Query("status"),
+ Sort: c.Query("sort"),
+ Dir: c.Query("order"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})
diff --git a/backend/handler/health.go b/backend/handler/health.go
index 825bf20..6d401c6 100644
--- a/backend/handler/health.go
+++ b/backend/handler/health.go
@@ -4,14 +4,23 @@ import (
"net/http"
"github.com/freefire/jiang13-bbs/model"
+ "github.com/freefire/jiang13-bbs/version"
"github.com/gin-gonic/gin"
)
-// Health 健康检查
+// Health 健康检查(官方运行时 Docker HEALTHCHECK 与反代 /health)
func (h *Handlers) Health(c *gin.Context) {
if err := model.PingDB(); err != nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()})
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "status": "error",
+ "error": err.Error(),
+ "version": version.Version,
+ })
return
}
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ c.JSON(http.StatusOK, gin.H{
+ "status": "ok",
+ "version": version.Version,
+ "commit": version.Commit,
+ })
}
diff --git a/backend/handler/setting.go b/backend/handler/setting.go
index f998acf..0482d0a 100644
--- a/backend/handler/setting.go
+++ b/backend/handler/setting.go
@@ -41,8 +41,10 @@ type updateSettingsRequest struct {
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
- CustomCSS *string `json:"custom_css"`
- CustomJS *string `json:"custom_js"`
+ BgSiteURL *string `json:"bg_site_url"`
+ BgSiteMode *string `json:"bg_site_mode"`
+ BgAdminURL *string `json:"bg_admin_url"`
+ BgAdminMode *string `json:"bg_admin_mode"`
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
}
@@ -68,8 +70,10 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"attachment_max_mb": saved.AttachmentMaxMB,
"attachment_max_count": saved.AttachmentMaxCount,
"image_max_mb": saved.ImageMaxMB,
- "custom_css": saved.CustomCSS,
- "custom_js": saved.CustomJS,
+ "bg_site_url": saved.BgSiteURL,
+ "bg_site_mode": saved.BgSiteMode,
+ "bg_admin_url": saved.BgAdminURL,
+ "bg_admin_mode": saved.BgAdminMode,
}
}
@@ -80,8 +84,9 @@ func (req *updateSettingsRequest) hasAny() bool {
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
- req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.CustomCSS != nil ||
- req.CustomJS != nil || req.TimelineGitImport != nil
+ req.AttachmentMaxCount != nil || req.ImageMaxMB != nil ||
+ req.BgSiteURL != nil || req.BgSiteMode != nil ||
+ req.BgAdminURL != nil || req.BgAdminMode != nil || req.TimelineGitImport != nil
}
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
@@ -269,20 +274,32 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
- if req.CustomCSS != nil {
- if err := h.Setting.SetCustomCSS(*req.CustomCSS); err != nil {
+ if req.BgSiteURL != nil {
+ if err := h.applyBgURL(service.BgSurfaceSite, *req.BgSiteURL); err != nil {
+ writeBgSettingError(c, err)
+ return
+ }
+ }
+ if req.BgAdminURL != nil {
+ if err := h.applyBgURL(service.BgSurfaceAdmin, *req.BgAdminURL); err != nil {
+ writeBgSettingError(c, err)
+ return
+ }
+ }
+ if req.BgSiteMode != nil {
+ if err := h.Setting.SetBgSiteMode(*req.BgSiteMode); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 CSS 过长或含非法闭合标签"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
- if req.CustomJS != nil {
- if err := h.Setting.SetCustomJS(*req.CustomJS); err != nil {
+ if req.BgAdminMode != nil {
+ if err := h.Setting.SetBgAdminMode(*req.BgAdminMode); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 JS 过长或含非法闭合标签"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
@@ -318,3 +335,46 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
}
c.JSON(http.StatusOK, out)
}
+
+func writeBgSettingError(c *gin.Context, err error) {
+ if errors.Is(err, service.ErrInvalidSiteSetting) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "背景图地址无效"})
+ return
+ }
+ if errors.Is(err, service.ErrBgFileMissing) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+}
+
+// applyBgURL 写入对应轨的背景 URL,并删除不再被引用的旧文件
+func (h *Handlers) applyBgURL(surface, url string) error {
+ u, ok := service.NormalizeBgURL(url)
+ if !ok {
+ return service.ErrInvalidSiteSetting
+ }
+ if u != "" && !h.Upload.BackgroundFileExists(u) {
+ return service.ErrBgFileMissing
+ }
+ oldSite, err := h.Setting.BgSiteURL()
+ if err != nil {
+ return err
+ }
+ oldAdmin, err := h.Setting.BgAdminURL()
+ if err != nil {
+ return err
+ }
+ if surface == service.BgSurfaceAdmin {
+ if err := h.Setting.SetBgAdminURL(u); err != nil {
+ return err
+ }
+ h.Upload.RemoveBackgroundIfUnused(oldAdmin, oldSite, u)
+ return nil
+ }
+ if err := h.Setting.SetBgSiteURL(u); err != nil {
+ return err
+ }
+ h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin)
+ return nil
+}
diff --git a/backend/handler/upload.go b/backend/handler/upload.go
index 5f33600..f13ab9f 100644
--- a/backend/handler/upload.go
+++ b/backend/handler/upload.go
@@ -7,6 +7,7 @@ import (
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
+ "github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -165,3 +166,67 @@ func (h *Handlers) DeleteAttachment(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+
+// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),并立即绑定前台或后台
+func (h *Handlers) UploadBackground(c *gin.Context) {
+ surface, ok := service.NormalizeBgSurface(c.PostForm("surface"))
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"})
+ return
+ }
+
+ limit := int64(service.BackgroundMaxBytes + 4096)
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
+
+ fh, err := c.FormFile("file")
+ if err != nil {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
+ return
+ }
+ if fh.Size > service.BackgroundMaxBytes {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
+ return
+ }
+ f, err := fh.Open()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
+ return
+ }
+ defer f.Close()
+
+ url, err := h.Upload.SaveBackground(f)
+ if err != nil {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if err := h.applyBgURL(surface, url); err != nil {
+ writeBgSettingError(c, err)
+ return
+ }
+
+ saved, err := h.Setting.Public()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
+ return
+ }
+ payload := settingsPayload(saved)
+ h.Hub.BroadcastAll(realtime.Envelope{
+ Type: realtime.EventSettingsChanged,
+ Data: payload,
+ })
+ out := settingsPayload(saved)
+ out["ok"] = true
+ out["url"] = url
+ c.JSON(http.StatusOK, out)
+}
diff --git a/backend/model/db.go b/backend/model/db.go
index 8525c59..4e1c3e8 100644
--- a/backend/model/db.go
+++ b/backend/model/db.go
@@ -270,7 +270,8 @@ func dropLegacyRefreshTokenColumn(db *gorm.DB) error {
}
// ensureOwnerRole 若无站长,则把 id 最小的旧管理员升级为站长;
-// 连管理员都没有的全新库,把 id=1 的初始账号设为站长
+// 连管理员都没有则升级 id 最小的账号。空库(尚无用户)直接返回,等首次注册。
+// 禁止 GORM Update+Limit:无 WHERE 主键时会生成全表 UPDATE 并报 WHERE conditions required。
func ensureOwnerRole(db *gorm.DB) error {
var ownerCount int64
if err := db.Model(&User{}).Where("role = ?", RoleOwner).Count(&ownerCount).Error; err != nil {
@@ -279,16 +280,20 @@ func ensureOwnerRole(db *gorm.DB) error {
if ownerCount > 0 {
return nil
}
- res := db.Model(&User{}).Where("role = ?", RoleAdmin).
- Order("id ASC").Limit(1).Update("role", RoleOwner)
- if res.Error != nil {
- return res.Error
+
+ var candidate User
+ err := db.Select("id").Where("role = ?", RoleAdmin).Order("id ASC").First(&candidate).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ err = db.Select("id").Order("id ASC").First(&candidate).Error
}
- if res.RowsAffected == 0 {
- if err := db.Model(&User{}).Order("id ASC").Limit(1).
- Update("role", RoleOwner).Error; err != nil {
- return err
- }
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err := db.Model(&User{}).Where("id = ?", candidate.ID).Update("role", RoleOwner).Error; err != nil {
+ return err
}
log.Println("[model] 已迁移初始账号为站长角色(owner)")
return nil
diff --git a/backend/router/router.go b/backend/router/router.go
index fe53b7f..4906109 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -31,9 +31,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 全局安全响应头
r.Use(middleware.SecurityHeaders())
- // CORS
+ // CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域)
r.Use(cors.New(cors.Config{
- AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
+ AllowOriginFunc: cfg.AllowOrigin,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
@@ -266,6 +266,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 站点外观设置(超级管理员/站长);含 timeline_git_import
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
+ staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go
index 9789549..aa0526c 100644
--- a/backend/service/admin_user.go
+++ b/backend/service/admin_user.go
@@ -2,10 +2,13 @@ package service
import (
"errors"
+ "fmt"
+ "strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// 管理员用户操作的业务护栏(handler 层映射为 4xx,前端只展示消息)
@@ -86,6 +89,43 @@ type AdminUserListQuery struct {
Keyword string // 用户名 / 昵称 / 邮箱模糊匹配
Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举
Status string // "" 全部 | "banned" 已封禁 | "normal" 正常
+ Sort string // 白名单列:id / role / status / points / last_login / counts / created_at / online
+ Dir string // asc | desc;非法值按 desc
+}
+
+// adminUserOrderSQL 生成 ORDER BY 表达式。sort/dir 均走白名单,默认 id DESC(与历史行为一致)。
+func adminUserOrderSQL(sort, dir string) string {
+ ord := "DESC"
+ if strings.EqualFold(strings.TrimSpace(dir), "asc") {
+ ord = "ASC"
+ }
+ switch strings.TrimSpace(sort) {
+ case "role":
+ return fmt.Sprintf(
+ "CASE users.role WHEN 'owner' THEN 100 WHEN 'super_admin' THEN 80 WHEN 'admin' THEN 50 WHEN 'board_admin' THEN 30 ELSE 0 END %s, users.id DESC",
+ ord,
+ )
+ case "status":
+ return fmt.Sprintf("users.banned %s, users.id DESC", ord)
+ case "points":
+ return fmt.Sprintf("users.points %s, users.id DESC", ord)
+ case "created_at":
+ return fmt.Sprintf("users.created_at %s, users.id DESC", ord)
+ case "online":
+ return fmt.Sprintf("users.last_seen_at %s NULLS LAST, users.id DESC", ord)
+ case "last_login":
+ return fmt.Sprintf(
+ "(SELECT MAX(created_at) FROM login_logs WHERE login_logs.user_id = users.id AND login_logs.success = true) %s NULLS LAST, users.id DESC",
+ ord,
+ )
+ case "counts":
+ return fmt.Sprintf(
+ "(SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id AND posts.status = '%s' AND posts.deleted_at IS NULL) %s, users.id DESC",
+ model.ContentStatusPublished, ord,
+ )
+ default:
+ return "users.id " + ord
+ }
}
// AdminUserListResult 分页结果 + 汇总
@@ -129,7 +169,9 @@ func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, err
}
var users []model.User
- if err := query.Order("id DESC").
+ if err := query.Clauses(clause.OrderBy{
+ Expression: clause.Expr{SQL: adminUserOrderSQL(q.Sort, q.Dir)},
+ }).
Offset((q.Page - 1) * q.Size).Limit(q.Size).
Find(&users).Error; err != nil {
return nil, err
diff --git a/backend/service/admin_user_sort_test.go b/backend/service/admin_user_sort_test.go
new file mode 100644
index 0000000..b224989
--- /dev/null
+++ b/backend/service/admin_user_sort_test.go
@@ -0,0 +1,46 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/freefire/jiang13-bbs/model"
+)
+
+func TestAdminUserOrderSQLDefault(t *testing.T) {
+ got := adminUserOrderSQL("", "")
+ if got != "users.id DESC" {
+ t.Fatalf("默认排序应为 id DESC,得到 %q", got)
+ }
+ if adminUserOrderSQL("unknown", "drop table") != "users.id DESC" {
+ t.Fatalf("非法 sort/dir 必须回落到 id DESC")
+ }
+}
+
+func TestAdminUserOrderSQLWhitelist(t *testing.T) {
+ cases := []struct {
+ sort, dir, wantSub string
+ asc bool
+ }{
+ {"id", "asc", "users.id ASC", true},
+ {"points", "desc", "users.points DESC, users.id DESC", false},
+ {"status", "ASC", "users.banned ASC, users.id DESC", true},
+ {"created_at", "", "users.created_at DESC, users.id DESC", false},
+ {"online", "asc", "users.last_seen_at ASC NULLS LAST", true},
+ {"last_login", "desc", "FROM login_logs", false},
+ {"role", "asc", "WHEN 'owner' THEN 100", true},
+ {"counts", "desc", "posts.status = '" + model.ContentStatusPublished + "'", false},
+ }
+ for _, tc := range cases {
+ got := adminUserOrderSQL(tc.sort, tc.dir)
+ if !strings.Contains(got, tc.wantSub) {
+ t.Fatalf("sort=%s dir=%s 应包含 %q,得到 %q", tc.sort, tc.dir, tc.wantSub, got)
+ }
+ if strings.ContainsAny(tc.sort+tc.dir, ";") {
+ t.Fatalf("用例本身不应含注入字符")
+ }
+ if tc.asc && !strings.Contains(got, "ASC") {
+ t.Fatalf("sort=%s 应为 ASC:%q", tc.sort, got)
+ }
+ }
+}
diff --git a/backend/service/auth.go b/backend/service/auth.go
index 90b72df..2d3695b 100644
--- a/backend/service/auth.go
+++ b/backend/service/auth.go
@@ -98,12 +98,21 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
return nil, err
}
+ var n int64
+ if err := s.db.Model(&model.User{}).Count(&n).Error; err != nil {
+ return nil, err
+ }
+ role := model.RoleUser
+ if n == 0 {
+ role = model.RoleOwner
+ }
+
user := &model.User{
Username: username,
Email: email,
Password: string(hashed),
Nickname: username,
- Role: model.RoleUser,
+ Role: role,
}
if err := s.db.Create(user).Error; err != nil {
return nil, err
diff --git a/backend/service/bg.go b/backend/service/bg.go
new file mode 100644
index 0000000..0909bcd
--- /dev/null
+++ b/backend/service/bg.go
@@ -0,0 +1,167 @@
+package service
+
+import (
+ "errors"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// 站点背景图(前台 / 后台分轨)
+const (
+ SettingKeyBgSiteURL = "bg_site_url"
+ SettingKeyBgSiteMode = "bg_site_mode"
+ SettingKeyBgAdminURL = "bg_admin_url"
+ SettingKeyBgAdminMode = "bg_admin_mode"
+
+ BgSurfaceSite = "site"
+ BgSurfaceAdmin = "admin"
+
+ BgModeCover = "cover"
+ BgModeContain = "contain"
+ BgModeRepeat = "repeat"
+ BgModeRepeatX = "repeat-x"
+ BgModeRepeatY = "repeat-y"
+ BgModeStretch = "stretch"
+ BgModeCenter = "center"
+ DefaultBgMode = BgModeCover
+)
+
+// ErrBgFileMissing 设置了合法 URL 但磁盘上没有对应文件
+var ErrBgFileMissing = errors.New("背景图文件不存在")
+
+// 仅接受本站上传目录下的 32 位 hex 文件名,禁止外链与路径穿越(写入 CSS url())
+var bgUploadURLRe = regexp.MustCompile(`^/uploads/backgrounds/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$`)
+
+var bgModes = map[string]struct{}{
+ BgModeCover: {},
+ BgModeContain: {},
+ BgModeRepeat: {},
+ BgModeRepeatX: {},
+ BgModeRepeatY: {},
+ BgModeStretch: {},
+ BgModeCenter: {},
+}
+
+// NormalizeBgMode 校验铺放模式;空串视为默认 cover
+func NormalizeBgMode(raw string) (string, bool) {
+ m := strings.ToLower(strings.TrimSpace(raw))
+ if m == "" {
+ return DefaultBgMode, true
+ }
+ if _, ok := bgModes[m]; !ok {
+ return "", false
+ }
+ return m, true
+}
+
+// NormalizeBgURL 校验背景图 URL;空串表示清除(走默认画布)
+func NormalizeBgURL(raw string) (string, bool) {
+ u := strings.TrimSpace(raw)
+ if u == "" {
+ return "", true
+ }
+ u = strings.ToLower(u)
+ if !bgUploadURLRe.MatchString(u) {
+ return "", false
+ }
+ return u, true
+}
+
+// NormalizeBgSurface 前台 site / 后台 admin
+func NormalizeBgSurface(raw string) (string, bool) {
+ s := strings.ToLower(strings.TrimSpace(raw))
+ if s == BgSurfaceSite || s == BgSurfaceAdmin {
+ return s, true
+ }
+ return "", false
+}
+
+func (s *SettingService) BgSiteURL() (string, error) {
+ return s.bgURL(SettingKeyBgSiteURL)
+}
+
+func (s *SettingService) BgAdminURL() (string, error) {
+ return s.bgURL(SettingKeyBgAdminURL)
+}
+
+func (s *SettingService) BgSiteMode() (string, error) {
+ return s.bgMode(SettingKeyBgSiteMode)
+}
+
+func (s *SettingService) BgAdminMode() (string, error) {
+ return s.bgMode(SettingKeyBgAdminMode)
+}
+
+func (s *SettingService) bgURL(key string) (string, error) {
+ v, found, err := s.getValue(key)
+ if err != nil || !found {
+ return "", err
+ }
+ u, ok := NormalizeBgURL(v)
+ if !ok {
+ return "", nil
+ }
+ return u, nil
+}
+
+func (s *SettingService) bgMode(key string) (string, error) {
+ v, found, err := s.getValue(key)
+ if err != nil || !found {
+ return DefaultBgMode, err
+ }
+ m, ok := NormalizeBgMode(v)
+ if !ok {
+ return DefaultBgMode, nil
+ }
+ return m, nil
+}
+
+// SetBgSiteURL 保存前台背景;空串删除键
+func (s *SettingService) SetBgSiteURL(url string) error {
+ return s.setBgURL(SettingKeyBgSiteURL, url)
+}
+
+// SetBgAdminURL 保存后台背景;空串删除键
+func (s *SettingService) SetBgAdminURL(url string) error {
+ return s.setBgURL(SettingKeyBgAdminURL, url)
+}
+
+func (s *SettingService) setBgURL(key, url string) error {
+ u, ok := NormalizeBgURL(url)
+ if !ok {
+ return ErrInvalidSiteSetting
+ }
+ if u == "" {
+ return s.deleteKey(key)
+ }
+ return s.putValue(key, u)
+}
+
+func (s *SettingService) SetBgSiteMode(mode string) error {
+ return s.setBgMode(SettingKeyBgSiteMode, mode)
+}
+
+func (s *SettingService) SetBgAdminMode(mode string) error {
+ return s.setBgMode(SettingKeyBgAdminMode, mode)
+}
+
+func (s *SettingService) setBgMode(key, mode string) error {
+ m, ok := NormalizeBgMode(mode)
+ if !ok {
+ return ErrInvalidSiteSetting
+ }
+ if m == DefaultBgMode {
+ return s.deleteKey(key)
+ }
+ return s.putValue(key, m)
+}
+
+// BgRelPath 把合法 URL 转成 uploads 根下的相对路径;非法返回空
+func BgRelPath(url string) string {
+ u, ok := NormalizeBgURL(url)
+ if !ok || u == "" {
+ return ""
+ }
+ return filepath.FromSlash(strings.TrimPrefix(u, "/uploads/"))
+}
diff --git a/backend/service/bg_test.go b/backend/service/bg_test.go
new file mode 100644
index 0000000..6d8dd54
--- /dev/null
+++ b/backend/service/bg_test.go
@@ -0,0 +1,56 @@
+package service
+
+import "testing"
+
+func TestNormalizeBgURL(t *testing.T) {
+ okEmpty, ok := NormalizeBgURL(" ")
+ if !ok || okEmpty != "" {
+ t.Fatalf("empty should clear, got %q ok=%v", okEmpty, ok)
+ }
+ good := "/uploads/backgrounds/0123456789abcdef0123456789abcdef.webp"
+ got, ok := NormalizeBgURL(good)
+ if !ok || got != good {
+ t.Fatalf("want %s, got %q ok=%v", good, got, ok)
+ }
+ rejects := []string{
+ "http://evil.test/x.webp",
+ "/uploads/backgrounds/../avatars/x.webp",
+ "/uploads/images/0123456789abcdef0123456789abcdef.webp",
+ "/uploads/backgrounds/0123456789abcdef0123456789abcdef.gif",
+ `/uploads/backgrounds/0123456789abcdef0123456789abcdef.webp")`,
+ "/uploads/backgrounds/short.webp",
+ "javascript:alert(1)",
+ }
+ for _, u := range rejects {
+ if _, ok := NormalizeBgURL(u); ok {
+ t.Fatalf("should reject %q", u)
+ }
+ }
+}
+
+func TestNormalizeBgMode(t *testing.T) {
+ m, ok := NormalizeBgMode("")
+ if !ok || m != DefaultBgMode {
+ t.Fatalf("empty mode want cover, got %q ok=%v", m, ok)
+ }
+ if _, ok := NormalizeBgMode("zoom"); ok {
+ t.Fatal("unknown mode should fail")
+ }
+ for _, want := range []string{
+ BgModeCover, BgModeContain, BgModeRepeat, BgModeRepeatX, BgModeRepeatY, BgModeStretch, BgModeCenter,
+ } {
+ got, ok := NormalizeBgMode(" " + want + " ")
+ if !ok || got != want {
+ t.Fatalf("mode %s: got %q ok=%v", want, got, ok)
+ }
+ }
+}
+
+func TestNormalizeBgSurface(t *testing.T) {
+ if s, ok := NormalizeBgSurface("SITE"); !ok || s != BgSurfaceSite {
+ t.Fatalf("site: %q %v", s, ok)
+ }
+ if _, ok := NormalizeBgSurface("frontend"); ok {
+ t.Fatal("frontend is not a surface")
+ }
+}
diff --git a/backend/service/custom_code_settings_test.go b/backend/service/custom_code_settings_test.go
deleted file mode 100644
index 2b42991..0000000
--- a/backend/service/custom_code_settings_test.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package service
-
-import "testing"
-
-func TestSanitizeCustomSnippet(t *testing.T) {
- got, err := sanitizeCustomSnippet(" body{color:red} ", "b", "b", " maxRunes {
- return "", ErrInvalidSiteSetting
- }
- if closer != "" && strings.Contains(strings.ToLower(s), closer) {
- return "", ErrInvalidSiteSetting
- }
- return s, nil
-}
-
-// CustomCSS 全站自定义 CSS。缺行/空=无。
-func (s *SettingService) CustomCSS() (string, error) {
- v, found, err := s.getValue(SettingKeyCustomCSS)
- if err != nil || !found {
- return "", err
- }
- out, nerr := sanitizeCustomSnippet(v, " BackgroundMaxBytes {
+ _ = os.Remove(tmp)
+ return "", tooLarge()
+ }
+
+ rf, err := os.Open(tmp)
+ if err != nil {
+ _ = os.Remove(tmp)
+ return "", err
+ }
+ w, h, err := decodeImageSizeReader(rf, format.mime)
+ _ = rf.Close()
+ if err != nil {
+ _ = os.Remove(tmp)
+ return "", err
+ }
+ if w < 1 || h < 1 {
+ _ = os.Remove(tmp)
+ return "", errors.New("无效的图片尺寸")
+ }
+ if w > BackgroundMaxDim || h > BackgroundMaxDim {
+ _ = os.Remove(tmp)
+ return "", errors.New("背景图边长不能超过 8192px")
+ }
+ if err := os.Rename(tmp, fullPath); err != nil {
+ _ = os.Remove(tmp)
+ return "", err
+ }
+ return "/uploads/backgrounds/" + filename, nil
+}
+
+// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
+func (s *UploadService) BackgroundFileExists(url string) bool {
+ rel := BgRelPath(url)
+ if rel == "" {
+ return false
+ }
+ abs := filepath.Join(s.dir, rel)
+ info, err := os.Stat(abs)
+ return err == nil && !info.IsDir()
+}
+
+// RemoveBackgroundIfUnused 旧墙纸不再被前台或后台引用时删除物理文件
+func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL string) {
+ if oldURL == "" || oldURL == siteURL || oldURL == adminURL {
+ return
+ }
+ rel := BgRelPath(oldURL)
+ if rel == "" {
+ return
+ }
+ abs := filepath.Join(s.dir, rel)
+ if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
+ log.Printf("[upload] 删除背景图失败 path=%s: %v", abs, err)
+ }
+}
+
// UseAvatar 选用一张【本人历史上传】的头像
func (s *UploadService) UseAvatar(userID uint, url string) error {
url = strings.TrimSpace(url)
diff --git a/backend/version/version.go b/backend/version/version.go
new file mode 100644
index 0000000..80d1f45
--- /dev/null
+++ b/backend/version/version.go
@@ -0,0 +1,9 @@
+// Package version 由构建注入发布号;本地 go run 为 dev。
+package version
+
+var (
+ // Version 与仓库根 VERSION 对齐,Docker / ldflags 写入。
+ Version = "dev"
+ // Commit 短 sha,未知则为 unknown。
+ Commit = "unknown"
+)
diff --git a/deploy/.env.example b/deploy/.env.example
new file mode 100644
index 0000000..2475af4
--- /dev/null
+++ b/deploy/.env.example
@@ -0,0 +1,25 @@
+# 官方运行时环境变量。复制为 .env 后填写,不要提交 .env。
+# 契约见 CONTRACT.md。反代由宿主机 Nginx 等承担,见 nginx.example.conf。
+
+JIANG13_VERSION=0.1.0
+
+# 镜像名。未推仓库时 docker compose up --build 会打到这些本地 tag。
+# 发布后改为 registry.example.com/jiang13-bbs-api 等即可 docker compose pull。
+JIANG13_IMAGE_API=jiang13-bbs-api
+JIANG13_IMAGE_WEB=jiang13-bbs-web
+
+# 对外 origin,须与浏览器地址栏一致(含 https、无尾斜杠)。CORS 兜底。
+SITE_URL=https://bbs.example.com
+
+# 发布到宿主机的地址/端口,供本机 Nginx 反代。默认只绑 loopback。
+JIANG13_BIND=127.0.0.1
+JIANG13_WEB_PORT=3000
+JIANG13_API_PORT=3001
+
+# Postgres(官方 Compose 不把 5432 打到宿主机)
+POSTGRES_USER=jiang13
+POSTGRES_PASSWORD=change-me
+POSTGRES_DB=jiang13
+
+# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。
+JWT_SECRET=change-me-to-a-long-random-string
diff --git a/deploy/CONTRACT.md b/deploy/CONTRACT.md
new file mode 100644
index 0000000..8af1743
--- /dev/null
+++ b/deploy/CONTRACT.md
@@ -0,0 +1,90 @@
+# 官方运行时契约
+
+变更本文件中的服务名、端口、volume、环境变量,视为**运行时大版本**,须在 changelog 写明,并给出最低可升级的应用版本。不要为了省事改本地开发的 `start.bat` / 仓库根 `docker-compose.yml`。
+
+当前应用版本见仓库根 `VERSION`(现 `0.1.0`)。最低可升级版本:无(首个官方运行时)。
+
+## 与本地开发的边界
+
+| | 本地开发 | 官方运行时 |
+|--|----------|------------|
+| Compose | 仓库根 `docker-compose.yml`,只起 Postgres,映射 `5432` | `deploy/docker-compose.yml`,项目名 `jiang13` |
+| 后端 | `cd backend && go run ./cmd/jiang13`,数据 `backend/data/` | 容器 `api`,`JIANG13_WORK_PATH=/var/lib/jiang13` |
+| 前端 | `cd frontend && npm run dev`,`:3000` | 容器 `web`,Next standalone `:3000` |
+| 入口 | 浏览器 `http://localhost:3000` | 宿主机反代(Nginx 等)同源反代 `127.0.0.1:3000` + `127.0.0.1:3001` |
+
+两套 Postgres volume 名字空间不同,可以并存,互不覆盖。官方 Compose **不包含**反代容器。
+
+## 服务名(Compose)
+
+- `postgres` — PostgreSQL 17(不映射宿主机 5432)
+- `api` — Go,容器内 `3001`,默认发布 `127.0.0.1:3001`
+- `web` — Next.js standalone,容器内 `3000`,默认发布 `127.0.0.1:3000`
+
+禁止把 API 单独做到另一个子域。`/api` 与 `/api/ws` 必须由反代直达 `api`,不能进 Next(Upgrade 无法经 Next rewrite)。
+
+## Volume
+
+| 名 | 容器路径 | 内容 |
+|----|----------|------|
+| `pgdata` | `/var/lib/postgresql/data` | 数据库 |
+| `appdata` | `/var/lib/jiang13/data` | 上传、`private/` 附件、`.jwt_secret` |
+
+镜像内不放真实 `app.ini`、不放用户数据。
+
+## 环境变量
+
+### api
+
+| 变量 | 必需 | 说明 |
+|------|------|------|
+| `HTTP_PORT` | 否 | 默认 `3001` |
+| `DEV_MODE` | 是(Compose 写死 `false`) | 生产 cookie 走 `__Host-`,须反代 HTTPS |
+| `JIANG13_WORK_PATH` | 是 | 固定 `/var/lib/jiang13` |
+| `DATA_DIR` | 是 | 固定 `/var/lib/jiang13/data` |
+| `DB_DSN` | 是 | 指向服务名 `postgres` |
+| `JWT_SECRET` | 是 | 强随机;勿依赖自动生成做多副本 |
+| `SITE_URL` | 建议 | 对外 origin,无尾斜杠,如 `https://bbs.example.com` |
+| `CORS_ORIGINS` | 否 | 额外 origin,逗号分隔 |
+
+### web
+
+| 变量 | 必需 | 说明 |
+|------|------|------|
+| `BACKEND_URL` | 是 | 固定 `http://api:3001`(SSR / middleware,勿写成公网 URL) |
+| `PORT` / `HOSTNAME` | 否 | `3000` / `0.0.0.0` |
+| `NEXT_PUBLIC_APP_VERSION` | 构建期 | 与 `VERSION` 相同;**不要**设置 `NEXT_PUBLIC_API_URL=http://api:3001`(会泄漏到浏览器) |
+
+浏览器 API 走同源 `/api/*`(由宿主机反代直达 Go)。WebSocket 走同源 `/api/ws`,不要设 `NEXT_PUBLIC_WS_URL`。
+
+### 宿主机发布
+
+| 变量 | 默认 | 说明 |
+|------|------|------|
+| `JIANG13_BIND` | `127.0.0.1` | 只给本机反代;需要对外直连再改 |
+| `JIANG13_WEB_PORT` | `3000` | web 宿主机端口 |
+| `JIANG13_API_PORT` | `3001` | api 宿主机端口 |
+
+### 镜像 tag
+
+`JIANG13_VERSION` 同时作为 `api`/`web` 镜像 tag。`JIANG13_IMAGE_API` / `JIANG13_IMAGE_WEB` 默认为本地名;推仓库后改成 registry 路径即可 `docker compose pull`。
+
+`POSTGRES_PASSWORD` 会拼进 `DB_DSN`,不要包含 `@ : / # ?` 等 URL 保留字符。
+
+## 健康检查
+
+- `api`:`GET http://127.0.0.1:3001/health`(含 DB ping、`version`)
+- `web`:`GET http://127.0.0.1:3000/healthz`(不访问后端)
+
+## 更新
+
+应用升级:换同一契约下的新镜像 tag,然后:
+
+```
+docker compose pull
+docker compose up -d
+```
+
+新 `api` 启动时执行既有 `AutoMigrate`。接受短暂停机。上线前不为双版本并存写兼容垫片。
+
+改 Compose / 环境变量 / volume 路径属于运行时升级,不能只 pull。
diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api
new file mode 100644
index 0000000..b144a31
--- /dev/null
+++ b/deploy/Dockerfile.api
@@ -0,0 +1,32 @@
+# 构建上下文:仓库根。不含源码与工具链。
+# syntax=docker/dockerfile:1
+
+FROM golang:1.27-bookworm AS build
+WORKDIR /src
+ENV GOTOOLCHAIN=local
+COPY backend/go.mod backend/go.sum ./
+RUN go mod download
+COPY backend/ ./
+ARG VERSION=dev
+ARG COMMIT=unknown
+RUN CGO_ENABLED=0 GOOS=linux go build \
+ -trimpath \
+ -ldflags="-s -w -X github.com/freefire/jiang13-bbs/version.Version=${VERSION} -X github.com/freefire/jiang13-bbs/version.Commit=${COMMIT}" \
+ -o /out/jiang13 ./cmd/jiang13
+
+FROM alpine:3.21
+RUN apk add --no-cache ca-certificates tzdata wget su-exec \
+ && adduser -D -H -u 1000 jiang13 \
+ && mkdir -p /var/lib/jiang13/data
+COPY --from=build /out/jiang13 /usr/local/bin/jiang13
+COPY deploy/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh
+RUN sed -i 's/\r$//' /usr/local/bin/api-entrypoint.sh && chmod +x /usr/local/bin/api-entrypoint.sh
+ENV JIANG13_WORK_PATH=/var/lib/jiang13 \
+ HTTP_PORT=3001 \
+ DEV_MODE=false \
+ TZ=UTC
+WORKDIR /var/lib/jiang13
+EXPOSE 3001
+HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
+ CMD wget -qO- http://127.0.0.1:3001/health || exit 1
+ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"]
diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web
new file mode 100644
index 0000000..4cacc42
--- /dev/null
+++ b/deploy/Dockerfile.web
@@ -0,0 +1,37 @@
+# 构建上下文:frontend/。standalone 产物,运行时不含完整 node_modules。
+# syntax=docker/dockerfile:1
+
+FROM node:22-bookworm-slim AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci --no-audit --no-fund
+
+FROM node:22-bookworm-slim AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN mkdir -p public
+ARG VERSION=dev
+ARG BACKEND_URL=http://api:3001
+ENV NEXT_TELEMETRY_DISABLED=1 \
+ NODE_ENV=production \
+ NEXT_PUBLIC_APP_VERSION=${VERSION} \
+ BACKEND_URL=${BACKEND_URL}
+RUN npm run build
+
+FROM node:22-bookworm-slim AS runner
+WORKDIR /app
+ENV NODE_ENV=production \
+ PORT=3000 \
+ HOSTNAME=0.0.0.0 \
+ NEXT_TELEMETRY_DISABLED=1 \
+ BACKEND_URL=http://api:3001
+RUN mkdir -p public .next && chown -R node:node /app
+COPY --from=builder --chown=node:node /app/public ./public
+COPY --from=builder --chown=node:node /app/.next/standalone ./
+COPY --from=builder --chown=node:node /app/.next/static ./.next/static
+USER node
+EXPOSE 3000
+HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
+ CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+CMD ["node", "server.js"]
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..a336cc0
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,74 @@
+# 官方运行时(Docker Compose)
+
+一体交付:Next.js SSR + Go API + PostgreSQL 17。 **不含反代**:TLS 与同源路由由你现有的 Nginx / Caddy 处理,示例见 [nginx.example.conf](./nginx.example.conf)。契约见 [CONTRACT.md](./CONTRACT.md)。
+
+本地开发**不要**用这份 Compose。请继续用仓库根 `start.bat` / `start.sh`(只起 Postgres,前后端热重载)。
+
+## 首次安装
+
+1. 安装 Docker Compose v2。
+2. 复制环境文件并改密码:
+
+```bash
+cd deploy
+cp .env.example .env
+# 编辑 .env:POSTGRES_PASSWORD、JWT_SECRET、SITE_URL
+```
+
+`SITE_URL` 填浏览器看到的 HTTPS origin(如 `https://bbs.example.com`)。`DEV_MODE=false` 会启用 `__Host-` cookie,**必须由你的反代提供 HTTPS**。
+
+3. 启动(尚未推镜像仓库时从源码构建):
+
+```bash
+docker compose up -d --build
+```
+
+默认只把 `127.0.0.1:3000`(web)和 `127.0.0.1:3001`(api)交给本机反代。与本地 `start.bat` 的 3000/3001 冲突时,先停开发进程,或改 `.env` 里的 `JIANG13_WEB_PORT` / `JIANG13_API_PORT`。
+
+4. 按 [nginx.example.conf](./nginx.example.conf) 把同一 Host 指过来:
+
+- `/` → web `:3000`
+- `/api/`(含 `/api/ws`)、`/uploads/`、`/health` → api `:3001`
+
+`GET https://你的域名/health` 应返回 `"status":"ok"` 和 `version`。空库首次注册的账号自动成为站长。不要把 API 放到另一个子域。
+
+数据在 Docker volume `appdata` / `pgdata`。不要把 `backend/data` 或真实 `app.ini` 打进镜像。
+
+## 更新应用(官方路径)
+
+镜像已推到仓库、且 `.env` 里 `JIANG13_IMAGE_*` 指向该仓库时:
+
+```bash
+cd deploy
+docker compose pull
+docker compose up -d
+```
+
+只换应用 tag,不改本目录 Compose / 环境变量表。启动时自动迁移。建议先备份:
+
+```bash
+docker compose exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
+```
+
+`appdata` 用 `docker volume` 备份。失败则把 `JIANG13_VERSION` 改回上一版再 `up -d`。
+
+尚未发布镜像时,在更新源码后执行 `docker compose up -d --build`。
+
+Compose 文件本身有变更时,按新版本 changelog **合并** `deploy/docker-compose.yml` 与 `.env`,不能只 pull。
+
+## 停止
+
+```bash
+cd deploy
+docker compose down
+```
+
+加 `-v` 会删数据库和上传,仅在你确认要清空时使用。不要对仓库根的开发 Postgres 执行这条。
+
+## 不做本期
+
+- 后台一键更新
+- 预编译包 / watchdog
+- 把 Docker socket 交给 Web 进程
+- 在 Compose 里内置 Nginx / Caddy
+- Cloudflare Workers 作为官方运行时
diff --git a/deploy/api-entrypoint.sh b/deploy/api-entrypoint.sh
new file mode 100644
index 0000000..b2580e6
--- /dev/null
+++ b/deploy/api-entrypoint.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+# 以 root 校正 volume 属主后降权运行。数据目录契约:/var/lib/jiang13/data
+set -e
+mkdir -p /var/lib/jiang13/data/uploads /var/lib/jiang13/data/private
+chown -R jiang13:jiang13 /var/lib/jiang13
+exec su-exec jiang13 /usr/local/bin/jiang13
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..a54c5c8
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,72 @@
+# 官方运行时契约(变更视为运行时大版本,须写 changelog)
+#
+# 本地开发不使用本文件:仓库根 docker-compose.yml 只起 Postgres,
+# 配合 start.bat / cd backend && go run ./cmd/jiang13 + frontend npm run dev。
+# 本 Compose 不含反代:页面 :3000、API :3001 交给宿主机 Nginx / Caddy。
+
+name: jiang13
+
+services:
+ postgres:
+ image: postgres:17-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-jiang13}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
+ POSTGRES_DB: ${POSTGRES_DB:-jiang13}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-jiang13} -d ${POSTGRES_DB:-jiang13}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ api:
+ image: ${JIANG13_IMAGE_API:-jiang13-bbs-api}:${JIANG13_VERSION:-0.1.0}
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile.api
+ args:
+ VERSION: ${JIANG13_VERSION:-0.1.0}
+ COMMIT: ${JIANG13_COMMIT:-unknown}
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ HTTP_PORT: "3001"
+ DEV_MODE: "false"
+ JIANG13_WORK_PATH: /var/lib/jiang13
+ DATA_DIR: /var/lib/jiang13/data
+ DB_DSN: postgres://${POSTGRES_USER:-jiang13}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-jiang13}?sslmode=disable
+ JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in deploy/.env}
+ SITE_URL: ${SITE_URL:-https://localhost}
+ volumes:
+ - appdata:/var/lib/jiang13/data
+ ports:
+ - "${JIANG13_BIND:-127.0.0.1}:${JIANG13_API_PORT:-3001}:3001"
+
+ web:
+ image: ${JIANG13_IMAGE_WEB:-jiang13-bbs-web}:${JIANG13_VERSION:-0.1.0}
+ build:
+ context: ../frontend
+ dockerfile: ../deploy/Dockerfile.web
+ args:
+ VERSION: ${JIANG13_VERSION:-0.1.0}
+ BACKEND_URL: http://api:3001
+ restart: unless-stopped
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ BACKEND_URL: http://api:3001
+ NODE_ENV: production
+ PORT: "3000"
+ HOSTNAME: 0.0.0.0
+ ports:
+ - "${JIANG13_BIND:-127.0.0.1}:${JIANG13_WEB_PORT:-3000}:3000"
+
+volumes:
+ pgdata:
+ appdata:
diff --git a/deploy/nginx.example.conf b/deploy/nginx.example.conf
new file mode 100644
index 0000000..00d8a17
--- /dev/null
+++ b/deploy/nginx.example.conf
@@ -0,0 +1,49 @@
+# 宿主机 Nginx 示例(官方 Compose 不含反代)。
+# 页面、/api、/api/ws、/uploads 必须同一 Host;/api/ws 直达 Go,不要进 Next。
+# 把 bbs.example.com 与证书路径换成实际值,upstream 端口与 .env 里 JIANG13_*_PORT 一致。
+
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name bbs.example.com;
+
+ # ssl_certificate /path/to/fullchain.pem;
+ # ssl_certificate_key /path/to/privkey.pem;
+
+ client_max_body_size 512m;
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:3001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_read_timeout 3600s;
+ }
+
+ location /uploads/ {
+ proxy_pass http://127.0.0.1:3001;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ location /health {
+ proxy_pass http://127.0.0.1:3001;
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..846075c
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+.next
+.env
+.env.local
+.env.*.local
+*.md
+coverage
diff --git a/frontend/.env.example b/frontend/.env.example
index d5d6695..febaadc 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -9,3 +9,6 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址,
# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。
# BACKEND_URL=https://your-backend.example.com
+
+# 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。
+# NEXT_PUBLIC_APP_VERSION=0.1.0
diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx
index 07d5907..aa67173 100644
--- a/frontend/app/about/page.tsx
+++ b/frontend/app/about/page.tsx
@@ -96,61 +96,63 @@ export default async function AboutIndexPage() {
- }
mobileExtras={
announcements.length > 0 ? : null
}
>
-
+
+
+
+
+
+
+
+ 关于本站
+
+
+ {error
+ ? "加载失败"
+ : pages.length > 0
+ ? `共 ${pages.length} 篇站点文档`
+ : "站点说明与政策文档"}
+
+
+ {canManagePages ? (
+
+ 管理
+
+ ) : null}
+
+
- {error ? (
-
{error}
- ) : pages.length === 0 ? (
-
-
- 暂无介绍
-
-
有新文档时会显示在这里
-
- ) : (
-
- {pages.map((p, i) => (
-
0} />
- ))}
-
- )}
+ {error ? (
+
{error}
+ ) : pages.length === 0 ? (
+
+
+ 暂无介绍
+
+
有新文档时会显示在这里
+
+ ) : (
+ pages.map((p, i) =>
0} />)
+ )}
+
);
}
diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx
index d7c0fe7..6724091 100644
--- a/frontend/app/admin/appearance/AppearanceAdmin.tsx
+++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx
@@ -12,7 +12,11 @@ import {
RotateCcw,
Pipette,
Sparkles,
- FileCode2,
+ ImageIcon,
+ Monitor,
+ LayoutDashboard,
+ Trash2,
+ Upload,
} from "lucide-react";
import {
AdminPageHeader,
@@ -20,8 +24,13 @@ import {
AdminSettingRow,
AdminStepper,
AdminSwitch,
+ AdminSegmented,
} from "@/components/admin";
-import { apiUpdateSiteSettings, type PublicSettings, type UpdateSiteSettingsBody } from "@/lib/api";
+import {
+ apiUpdateSiteSettings,
+ apiUploadSiteBackground,
+ type PublicSettings,
+} from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
@@ -32,26 +41,23 @@ import {
normalizeHex,
} from "@/lib/theme";
import {
- MAX_CUSTOM_CSS_CHARS,
- MAX_CUSTOM_JS_CHARS,
- SAMPLE_CUSTOM_CSS,
- SAMPLE_CUSTOM_JS,
- applyCustomCSS,
- clipCustomSnippet,
-} from "@/lib/customCode";
+ BG_MODE_OPTIONS,
+ applySiteBackground,
+ bgConfigFromSettings,
+ normalizeBgMode,
+ previewBoxStyle,
+ type BgMode,
+ type BgSurface,
+ type SiteBgConfig,
+} from "@/lib/bg";
-// 后台「外观设置」:主题色、自定义 CSS/JS、代码块折叠与界面动画。
-// 主题色 / 自定义 CSS 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
-// 自定义 JS 不在编辑时执行,保存后整页加载生效。
+// 后台「外观设置」:主题色、代码块折叠与界面动画。
+// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
- const [savedCustomCSS, setSavedCustomCSS] = useState(initial.custom_css || "");
- const [customCSS, setCustomCSS] = useState(initial.custom_css || SAMPLE_CUSTOM_CSS);
- const [savedCustomJS, setSavedCustomJS] = useState(initial.custom_js || "");
- const [customJS, setCustomJS] = useState(initial.custom_js || SAMPLE_CUSTOM_JS);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
const [foldLines, setFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
@@ -66,10 +72,12 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
const [togglingMotion, setTogglingMotion] = useState(false);
const [saving, setSaving] = useState(false);
+ const [bg, setBg] = useState(() => bgConfigFromSettings(initial));
+ const [bgBusy, setBgBusy] = useState(null);
+ const siteFileRef = useRef(null);
+ const adminFileRef = useRef(null);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
- const savedCSSRef = useRef(savedCustomCSS);
- savedCSSRef.current = savedCustomCSS;
const normalized = useMemo(() => normalizeHex(draft), [draft]);
const pair = useMemo(
@@ -78,12 +86,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
- const accentDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
- const cssIsGuide = !savedCustomCSS && customCSS === SAMPLE_CUSTOM_CSS;
- const jsIsGuide = !savedCustomJS && customJS === SAMPLE_CUSTOM_JS;
- const cssDirty = customCSS !== savedCustomCSS && !cssIsGuide;
- const jsDirty = customJS !== savedCustomJS && !jsIsGuide;
- const dirty = accentDirty || cssDirty || jsDirty;
+ const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
useEffect(() => {
@@ -93,35 +96,19 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
};
}, [normalized]);
- useEffect(() => {
- applyCustomCSS(customCSS, true);
- return () => {
- applyCustomCSS(savedCSSRef.current, false);
- };
- }, [customCSS]);
-
const handleSave = async () => {
if (!normalized || saving) return;
- const body: UpdateSiteSettingsBody = {};
- if (accentDirty) body.accent = submitHex;
- if (cssDirty) body.custom_css = customCSS;
- if (jsDirty) body.custom_js = customJS;
- if (Object.keys(body).length === 0) return;
setSaving(true);
try {
- const res = await apiUpdateSiteSettings(body);
+ const res = await apiUpdateSiteSettings({ accent: submitHex });
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
- setSavedCustomCSS(res.custom_css || "");
- setSavedCustomJS(res.custom_js || "");
applyAccentTheme(next || null);
- applyCustomCSS(res.custom_css || "", false);
router.refresh();
- toast(jsDirty ? "外观已保存;自定义 JS 将在整页刷新后执行" : "外观已保存并对全站生效");
+ toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
applyAccentTheme(savedRef.current || null);
- applyCustomCSS(savedCSSRef.current, false);
} finally {
setSaving(false);
}
@@ -129,10 +116,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
const discard = () => {
setDraft(savedHex || DEFAULT_ACCENT);
- setCustomCSS(savedCustomCSS || SAMPLE_CUSTOM_CSS);
- setCustomJS(savedCustomJS || SAMPLE_CUSTOM_JS);
applyAccentTheme(savedHex || null);
- applyCustomCSS(savedCustomCSS, true);
};
const toggleCodeFold = async () => {
@@ -202,12 +186,180 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
}
};
+ const commitBg = (next: SiteBgConfig) => {
+ setBg(next);
+ applySiteBackground(next);
+ };
+
+ const saveBgPatch = async (patch: Partial, toastOk: string) => {
+ const res = await apiUpdateSiteSettings(patch);
+ commitBg(bgConfigFromSettings(res));
+ toast(toastOk, "ok");
+ };
+
+ const onPickBg = async (surface: BgSurface, file: File | undefined) => {
+ if (!file || bgBusy) return;
+ if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) {
+ toast("仅支持 JPEG / PNG / WebP", "error");
+ return;
+ }
+ if (file.size > 8 * 1024 * 1024) {
+ toast("背景图不能超过 8MB", "error");
+ return;
+ }
+ setBgBusy(surface);
+ try {
+ const pendingMode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode;
+ let res = await apiUploadSiteBackground(surface, file);
+ let next = bgConfigFromSettings(res);
+ const savedMode = surface === "site" ? next.bg_site_mode : next.bg_admin_mode;
+ if (pendingMode !== savedMode) {
+ const modeKey = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
+ res = await apiUpdateSiteSettings({ [modeKey]: pendingMode });
+ next = bgConfigFromSettings(res);
+ }
+ commitBg(next);
+ toast(surface === "site" ? "前台背景已更新" : "后台背景已更新", "ok");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "上传失败", "error");
+ } finally {
+ setBgBusy(null);
+ }
+ };
+
+ const onChangeBgMode = async (surface: BgSurface, mode: BgMode) => {
+ if (bgBusy) return;
+ const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
+ const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url;
+ if (!url) {
+ commitBg({ ...bg, [key]: mode });
+ return;
+ }
+ setBgBusy(surface);
+ try {
+ await saveBgPatch(
+ { [key]: mode },
+ surface === "site" ? "前台铺放模式已保存" : "后台铺放模式已保存"
+ );
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败", "error");
+ } finally {
+ setBgBusy(null);
+ }
+ };
+
+ const onClearBg = async (surface: BgSurface) => {
+ if (bgBusy) return;
+ const key = surface === "site" ? "bg_site_url" : "bg_admin_url";
+ setBgBusy(surface);
+ try {
+ await saveBgPatch(
+ { [key]: "" },
+ surface === "site" ? "已恢复前台默认画布" : "已恢复后台默认画布"
+ );
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败", "error");
+ } finally {
+ setBgBusy(null);
+ }
+ };
+
+ const renderBgRail = (
+ surface: BgSurface,
+ title: string,
+ desc: string,
+ Icon: typeof Monitor,
+ fileRef: typeof siteFileRef
+ ) => {
+ const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url;
+ const mode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode;
+ const busy = bgBusy === surface;
+ return (
+
+
+
+ {!url && (
+
+ 默认纹饰画布
+
+ )}
+
+
{
+ const f = e.target.files?.[0];
+ e.target.value = "";
+ void onPickBg(surface, f);
+ }}
+ />
+
+
+ {url ? (
+
+ ) : null}
+
+
+
铺放模式
+
void onChangeBgMode(surface, normalizeBgMode(key))}
+ options={BG_MODE_OPTIONS.map((o) => ({
+ key: o.key,
+ label: o.label,
+ disabled: busy,
+ }))}
+ />
+
+ {BG_MODE_OPTIONS.find((o) => o.key === mode)?.hint}
+
+
+
+ );
+ };
+
return (
@@ -370,7 +522,8 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
@@ -516,81 +669,29 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
- 自定义代码
+ 站点背景图
}
- description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 或 。"
- className="mb-6"
+ description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。未配置时使用默认纹饰画布。"
>
-
-
-
-
- {customCSS !== SAMPLE_CUSTOM_CSS && (
-
- )}
-
-
- 覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "}
- {MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。
-
-
-
-
-
- {customJS !== SAMPLE_CUSTOM_JS && (
-
- )}
-
-
- 仅站长/超管可写。不在编辑时运行,保存后需整页刷新。上限{" "}
- {MAX_CUSTOM_JS_CHARS.toLocaleString()} 字。
-
-
+
+ {renderBgRail(
+ "site",
+ "前台",
+ "访客看到的论坛页面,含首页、帖子与个人主页。",
+ Monitor,
+ siteFileRef
+ )}
+ {renderBgRail(
+ "admin",
+ "后台",
+ "管理面板专用。与前台互不影响,可配更克制的图案。",
+ LayoutDashboard,
+ adminFileRef
+ )}
diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx
index b84fc46..6822156 100644
--- a/frontend/app/admin/users/UsersAdmin.tsx
+++ b/frontend/app/admin/users/UsersAdmin.tsx
@@ -4,8 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
+ ArrowUpDown,
+ ChevronDown,
ChevronLeft,
ChevronRight,
+ ChevronsUpDown,
+ ChevronUp,
Clock,
Columns3,
ExternalLink,
@@ -46,12 +50,14 @@ import {
} from "@/components/admin";
export type UserFilter = "all" | "staff" | "banned";
+export type UserSortKey = "id" | "role" | "status" | "points" | "last_login" | "counts" | "created_at" | "online";
+export type UserSortDir = "asc" | "desc";
const PAGE_SIZE = 20;
const COLS_STORAGE_KEY = "j13-admin-user-cols";
/** 默认始终显示的列 */
-const CORE_COLS = ["member", "role", "status", "points", "last_login", "actions"] as const;
+const CORE_COLS = ["id", "member", "role", "status", "points", "last_login", "actions"] as const;
/** 可选列(localStorage 开关) */
const OPTIONAL_COLS = ["online", "email", "counts", "created_at"] as const;
@@ -59,6 +65,9 @@ type CoreCol = (typeof CORE_COLS)[number];
type OptionalCol = (typeof OPTIONAL_COLS)[number];
type ColKey = CoreCol | OptionalCol;
+/** 可服务端排序的列(与后端白名单一致) */
+const SORTABLE_COLS = new Set
(["id", "role", "status", "points", "last_login", "counts", "created_at", "online"]);
+
const OPTIONAL_LABELS: Record = {
online: "在线",
email: "邮箱",
@@ -96,6 +105,17 @@ const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "banned", label: "已封禁" },
];
+const SORT_OPTIONS: { key: UserSortKey; label: string }[] = [
+ { key: "id", label: "用户 ID" },
+ { key: "role", label: "角色" },
+ { key: "status", label: "状态" },
+ { key: "points", label: "积分" },
+ { key: "last_login", label: "上次登录" },
+ { key: "counts", label: "内容" },
+ { key: "created_at", label: "注册时间" },
+ { key: "online", label: "在线" },
+];
+
function filterToParams(filter: UserFilter): { role?: string; status?: string } {
if (filter === "staff") return { role: "staff" };
if (filter === "banned") return { status: "banned" };
@@ -178,7 +198,7 @@ function ColumnConfigPopover({
))}
- 成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
+ 成员 / ID / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
)}
@@ -186,6 +206,89 @@ function ColumnConfigPopover({
);
}
+function MobileSortPopover({
+ sort,
+ order,
+ onChange,
+}: {
+ sort: UserSortKey;
+ order: UserSortDir;
+ onChange: (key: UserSortKey, dir: UserSortDir) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const current = SORT_OPTIONS.find((o) => o.key === sort);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open && (
+
+
+ {SORT_OPTIONS.map((o) => {
+ const active = sort === o.key;
+ return (
+ -
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
export default function UsersAdmin({
initial,
initError,
@@ -195,7 +298,7 @@ export default function UsersAdmin({
}: {
initial: AdminUsersResponse | null;
initError: string;
- initialQuery: { q: string; filter: UserFilter; page: number };
+ initialQuery: { q: string; filter: UserFilter; page: number; sort: UserSortKey; order: UserSortDir };
currentUserId: number;
currentUserRole: string;
}) {
@@ -216,6 +319,8 @@ export default function UsersAdmin({
const [filter, setFilter] = useState(initialQuery.filter);
const [keyword, setKeyword] = useState(initialQuery.q);
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
+ const [sort, setSort] = useState(initialQuery.sort);
+ const [order, setOrder] = useState(initialQuery.order);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState(null);
@@ -270,7 +375,13 @@ export default function UsersAdmin({
return off;
}, []);
- const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
+ const load = useCallback(async (opts: {
+ page: number;
+ filter: UserFilter;
+ q: string;
+ sort: UserSortKey;
+ order: UserSortDir;
+ }) => {
const seq = ++seqRef.current;
setLoading(true);
try {
@@ -278,6 +389,8 @@ export default function UsersAdmin({
page: opts.page,
size: PAGE_SIZE,
q: opts.q || undefined,
+ sort: opts.sort,
+ order: opts.order,
...filterToParams(opts.filter),
});
if (seq !== seqRef.current) return;
@@ -287,6 +400,8 @@ export default function UsersAdmin({
setFilter(opts.filter);
setPage(opts.page);
setAppliedQ(opts.q);
+ setSort(opts.sort);
+ setOrder(opts.order);
setError("");
} catch (e) {
if (seq !== seqRef.current) return;
@@ -301,14 +416,14 @@ export default function UsersAdmin({
debounceRef.current = setTimeout(() => {
const q = keyword.trim();
if (q !== appliedQ) {
- load({ page: 1, filter, q });
+ load({ page: 1, filter, q, sort, order });
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [keyword]);
+ }, [keyword, filter, sort, order]);
useEffect(() => {
if (urlFirstRun.current) {
@@ -319,43 +434,50 @@ export default function UsersAdmin({
if (appliedQ) p.set("q", appliedQ);
if (filter !== "all") p.set("filter", filter);
if (page > 1) p.set("page", String(page));
+ if (sort !== "id") p.set("sort", sort);
+ if (order === "asc") p.set("order", "asc");
const qs = p.toString();
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
- }, [appliedQ, filter, page, router]);
+ }, [appliedQ, filter, page, sort, order, router]);
const submitSearch = () => {
const q = keyword.trim();
if (q === appliedQ) return;
- load({ page: 1, filter, q });
+ load({ page: 1, filter, q, sort, order });
};
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
- load({ page: 1, filter, q: "" });
+ load({ page: 1, filter, q: "", sort, order });
}
};
const changeFilter = (f: UserFilter) => {
if (f === filter) return;
- load({ page: 1, filter: f, q: appliedQ });
+ load({ page: 1, filter: f, q: appliedQ, sort, order });
};
const goPage = (p: number) => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const next = Math.min(Math.max(1, p), totalPages);
if (next === page) return;
- load({ page: next, filter, q: appliedQ });
+ load({ page: next, filter, q: appliedQ, sort, order });
+ };
+
+ const toggleSort = (key: UserSortKey) => {
+ const nextDir: UserSortDir = sort === key && order === "desc" ? "asc" : "desc";
+ load({ page: 1, filter, q: appliedQ, sort: key, order: sort === key ? nextDir : "desc" });
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned;
const hasFilter = appliedQ !== "" || filter !== "all";
- const reload = () => load({ page, filter, q: appliedQ });
+ const reload = () => load({ page, filter, q: appliedQ, sort, order });
const visibleCols = useMemo(() => {
- const list: ColKey[] = ["member", "role", "status", "points"];
+ const list: ColKey[] = ["id", "member", "role", "status", "points"];
if (optCols.online) list.push("online");
if (optCols.email) list.push("email");
list.push("last_login");
@@ -370,11 +492,14 @@ export default function UsersAdmin({
const frags: string[] = [];
for (const c of visibleCols) {
switch (c) {
+ case "id":
+ frags.push("56px");
+ break;
case "member":
- frags.push("minmax(0,1.35fr)");
+ frags.push("minmax(0,0.7fr)");
break;
case "role":
- frags.push("minmax(96px,0.9fr)");
+ frags.push("minmax(92px,0.45fr)");
break;
case "status":
frags.push("72px");
@@ -386,7 +511,7 @@ export default function UsersAdmin({
frags.push("64px");
break;
case "email":
- frags.push("minmax(120px,1fr)");
+ frags.push("minmax(0,0.55fr)");
break;
case "last_login":
frags.push("146px");
@@ -398,7 +523,7 @@ export default function UsersAdmin({
frags.push("100px");
break;
case "actions":
- frags.push("136px");
+ frags.push("minmax(148px,1.8fr)");
break;
}
}
@@ -407,6 +532,8 @@ export default function UsersAdmin({
const colHeader = (c: ColKey): string => {
switch (c) {
+ case "id":
+ return "ID";
case "member":
return "成员";
case "role":
@@ -430,6 +557,37 @@ export default function UsersAdmin({
}
};
+ const renderColHeader = (c: ColKey) => {
+ const label = colHeader(c);
+ if (!SORTABLE_COLS.has(c)) {
+ return (
+ {label}
+ );
+ }
+ const key = c as UserSortKey;
+ const active = sort === key;
+ const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none";
+ const Icon = active ? (order === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
+ return (
+
+
+
+ );
+ };
+
const renderStatus = (u: AdminUser) => {
if (u.banned) {
return (
@@ -519,7 +677,7 @@ export default function UsersAdmin({
if (u.role === ROLES.OWNER) {
return (
-
+
{auditBtn}
@@ -529,7 +687,7 @@ export default function UsersAdmin({
}
if (isSelf) {
return (
-
+
{auditBtn}
+
{auditBtn}
{
switch (c) {
+ case "id":
+ return (
+
+ {u.id}
+
+ );
case "member":
return (
@@ -622,12 +786,12 @@ export default function UsersAdmin({
);
case "actions":
- return
{renderActions(u, isSelf)}
;
+ return
{renderActions(u, isSelf)}
;
}
};
return (
-
+
-
-
-
-
-
-
- role="group"
- ariaLabel="用户筛选"
- value={filter}
- onChange={changeFilter}
- options={FILTER_TABS.map((t) => ({
- key: t.key,
- label: t.label,
- count: tabCount(t.key),
- }))}
+
+
+
+
+
+
+ role="group"
+ ariaLabel="用户筛选"
+ value={filter}
+ onChange={changeFilter}
+ options={FILTER_TABS.map((t) => ({
+ key: t.key,
+ label: t.label,
+ count: tabCount(t.key),
+ }))}
+ />
+ load({ page: 1, filter, q: appliedQ, sort: key, order: dir })}
+ />
+
+
+
-
-
-
-
- {appliedQ ? (
- <>
- 搜索“{appliedQ}”
- >
- ) : filter === "staff" ? (
- "管理团队"
- ) : filter === "banned" ? (
- "已封禁成员"
- ) : (
- "全部成员"
- )}
-
-
- 共 {total} 人
-
-
-
- {users.length > 0 && (
- {visibleCols.map((c) => (
-
- {colHeader(c)}
-
- ))}
+
+ {appliedQ ? (
+ <>
+ 搜索“{appliedQ}”
+ >
+ ) : filter === "staff" ? (
+ "管理团队"
+ ) : filter === "banned" ? (
+ "已封禁成员"
+ ) : (
+ "全部成员"
+ )}
+
+
+ 共 {total} 人
+
- )}
+
+ {users.length > 0 && (
+
+ {visibleCols.map((c) => (
+
+ {renderColHeader(c)}
+
+ ))}
+
+ )}
+
{error ? (
@@ -779,7 +950,7 @@ export default function UsersAdmin({
)}
- @{u.username}
+ #{u.id} · @{u.username}
{optCols.email && u.email ? ` · ${u.email}` : ""}
diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx
index 0b550c0..3733d8b 100644
--- a/frontend/app/admin/users/page.tsx
+++ b/frontend/app/admin/users/page.tsx
@@ -5,14 +5,20 @@ import { fetchAdminUsers, type AdminUsersResponse } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
-import UsersAdmin, { type UserFilter } from "./UsersAdmin";
+import UsersAdmin, { type UserFilter, type UserSortKey } from "./UsersAdmin";
export const metadata: Metadata = {
title: "用户管理",
};
+const SORT_KEYS = ["id", "role", "status", "points", "last_login", "counts", "created_at", "online"] as const;
+
+function parseSort(raw?: string): UserSortKey {
+ return (SORT_KEYS as readonly string[]).includes(raw || "") ? (raw as UserSortKey) : "id";
+}
+
interface UsersPageProps {
- searchParams: Promise<{ q?: string; filter?: string; page?: string }>;
+ searchParams: Promise<{ q?: string; filter?: string; page?: string; sort?: string; order?: string }>;
}
// 筛选项映射为后端查询参数:管理团队走 role=staff,封禁走 status,其余不过滤
@@ -28,6 +34,8 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
const q = sp.q?.trim() || "";
const filter: UserFilter = sp.filter === "staff" || sp.filter === "banned" ? sp.filter : "all";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
+ const sort = parseSort(sp.sort);
+ const order: "asc" | "desc" = sp.order === "asc" ? "asc" : "desc";
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -40,7 +48,7 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
let initial: AdminUsersResponse | null = null;
let initError = "";
try {
- initial = await fetchAdminUsers({ page, q, ...filterToQuery(filter) }, cookie || undefined);
+ initial = await fetchAdminUsers({ page, q, sort, order, ...filterToQuery(filter) }, cookie || undefined);
} catch (e) {
initError = e instanceof Error ? e.message : "获取用户列表失败";
}
@@ -49,7 +57,7 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx
index b838e3b..f9929e9 100644
--- a/frontend/app/announcement/[id]/page.tsx
+++ b/frontend/app/announcement/[id]/page.tsx
@@ -105,15 +105,6 @@ export default async function AnnouncementPage({ params }: PageProps) {
- }
mobileExtras={
<>
{toc.length > 0 ?
: null}
@@ -123,6 +114,14 @@ export default async function AnnouncementPage({ params }: PageProps) {
}
>
+
- }
mobileExtras={
}
>
-
-
- {error ? (
- {error}
- ) : announcements.length === 0 ? (
-
-
- 暂无公告
+
+
+
+ 站点公告
+
+
+ {error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
-
有新消息时会显示在这里
-
- ) : (
-
- {featured ?
: null}
- {rest.map((a, i) => (
-
0} />
- ))}
-
- )}
+
+
+ {error ? (
+ {error}
+ ) : announcements.length === 0 ? (
+
+
+ 暂无公告
+
+
有新消息时会显示在这里
+
+ ) : (
+ <>
+ {featured ? : null}
+ {rest.map((a, i) => (
+ 0} />
+ ))}
+ >
+ )}
+
{!error ? (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 0d29a59..ffe926d 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -6,8 +6,8 @@
============================================================ */
:root {
- /* 浅色(默认)——中性暖灰底 + 纯白卡片浮起 */
- --bg: #f7f7f5;
+ /* 浅色(默认)——画廊白底,卡片纯白浮起 */
+ --bg: #fafbfc;
--panel: #ffffff;
--panel-2: #f2f4f7;
--tint: #eceff3;
@@ -56,8 +56,11 @@
--stock-badge-low-bg: var(--tint);
--stock-badge-low-fg: var(--ink-3);
- /* 工作台语义:选中用描边+文字色指示,底保持面板色,避免品牌色铺底 */
- --nav-active-bg: var(--panel);
+ /* 工作台可点块悬停底:左栏、帖子行、右栏公告/热门/友链/关于本站、目录等共用。
+ 改这一处即可全局换悬停色;选中态仍走 --nav-active-*。按钮/菜单等控件不要绑这个。 */
+ --item-hover-bg: var(--accent-soft);
+ /* 工作台语义:选中用主题色淡底 */
+ --nav-active-bg: var(--accent-soft);
--nav-active-ink: var(--accent);
--nav-active-line: rgba(59, 91, 219, 0.38);
--rank-1: #96620f;
@@ -68,9 +71,11 @@
--shadow: 0 2px 8px rgba(16, 24, 40, 0.05), 0 10px 28px rgba(16, 24, 40, 0.06);
--shadow-lg: 0 6px 16px rgba(16, 24, 40, 0.08), 0 20px 48px rgba(16, 24, 40, 0.12);
- /* 页面画布:极淡品牌绿光晕 + SVG 噪点(色值集中于此) */
- --bg-halo: rgba(7, 192, 95, 0.04);
- --bg-noise-opacity: 0.03;
+ /* 页面画布:主题色柔光 + 发丝网格(纯 CSS,随 --accent 换肤) */
+ --bg-halo: color-mix(in srgb, var(--accent) 8%, transparent);
+ --bg-mesh: color-mix(in srgb, var(--ink) 3.2%, transparent);
+ --bg-grid-line: rgba(15, 23, 42, 0.05);
+ --bg-grid-size: 28px;
/* next/font 构建期自托管,变量由 layout.tsx 注入;
本地系统字体仅作网络字体分片就绪前的回退(PingFang/雅黑与 Noto 观感接近) */
@@ -96,8 +101,8 @@
}
.dark {
- /* 近黑暖灰:面板逐层浮起,靛蓝提亮为长春花蓝 */
- --bg: #101211;
+ /* 近黑冷灰:面板逐层浮起,靛蓝提亮为长春花蓝 */
+ --bg: #0c0d10;
--panel: #181b21;
--panel-2: #20242c;
--tint: #282d37;
@@ -144,8 +149,9 @@
--stock-badge-low-bg: var(--tint);
--stock-badge-low-fg: var(--ink-3);
- /* 工作台语义:选中不铺品牌色底 */
- --nav-active-bg: var(--panel);
+ --item-hover-bg: var(--accent-soft);
+ /* 工作台语义:选中用主题色淡底 */
+ --nav-active-bg: var(--accent-soft);
--nav-active-ink: #a9b8ff;
--nav-active-line: rgba(141, 163, 255, 0.38);
--rank-1: #e3bf74;
@@ -156,8 +162,10 @@
--shadow: 0 2px 8px rgba(0, 0, 0, 0.28), 0 12px 32px rgba(0, 0, 0, 0.22);
--shadow-lg: 0 6px 18px rgba(0, 0, 0, 0.35), 0 24px 56px rgba(0, 0, 0, 0.3);
- --bg-halo: rgba(7, 192, 95, 0.03);
- --bg-noise-opacity: 0.04;
+ --bg-halo: color-mix(in srgb, var(--accent) 14%, transparent);
+ --bg-mesh: color-mix(in srgb, var(--ink) 5%, transparent);
+ --bg-grid-line: rgba(238, 241, 246, 0.045);
+ --bg-grid-size: 28px;
color-scheme: dark;
}
@@ -165,6 +173,14 @@
html {
scrollbar-color: var(--line-2) transparent;
background-color: var(--bg); /* 橡皮筋 overscroll 与画布同色 */
+ --j13-bg-site-image: none;
+ --j13-bg-site-size: cover;
+ --j13-bg-site-repeat: no-repeat;
+ --j13-bg-site-position: center;
+ --j13-bg-admin-image: none;
+ --j13-bg-admin-size: cover;
+ --j13-bg-admin-repeat: no-repeat;
+ --j13-bg-admin-position: center;
}
body {
@@ -178,14 +194,17 @@ body {
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
}
-/* 克制画布:fixed 伪元素(不用 background-attachment:fixed,避免移动端掉帧) */
+/* 画布纹饰:固定层(不用 background-attachment:fixed,避免移动端掉帧)。
+ 柔光 + 发丝网格均为静态 CSS,无 JS、无 SVG 滤镜、无持续动画。 */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
- background: radial-gradient(ellipse 80% 50% at 50% -20%, var(--bg-halo), transparent 70%);
+ background:
+ radial-gradient(ellipse 78% 48% at 16% -14%, var(--bg-halo), transparent 62%),
+ radial-gradient(ellipse 52% 36% at 96% 0%, var(--bg-mesh), transparent 58%);
}
body::after {
@@ -194,9 +213,23 @@ body::after {
inset: 0;
z-index: -1;
pointer-events: none;
- opacity: var(--bg-noise-opacity);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160' width='160' height='160'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
- background-size: 160px 160px;
+ background-image:
+ repeating-linear-gradient(
+ to right,
+ var(--bg-grid-line) 0,
+ var(--bg-grid-line) 1px,
+ transparent 1px,
+ transparent var(--bg-grid-size)
+ ),
+ repeating-linear-gradient(
+ to bottom,
+ var(--bg-grid-line) 0,
+ var(--bg-grid-line) 1px,
+ transparent 1px,
+ transparent var(--bg-grid-size)
+ );
+ -webkit-mask-image: radial-gradient(ellipse 92% 78% at 50% 16%, #000 12%, transparent 74%);
+ mask-image: radial-gradient(ellipse 92% 78% at 50% 16%, #000 12%, transparent 74%);
}
@media print {
@@ -213,6 +246,27 @@ body::after {
}
}
+/* 自定义背景图:改写 canvas 的 ::before 固定层,关闭发丝网格。
+ 前台 / 后台用 data-j13-chrome 分轨,软导航只改该属性即可切换。 */
+html.j13-has-site-bg[data-j13-chrome="site"] body::before {
+ background-color: var(--bg);
+ background-image: var(--j13-bg-site-image);
+ background-size: var(--j13-bg-site-size);
+ background-repeat: var(--j13-bg-site-repeat);
+ background-position: var(--j13-bg-site-position);
+}
+html.j13-has-admin-bg[data-j13-chrome="admin"] body::before {
+ background-color: var(--bg);
+ background-image: var(--j13-bg-admin-image);
+ background-size: var(--j13-bg-admin-size);
+ background-repeat: var(--j13-bg-admin-repeat);
+ background-position: var(--j13-bg-admin-position);
+}
+html.j13-has-site-bg[data-j13-chrome="site"] body::after,
+html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
+ display: none;
+}
+
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble {
position: fixed;
@@ -3077,6 +3131,10 @@ body:has([data-admin-viewport]) {
.admin-canvas:has(.j13-compose-page) {
padding: 0;
}
+/* 用户管理吸顶:顶内边距挪到 .admin-users-page,sticky 才能贴紧顶栏而不提前定住 */
+.admin-canvas:has(.admin-users-stick) {
+ padding-top: 0;
+}
.admin-canvas .j13-compose-page,
.admin-canvas .j13-compose-stage {
min-height: 100%;
@@ -3335,6 +3393,13 @@ a.j13-mention-token:hover {
inset: 0;
z-index: 1;
}
+ /* 标题本身在 overflow 里会被裁半圈;焦点画在铺满整行的 ::after 内侧 */
+ .stretch-link:focus-visible {
+ outline: none;
+ }
+ .stretch-link:focus-visible::after {
+ box-shadow: inset 0 0 0 2.5px var(--accent);
+ }
.above-link {
position: relative;
z-index: 2;
@@ -3740,6 +3805,28 @@ a.j13-mention-token:hover {
}
}
+ /* 用户管理:搜索栏 + 列头贴齐 admin-topbar(滚动容器是 .admin-canvas) */
+ .admin-users-page {
+ padding-top: 1.25rem;
+ }
+ @media (min-width: 640px) {
+ .admin-users-page {
+ padding-top: 1.5rem;
+ }
+ }
+ @media (min-width: 1024px) {
+ .admin-users-page {
+ padding-top: 1.75rem;
+ }
+ }
+ .admin-users-stick {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ background: var(--panel);
+ box-shadow: var(--shadow-sm);
+ }
+
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
.admin-content-page {
display: flex;
@@ -4419,7 +4506,7 @@ a.j13-mention-token:hover {
}
.j13-anno-item:hover,
.j13-anno-item:focus-visible {
- background: var(--accent-soft);
+ background: var(--item-hover-bg);
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
}
.j13-anno-item:hover::before,
@@ -4761,13 +4848,18 @@ a.j13-mention-token:hover {
color: var(--accent);
transform: translateY(-1px);
}
- /* 当前页:与全站选中态一致的主题色淡化底(而非实心填充) */
+ /* 当前页:主题色实心底,字色走 --accent-on 保证对比 */
.pager-btn.is-current,
.pager-btn.is-current:hover {
- background: var(--nav-active-bg);
- border-color: var(--nav-active-line);
- color: var(--nav-active-ink);
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-on);
font-weight: 600;
+ transform: none;
+ }
+ .pager-btn.is-current:hover {
+ background: var(--accent-hi);
+ border-color: var(--accent-hi);
}
/* ----- 辅助 ----- */
@@ -4822,7 +4914,7 @@ a.j13-mention-token:hover {
.j13-tab:hover {
color: var(--ink);
border-color: var(--line-2);
- background: var(--panel-2);
+ background: var(--item-hover-bg);
}
.j13-tab[aria-selected="true"] {
background: var(--nav-active-bg);
@@ -4848,7 +4940,7 @@ a.j13-mention-token:hover {
}
}
- /* 侧栏导航项:无选中态的悬停/聚焦与榜单、公告一致,用主题色淡底 */
+ /* 侧栏导航项:无选中态的悬停/聚焦与榜单、公告一致,走 --item-hover-bg */
.j13-raillink {
display: flex;
align-items: center;
@@ -4863,7 +4955,7 @@ a.j13-mention-token:hover {
}
.j13-raillink:hover,
.j13-raillink:focus-visible {
- background: var(--panel-2);
+ background: var(--item-hover-bg);
color: var(--ink);
}
.j13-raillink[aria-current="page"],
@@ -4875,8 +4967,17 @@ a.j13-mention-token:hover {
font-weight: 600;
}
- /* 首页左栏「全部讨论 / 板块目录」卡片:与面包屑的纯文字位置指引区分,
- 承载讨论量等发现型信息,点击在工作台内筛选帖子流 */
+ /* 正文内嵌面包屑:无独立底块,靠所在面板保证对比 */
+ .j13-page-crumb {
+ color: var(--ink-3);
+ }
+ .j13-page-crumb.is-ruled {
+ padding-bottom: 14px;
+ margin-bottom: 20px;
+ border-bottom: 1px solid var(--line);
+ }
+
+ /* 板块导航行:默认独立卡片;落入 j13-module 后收成内嵌列表 */
.j13-bcard {
display: flex;
align-items: center;
@@ -4888,24 +4989,25 @@ a.j13-mention-token:hover {
border-radius: var(--r-sm);
transition: border-color 0.16s ease, background 0.16s ease;
}
- .j13-bcard:hover {
- border-color: var(--line-2);
- background: var(--panel-2);
+ /* 悬停/选中必须叠在不透明 --panel 上:半透明 accent-soft 会把网格透出来 */
+ .j13-bcard:hover,
+ .j13-bcard:focus-visible {
+ border-color: var(--nav-active-line);
+ background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.j13-bcard:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
- /* 选中态:全站统一使用主题色淡化底(--nav-active-bg,随站点主题色联动),
- 与 raillink / chip / tab / 顶部导航 pill 的选中表达保持一致 */
.j13-bcard[aria-current="page"],
- .j13-bcard[aria-current="page"]:hover {
+ .j13-bcard[aria-current="page"]:hover,
+ .j13-bcard[aria-current="page"]:focus-visible {
border-color: var(--nav-active-line);
- background: var(--nav-active-bg);
+ background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.j13-bcard-name {
color: var(--ink);
- font-size: 14px;
+ font-size: 13.5px;
font-weight: 500;
}
.j13-bcard[aria-current="page"] .j13-bcard-name {
@@ -4922,6 +5024,49 @@ a.j13-mention-token:hover {
font-weight: 800;
flex-shrink: 0;
}
+ .j13-module .j13-bcard {
+ background: transparent;
+ border-color: transparent;
+ padding: 7px 8px;
+ border-radius: 10px;
+ }
+ .j13-module .j13-bcard:hover,
+ .j13-module .j13-bcard:focus-visible {
+ border-color: transparent;
+ background: color-mix(in srgb, var(--accent) 10%, var(--panel));
+ }
+ /* 滚动容器会把外扩 outline 裁成半圈:描边改画在条目内侧 */
+ .j13-module .j13-bcard:focus-visible {
+ outline: 2.5px solid var(--accent);
+ outline-offset: -2px;
+ }
+ .j13-module .j13-bcard[aria-current="page"],
+ .j13-module .j13-bcard[aria-current="page"]:hover,
+ .j13-module .j13-bcard[aria-current="page"]:focus-visible {
+ border-color: var(--nav-active-line);
+ background: color-mix(in srgb, var(--accent) 12%, var(--panel));
+ }
+ .j13-module .j13-bcard[aria-current="page"]:focus-visible {
+ outline: 2.5px solid var(--accent);
+ outline-offset: -2px;
+ }
+ .j13-module .j13-bcard-ico {
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ font-size: 12px;
+ }
+ .j13-board-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .j13-board-split {
+ height: 1px;
+ background: var(--line);
+ margin: 2px 4px;
+ flex-shrink: 0;
+ }
@media (prefers-reduced-motion: reduce) {
.j13-bcard {
transition: none;
@@ -4972,8 +5117,11 @@ a.j13-mention-token:hover {
}
.j13-about-item:hover,
.j13-about-item:focus-visible {
- background: var(--panel);
- border-color: var(--line-2);
+ background: var(--item-hover-bg);
+ border-color: var(--nav-active-line);
+ }
+ .j13-about-item:focus-visible {
+ outline-offset: -2px;
}
.j13-about-ico {
width: 30px;
@@ -5175,6 +5323,9 @@ a.j13-mention-token:hover {
font-weight: 500;
white-space: nowrap;
}
+ .j13-checkin-flow:focus-visible {
+ outline-offset: 1px;
+ }
/* 热门榜单 */
.j13-rank {
@@ -5186,10 +5337,13 @@ a.j13-mention-token:hover {
border-radius: 10px;
transition: background 0.16s ease;
}
- /* 榜单行:无选中态的导航项,悬停/聚焦统一使用主题色淡化底 */
+ /* 榜单行:无选中态的导航项,悬停/聚焦走 --item-hover-bg */
.j13-rank:hover,
.j13-rank:focus-visible {
- background: var(--accent-soft);
+ background: var(--item-hover-bg);
+ }
+ .j13-rank:focus-visible {
+ outline-offset: -2px;
}
.j13-rank-title {
display: block;
@@ -5263,9 +5417,9 @@ a.j13-mention-token:hover {
}
.j13-chip:hover,
.j13-chip:focus-visible {
- background: var(--panel-2);
- border-color: var(--line-2);
- color: var(--ink);
+ background: var(--item-hover-bg);
+ border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
+ color: var(--accent);
}
.j13-chip[aria-current="page"],
.j13-chip[aria-current="page"]:hover,
@@ -5274,6 +5428,14 @@ a.j13-mention-token:hover {
border-color: var(--nav-active-line);
color: var(--nav-active-ink);
}
+ .j13-chip:focus-visible {
+ outline-offset: -2px;
+ }
+
+ /* overflow:hidden 的列表面板:整行链接外描边会被圆角裁切 */
+ .panel.overflow-hidden > a:focus-visible {
+ outline-offset: -2px;
+ }
/* 帖子详情侧栏内的滚动承载区(目录 / 作者帖子列表):
配合栏容器的 max-height 独立滚动,细滚动条、不产生滚动链 */
@@ -5294,8 +5456,11 @@ a.j13-mention-token:hover {
.j13-rail-scroll::-webkit-scrollbar-track {
background: transparent;
}
+ .j13-rail-scroll :focus-visible {
+ outline-offset: -2px;
+ }
- /* 文章目录项:悬停/聚焦与侧栏其他可点块统一为主题色淡底 */
+ /* 文章目录项:悬停/聚焦与侧栏其他可点块统一走 --item-hover-bg */
.j13-toc-link {
display: block;
padding: 6px 4px 6px 12px;
@@ -5313,7 +5478,7 @@ a.j13-mention-token:hover {
.j13-toc-link:hover,
.j13-toc-link:focus-visible {
color: var(--accent);
- background: var(--panel-2);
+ background: var(--item-hover-bg);
}
.j13-toc-link.is-active,
.j13-toc-link.is-active:hover,
@@ -5823,20 +5988,20 @@ html.dark .prose-content pre[data-theme] span {
width: 2.75rem;
height: 2.75rem;
border-radius: 999px;
- border: 1px solid var(--line-2);
- background: color-mix(in srgb, var(--panel) 92%, transparent);
- color: var(--ink-2);
- box-shadow: var(--shadow-lg);
- backdrop-filter: blur(14px);
- -webkit-backdrop-filter: blur(14px);
+ border: 1px solid var(--accent);
+ background: var(--accent);
+ color: var(--accent-on);
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
+ 0 4px 14px color-mix(in srgb, var(--accent) 32%, transparent);
cursor: pointer;
- transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease,
+ box-shadow 0.15s ease;
}
.j13-back-top:hover,
.j13-back-top:focus-visible {
- color: var(--accent);
- background: var(--accent-soft);
- border-color: color-mix(in srgb, var(--accent) 28%, var(--line-2));
+ background: var(--accent-hi);
+ border-color: var(--accent-hi);
+ color: var(--accent-on);
outline: none;
}
/* 帖子移动端底栏上方抬高,避免遮挡 */
diff --git a/frontend/app/healthz/route.ts b/frontend/app/healthz/route.ts
new file mode 100644
index 0000000..f90353a
--- /dev/null
+++ b/frontend/app/healthz/route.ts
@@ -0,0 +1,9 @@
+// 官方运行时容器健康检查用,不走后端、不做 SSR。
+export const dynamic = "force-dynamic";
+
+export function GET() {
+ return new Response("ok", {
+ status: 200,
+ headers: { "cache-control": "no-store" },
+ });
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 7d35f5a..6113161 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -18,6 +18,7 @@ import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
import { accentStyleCSS } from "@/lib/theme";
+import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
import { motionConfigFromSettings } from "@/lib/motion";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
@@ -83,12 +84,14 @@ export default async function RootLayout({
const settings = await getPublicSettingsCached();
const accent = settings.accent;
const accentCSS = accentStyleCSS(accent || "");
- const customCSS = (settings.custom_css || "").trim();
- const customJS = (settings.custom_js || "").trim();
+ const bgCfg = bgConfigFromSettings(settings);
+ const bgCSS = bgStyleCSS(bgCfg);
const motionCfg = motionConfigFromSettings(settings);
const motionHtmlClass = [
!motionCfg.ui_animations ? "j13-reduce-motion" : "",
motionCfg.ui_animations && !motionCfg.anim_chrome ? "j13-reduce-chrome" : "",
+ bgCfg.bg_site_url ? "j13-has-site-bg" : "",
+ bgCfg.bg_admin_url ? "j13-has-admin-bg" : "",
]
.filter(Boolean)
.join(" ");
@@ -108,6 +111,7 @@ export default async function RootLayout({
return (
@@ -119,11 +123,11 @@ export default async function RootLayout({
dangerouslySetInnerHTML={{ __html: accentCSS }}
/>
) : null}
- {customCSS ? (
+ {bgCSS ? (
) : null}
@@ -156,12 +160,9 @@ export default async function RootLayout({
-
+
- {customJS ? (
-
- ) : null}
);
diff --git a/frontend/app/p/[slug]/page.tsx b/frontend/app/p/[slug]/page.tsx
index 9eeda83..5f1a001 100644
--- a/frontend/app/p/[slug]/page.tsx
+++ b/frontend/app/p/[slug]/page.tsx
@@ -107,15 +107,6 @@ export default async function SitePagePublic({ params }: PageProps) {
- }
mobileExtras={
<>
{toc.length > 0 ? : null}
@@ -132,6 +123,14 @@ export default async function SitePagePublic({ params }: PageProps) {
}
>
+
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
-
- {activeBoard ? (
-
-
-
- ) : null}
+
{heading}
+ {activeBoard ? (
+
+
+
+ ) : null}
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 5310124..32640a3 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -581,21 +581,17 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 中栏:正文主体 */}
-
-
+
+
-
-
- {post.board.name}
-
-
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 &&
置顶}
diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx
index cfd1097..e92f194 100644
--- a/frontend/app/u/[id]/page.tsx
+++ b/frontend/app/u/[id]/page.tsx
@@ -194,6 +194,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
role: u.role,
board_ids: u.board_ids,
banned: u.banned,
+ avatar: u.avatar,
+ created_at: u.created_at,
+ points: data.stats.points,
}}
/>
{allowMessages ?
: null}
diff --git a/frontend/components/Footer.tsx b/frontend/components/Footer.tsx
index ca54533..9bc18fa 100644
--- a/frontend/components/Footer.tsx
+++ b/frontend/components/Footer.tsx
@@ -1,9 +1,12 @@
import pkg from "../package.json";
import FooterTiming from "./FooterTiming";
-// 仅把明确发布过的版本视为"真实版本";脚手架默认号视为未设计,传空显示占位
-function realVersion(v: string): string {
- return !v || v === "0.0.0" || v === "0.1.0" ? "" : v;
+// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
+// 未注入时,脚手架默认号仍当未发布,页脚显示占位。
+function realVersion(pkgVersion: string): string {
+ const injected = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
+ if (injected) return injected;
+ return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
}
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
@@ -12,7 +15,13 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
const brand = siteName.trim() || "姜十三社区";
return (
-