feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -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
|
||||
27
README.md
27
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 生产)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "获取用户列表失败"})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
46
backend/service/admin_user_sort_test.go
Normal file
46
backend/service/admin_user_sort_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
167
backend/service/bg.go
Normal file
167
backend/service/bg.go
Normal file
@@ -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/"))
|
||||
}
|
||||
56
backend/service/bg_test.go
Normal file
56
backend/service/bg_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeCustomSnippet(t *testing.T) {
|
||||
got, err := sanitizeCustomSnippet(" body{color:red} ", "</style", MaxCustomCSSRunes)
|
||||
if err != nil || got != "body{color:red}" {
|
||||
t.Fatalf("trim got %q err=%v", got, err)
|
||||
}
|
||||
if _, err = sanitizeCustomSnippet("a</style>b", "</style", MaxCustomCSSRunes); err == nil {
|
||||
t.Fatal("css closer should fail")
|
||||
}
|
||||
if _, err = sanitizeCustomSnippet("a</SCRIPT>b", "</script", MaxCustomJSRunes); err == nil {
|
||||
t.Fatal("js closer should fail")
|
||||
}
|
||||
if _, err = sanitizeCustomSnippet("ok", "</script", MaxCustomJSRunes); err != nil {
|
||||
t.Fatalf("valid js: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,6 @@ const (
|
||||
SettingKeyAttachmentMaxCount = "attachment_max_count"
|
||||
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
|
||||
SettingKeyImageMaxMB = "image_max_mb"
|
||||
// SettingKeyCustomCSS 全站自定义 CSS(外观页注入,空=无)
|
||||
SettingKeyCustomCSS = "custom_css"
|
||||
// SettingKeyCustomJS 全站自定义 JS(外观页注入,空=无)
|
||||
SettingKeyCustomJS = "custom_js"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -74,9 +70,6 @@ const (
|
||||
MinAttachmentMaxCount = 1
|
||||
MaxAttachmentMaxCount = 20
|
||||
MaxAttachmentExtCount = 80
|
||||
|
||||
MaxCustomCSSRunes = 48000
|
||||
MaxCustomJSRunes = 48000
|
||||
)
|
||||
|
||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||
@@ -122,8 +115,10 @@ type PublicSiteSettings 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"`
|
||||
}
|
||||
|
||||
// SettingService 站点级键值设置
|
||||
@@ -198,6 +193,8 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
AttachmentMaxMB: DefaultAttachmentMaxMB,
|
||||
AttachmentMaxCount: DefaultAttachmentMaxCount,
|
||||
ImageMaxMB: DefaultImageMaxMB,
|
||||
BgSiteMode: DefaultBgMode,
|
||||
BgAdminMode: DefaultBgMode,
|
||||
}
|
||||
accent, err := s.AccentColor()
|
||||
if err != nil {
|
||||
@@ -326,17 +323,26 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
}
|
||||
out.ImageMaxMB = imgMB
|
||||
|
||||
css, err := s.CustomCSS()
|
||||
siteURL, err := s.BgSiteURL()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.CustomCSS = css
|
||||
|
||||
js, err := s.CustomJS()
|
||||
out.BgSiteURL = siteURL
|
||||
siteMode, err := s.BgSiteMode()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.CustomJS = js
|
||||
out.BgSiteMode = siteMode
|
||||
adminURL, err := s.BgAdminURL()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.BgAdminURL = adminURL
|
||||
adminMode, err := s.BgAdminMode()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.BgAdminMode = adminMode
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -788,65 +794,3 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
|
||||
}
|
||||
return int64(mb) << 20, nil
|
||||
}
|
||||
|
||||
func sanitizeCustomSnippet(s, closer string, maxRunes int) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.ContainsRune(s, 0) {
|
||||
return "", ErrInvalidSiteSetting
|
||||
}
|
||||
if utf8.RuneCountInString(s) > 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, "</style", MaxCustomCSSRunes)
|
||||
if nerr != nil {
|
||||
return "", nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetCustomCSS(css string) error {
|
||||
normalized, err := sanitizeCustomSnippet(css, "</style", MaxCustomCSSRunes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalized == "" {
|
||||
return s.deleteKey(SettingKeyCustomCSS)
|
||||
}
|
||||
return s.putValue(SettingKeyCustomCSS, normalized)
|
||||
}
|
||||
|
||||
// CustomJS 全站自定义 JS。缺行/空=无。
|
||||
func (s *SettingService) CustomJS() (string, error) {
|
||||
v, found, err := s.getValue(SettingKeyCustomJS)
|
||||
if err != nil || !found {
|
||||
return "", err
|
||||
}
|
||||
out, nerr := sanitizeCustomSnippet(v, "</script", MaxCustomJSRunes)
|
||||
if nerr != nil {
|
||||
return "", nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetCustomJS(js string) error {
|
||||
normalized, err := sanitizeCustomSnippet(js, "</script", MaxCustomJSRunes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalized == "" {
|
||||
return s.deleteKey(SettingKeyCustomJS)
|
||||
}
|
||||
return s.putValue(SettingKeyCustomJS, normalized)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ const (
|
||||
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
|
||||
ImageMaxBytes = 5 << 20 // 5 MiB
|
||||
ImageMaxDim = 4096
|
||||
|
||||
// 站点背景图(前台/后台墙纸)
|
||||
BackgroundMaxBytes = 8 << 20 // 8 MiB
|
||||
BackgroundMaxDim = 8192
|
||||
)
|
||||
|
||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||
@@ -64,7 +68,10 @@ func (s *UploadService) EnsureDir() error {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(s.dir, "images"), 0o755)
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755)
|
||||
}
|
||||
|
||||
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
|
||||
@@ -267,6 +274,119 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
||||
return att, nil
|
||||
}
|
||||
|
||||
// SaveBackground 保存站点背景图到 uploads/backgrounds,不进用户媒体库
|
||||
func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
|
||||
if src == nil {
|
||||
return "", errors.New("文件为空")
|
||||
}
|
||||
tooLarge := func() error {
|
||||
return errors.New("背景图不能超过 8MB")
|
||||
}
|
||||
|
||||
head := make([]byte, 12)
|
||||
n, err := io.ReadFull(src, head)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||
return "", errors.New("读取图片失败")
|
||||
}
|
||||
if n == 0 {
|
||||
return "", errors.New("文件为空")
|
||||
}
|
||||
format, err := detectImageFormat(head[:n])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "backgrounds", filename)
|
||||
tmp := fullPath + ".partial"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remain := BackgroundMaxBytes + 1 - int64(n)
|
||||
if remain < 0 {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return "", tooLarge()
|
||||
}
|
||||
if _, err := f.Write(head[:n]); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", copyErr
|
||||
}
|
||||
total := int64(n) + writtenRest
|
||||
if total > 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)
|
||||
|
||||
9
backend/version/version.go
Normal file
9
backend/version/version.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package version 由构建注入发布号;本地 go run 为 dev。
|
||||
package version
|
||||
|
||||
var (
|
||||
// Version 与仓库根 VERSION 对齐,Docker / ldflags 写入。
|
||||
Version = "dev"
|
||||
// Commit 短 sha,未知则为 unknown。
|
||||
Commit = "unknown"
|
||||
)
|
||||
25
deploy/.env.example
Normal file
25
deploy/.env.example
Normal file
@@ -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
|
||||
90
deploy/CONTRACT.md
Normal file
90
deploy/CONTRACT.md
Normal file
@@ -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。
|
||||
32
deploy/Dockerfile.api
Normal file
32
deploy/Dockerfile.api
Normal file
@@ -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"]
|
||||
37
deploy/Dockerfile.web
Normal file
37
deploy/Dockerfile.web
Normal file
@@ -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"]
|
||||
74
deploy/README.md
Normal file
74
deploy/README.md
Normal file
@@ -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 作为官方运行时
|
||||
6
deploy/api-entrypoint.sh
Normal file
6
deploy/api-entrypoint.sh
Normal file
@@ -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
|
||||
72
deploy/docker-compose.yml
Normal file
72
deploy/docker-compose.yml
Normal file
@@ -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:
|
||||
49
deploy/nginx.example.conf
Normal file
49
deploy/nginx.example.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
7
frontend/.dockerignore
Normal file
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.md
|
||||
coverage
|
||||
@@ -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
|
||||
|
||||
@@ -96,61 +96,63 @@ export default async function AboutIndexPage() {
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
right={right}
|
||||
breadcrumb={
|
||||
<SiteDocBreadcrumb
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "关于本站" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
mobileExtras={
|
||||
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
|
||||
}
|
||||
>
|
||||
<header className="mb-5 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1
|
||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<Info size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
关于本站
|
||||
</h1>
|
||||
<p className="meta mt-2">
|
||||
{error
|
||||
? "加载失败"
|
||||
: pages.length > 0
|
||||
? `共 ${pages.length} 篇站点文档`
|
||||
: "站点说明与政策文档"}
|
||||
</p>
|
||||
</div>
|
||||
{canManagePages ? (
|
||||
<Link
|
||||
href="/admin/pages"
|
||||
className="meta text-[13px] shrink-0 mb-0.5 transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
管理
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="panel overflow-hidden">
|
||||
<header
|
||||
className="px-5 sm:px-6 pt-5 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<SiteDocBreadcrumb
|
||||
className="mb-3"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "关于本站" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1
|
||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<Info size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
关于本站
|
||||
</h1>
|
||||
<p className="meta mt-2">
|
||||
{error
|
||||
? "加载失败"
|
||||
: pages.length > 0
|
||||
? `共 ${pages.length} 篇站点文档`
|
||||
: "站点说明与政策文档"}
|
||||
</p>
|
||||
</div>
|
||||
{canManagePages ? (
|
||||
<Link
|
||||
href="/admin/pages"
|
||||
className="meta text-[13px] shrink-0 mb-0.5 transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
管理
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="alert-error">{error}</p>
|
||||
) : pages.length === 0 ? (
|
||||
<div className="panel p-10 text-center">
|
||||
<p className="font-medium" style={{ color: "var(--ink)" }}>
|
||||
暂无介绍
|
||||
</p>
|
||||
<p className="meta mt-1.5">有新文档时会显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel overflow-hidden">
|
||||
{pages.map((p, i) => (
|
||||
<PageRow key={p.id} p={p} showDivider={i > 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="alert-error m-5">{error}</p>
|
||||
) : pages.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<p className="font-medium" style={{ color: "var(--ink)" }}>
|
||||
暂无介绍
|
||||
</p>
|
||||
<p className="meta mt-1.5">有新文档时会显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
pages.map((p, i) => <PageRow key={p.id} p={p} showDivider={i > 0} />)
|
||||
)}
|
||||
</div>
|
||||
</SiteDocShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SiteBgConfig>(() => bgConfigFromSettings(initial));
|
||||
const [bgBusy, setBgBusy] = useState<BgSurface | null>(null);
|
||||
const siteFileRef = useRef<HTMLInputElement>(null);
|
||||
const adminFileRef = useRef<HTMLInputElement>(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<SiteBgConfig>, 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 (
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||
<Icon size={15} /> {title}
|
||||
</h3>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative h-36 rounded-[var(--r-sm)] border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
...previewBoxStyle(url, mode),
|
||||
}}
|
||||
>
|
||||
{!url && (
|
||||
<span
|
||||
className="absolute inset-0 flex items-center justify-center text-[12.5px]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
默认纹饰画布
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
void onPickBg(surface, f);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
|
||||
{url ? "更换图片" : "上传图片"}
|
||||
</button>
|
||||
{url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void onClearBg(surface)}
|
||||
>
|
||||
<Trash2 size={14} /> 恢复默认
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="field-label mb-2">铺放模式</p>
|
||||
<AdminSegmented
|
||||
size="sm"
|
||||
role="group"
|
||||
ariaLabel={`${title}铺放模式`}
|
||||
value={mode}
|
||||
onChange={(key) => void onChangeBgMode(surface, normalizeBgMode(key))}
|
||||
options={BG_MODE_OPTIONS.map((o) => ({
|
||||
key: o.key,
|
||||
label: o.label,
|
||||
disabled: busy,
|
||||
}))}
|
||||
/>
|
||||
<p className="meta text-[12px] mt-1.5">
|
||||
{BG_MODE_OPTIONS.find((o) => o.key === mode)?.hint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={Palette}
|
||||
title="外观设置"
|
||||
description="配置站点主题色、自定义 CSS/JS、代码块折叠与界面动画"
|
||||
description="配置站点主题色、背景图、代码块折叠与界面动画"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
|
||||
@@ -370,7 +522,8 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-w-xs">
|
||||
<div className="panel p-3">
|
||||
<section className="j13-module">
|
||||
<p className="j13-module-title mb-2.5">板块目录</p>
|
||||
<div className="j13-bcard" aria-current="page">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
@@ -378,9 +531,9 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
>
|
||||
<Palette size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
|
||||
<span className="j13-bcard-name flex-1">选中的板块</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSection>
|
||||
@@ -516,81 +669,29 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
</div>
|
||||
|
||||
<AdminSection
|
||||
className="mb-6"
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<FileCode2 size={15} /> 自定义代码
|
||||
<ImageIcon size={15} /> 站点背景图
|
||||
</span>
|
||||
}
|
||||
description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 </style> 或 </script>。"
|
||||
className="mb-6"
|
||||
description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。未配置时使用默认纹饰画布。"
|
||||
>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<label className="field-label !mb-0" htmlFor="custom-css">
|
||||
自定义 CSS
|
||||
</label>
|
||||
{customCSS !== SAMPLE_CUSTOM_CSS && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !h-7 !px-2.5 !text-[12px]"
|
||||
onClick={() => setCustomCSS(SAMPLE_CUSTOM_CSS)}
|
||||
>
|
||||
填入示例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="meta text-[12.5px] mb-2 leading-relaxed">
|
||||
覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "}
|
||||
{MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。
|
||||
</p>
|
||||
<textarea
|
||||
id="custom-css"
|
||||
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
|
||||
style={{ minHeight: 280 }}
|
||||
spellCheck={false}
|
||||
value={customCSS}
|
||||
onChange={(e) => setCustomCSS(clipCustomSnippet(e.target.value, MAX_CUSTOM_CSS_CHARS))}
|
||||
aria-label="自定义 CSS"
|
||||
/>
|
||||
<p className="meta text-[11.5px] mt-1.5 tabular-nums">
|
||||
{customCSS.length.toLocaleString()} / {MAX_CUSTOM_CSS_CHARS.toLocaleString()}
|
||||
{cssIsGuide ? " · 示例未保存" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<label className="field-label !mb-0" htmlFor="custom-js">
|
||||
自定义 JS
|
||||
</label>
|
||||
{customJS !== SAMPLE_CUSTOM_JS && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !h-7 !px-2.5 !text-[12px]"
|
||||
onClick={() => setCustomJS(SAMPLE_CUSTOM_JS)}
|
||||
>
|
||||
填入示例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="meta text-[12.5px] mb-2 leading-relaxed">
|
||||
仅站长/超管可写。不在编辑时运行,保存后需整页刷新。上限{" "}
|
||||
{MAX_CUSTOM_JS_CHARS.toLocaleString()} 字。
|
||||
</p>
|
||||
<textarea
|
||||
id="custom-js"
|
||||
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
|
||||
style={{ minHeight: 280 }}
|
||||
spellCheck={false}
|
||||
value={customJS}
|
||||
onChange={(e) => setCustomJS(clipCustomSnippet(e.target.value, MAX_CUSTOM_JS_CHARS))}
|
||||
aria-label="自定义 JS"
|
||||
/>
|
||||
<p className="meta text-[11.5px] mt-1.5 tabular-nums">
|
||||
{customJS.length.toLocaleString()} / {MAX_CUSTOM_JS_CHARS.toLocaleString()}
|
||||
{jsIsGuide ? " · 示例未保存" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{renderBgRail(
|
||||
"site",
|
||||
"前台",
|
||||
"访客看到的论坛页面,含首页、帖子与个人主页。",
|
||||
Monitor,
|
||||
siteFileRef
|
||||
)}
|
||||
{renderBgRail(
|
||||
"admin",
|
||||
"后台",
|
||||
"管理面板专用。与前台互不影响,可配更克制的图案。",
|
||||
LayoutDashboard,
|
||||
adminFileRef
|
||||
)}
|
||||
</div>
|
||||
</AdminSection>
|
||||
|
||||
|
||||
@@ -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<ColKey>(["id", "role", "status", "points", "last_login", "counts", "created_at", "online"]);
|
||||
|
||||
const OPTIONAL_LABELS: Record<OptionalCol, string> = {
|
||||
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({
|
||||
))}
|
||||
</ul>
|
||||
<p className="px-2 pt-1.5 pb-1 text-[11px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
|
||||
成员 / ID / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative shrink-0 md:hidden" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ArrowUpDown size={14} />
|
||||
{current?.label ?? "排序"}
|
||||
{order === "asc" ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="排序"
|
||||
className="absolute right-0 top-full mt-2 z-40 w-48 p-2 j13-toast-in"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{SORT_OPTIONS.map((o) => {
|
||||
const active = sort === o.key;
|
||||
return (
|
||||
<li key={o.key}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className="w-full flex items-center justify-between gap-2 px-2 py-2 rounded-[var(--r-sm)] text-[13px] hover:bg-[var(--panel-2)]"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
|
||||
onClick={() => {
|
||||
onChange(o.key, active && order === "desc" ? "asc" : "desc");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
{active ? (order === "asc" ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<UserFilter>(initialQuery.filter);
|
||||
const [keyword, setKeyword] = useState(initialQuery.q);
|
||||
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
|
||||
const [sort, setSort] = useState<UserSortKey>(initialQuery.sort);
|
||||
const [order, setOrder] = useState<UserSortDir>(initialQuery.order);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initError);
|
||||
const [actingId, setActingId] = useState<number | null>(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 (
|
||||
<span>{label}</span>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div role="columnheader" aria-sort={ariaSort}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className="-ml-1 inline-flex items-center gap-0.5 rounded-md px-1 py-0.5 uppercase tracking-wide transition-colors hover:bg-[var(--panel)]"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
|
||||
aria-label={
|
||||
active
|
||||
? `${label},当前${order === "asc" ? "升序" : "降序"},点击切换`
|
||||
: `按${label}排序`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
<Icon size={13} className={active ? undefined : "opacity-45"} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (u: AdminUser) => {
|
||||
if (u.banned) {
|
||||
return (
|
||||
@@ -519,7 +677,7 @@ export default function UsersAdmin({
|
||||
|
||||
if (u.role === ROLES.OWNER) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<div className="flex items-center gap-1">
|
||||
{auditBtn}
|
||||
<AdminIconButton href={`/u/${u.id}`} tip="查看个人主页" ariaLabel={`查看 ${u.nickname} 的个人主页`}>
|
||||
<ExternalLink size={15} />
|
||||
@@ -529,7 +687,7 @@ export default function UsersAdmin({
|
||||
}
|
||||
if (isSelf) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<div className="flex items-center gap-1">
|
||||
{auditBtn}
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] whitespace-nowrap"
|
||||
@@ -543,7 +701,7 @@ export default function UsersAdmin({
|
||||
const busy = actingId === u.id;
|
||||
const roleTone = u.banned ? "danger" : u.role !== ROLES.USER ? "accent" : "default";
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<div className="flex items-center gap-1">
|
||||
{auditBtn}
|
||||
<AdminIconButton
|
||||
href={`/u/${u.id}`}
|
||||
@@ -567,6 +725,12 @@ export default function UsersAdmin({
|
||||
|
||||
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
|
||||
switch (c) {
|
||||
case "id":
|
||||
return (
|
||||
<span className="text-[12.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
{u.id}
|
||||
</span>
|
||||
);
|
||||
case "member":
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
@@ -622,12 +786,12 @@ export default function UsersAdmin({
|
||||
</span>
|
||||
);
|
||||
case "actions":
|
||||
return <div className="flex justify-end">{renderActions(u, isSelf)}</div>;
|
||||
return <div className="flex">{renderActions(u, isSelf)}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-users-page">
|
||||
<AdminPageHeader
|
||||
icon={Users}
|
||||
title="用户管理"
|
||||
@@ -668,73 +832,80 @@ export default function UsersAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="admin-toolbar">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索用户名、昵称或邮箱…"
|
||||
ariaLabel="搜索用户"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
<AdminSegmented<UserFilter>
|
||||
role="group"
|
||||
ariaLabel="用户筛选"
|
||||
value={filter}
|
||||
onChange={changeFilter}
|
||||
options={FILTER_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
count: tabCount(t.key),
|
||||
}))}
|
||||
<div className="panel">
|
||||
<div className="admin-users-stick">
|
||||
<div className="admin-toolbar">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
<AdminSegmented<UserFilter>
|
||||
role="group"
|
||||
ariaLabel="用户筛选"
|
||||
value={filter}
|
||||
onChange={changeFilter}
|
||||
options={FILTER_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
count: tabCount(t.key),
|
||||
}))}
|
||||
/>
|
||||
<MobileSortPopover
|
||||
sort={sort}
|
||||
order={order}
|
||||
onChange={(key, dir) => load({ page: 1, filter, q: appliedQ, sort: key, order: dir })}
|
||||
/>
|
||||
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
|
||||
</div>
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索用户名、昵称或邮箱…"
|
||||
ariaLabel="搜索用户"
|
||||
/>
|
||||
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
{appliedQ ? (
|
||||
<>
|
||||
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
|
||||
</>
|
||||
) : filter === "staff" ? (
|
||||
"管理团队"
|
||||
) : filter === "banned" ? (
|
||||
"已封禁成员"
|
||||
) : (
|
||||
"全部成员"
|
||||
)}
|
||||
</span>
|
||||
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
共 {total} 人
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<div
|
||||
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 text-[11.5px] uppercase tracking-wide"
|
||||
style={{
|
||||
...gridStyle,
|
||||
color: "var(--ink-3)",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
background: "var(--panel-2)",
|
||||
}}
|
||||
className="px-4 sm:px-5 py-2.5 flex items-center justify-between gap-2 flex-wrap"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
{visibleCols.map((c) => (
|
||||
<span key={c} className={c === "actions" ? "text-right" : undefined}>
|
||||
{colHeader(c)}
|
||||
</span>
|
||||
))}
|
||||
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{appliedQ ? (
|
||||
<>
|
||||
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
|
||||
</>
|
||||
) : filter === "staff" ? (
|
||||
"管理团队"
|
||||
) : filter === "banned" ? (
|
||||
"已封禁成员"
|
||||
) : (
|
||||
"全部成员"
|
||||
)}
|
||||
</span>
|
||||
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
共 {total} 人
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{users.length > 0 && (
|
||||
<div
|
||||
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3 text-[11.5px] uppercase tracking-wide"
|
||||
style={{
|
||||
...gridStyle,
|
||||
color: "var(--ink-3)",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
background: "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
{visibleCols.map((c) => (
|
||||
<div key={c}>
|
||||
{renderColHeader(c)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div aria-busy={loading}>
|
||||
{error ? (
|
||||
@@ -779,7 +950,7 @@ export default function UsersAdmin({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
@{u.username}
|
||||
#{u.id} · @{u.username}
|
||||
{optCols.email && u.email ? ` · ${u.email}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
<UsersAdmin
|
||||
initial={initial}
|
||||
initError={initError}
|
||||
initialQuery={{ q, filter, page }}
|
||||
initialQuery={{ q, filter, page, sort, order }}
|
||||
currentUserId={me.user?.id ?? 0}
|
||||
currentUserRole={me.user?.role ?? ""}
|
||||
/>
|
||||
|
||||
@@ -105,15 +105,6 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
right={right}
|
||||
breadcrumb={
|
||||
<SiteDocBreadcrumb
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/announcements", label: "站点公告" },
|
||||
{ label: a.title },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
mobileExtras={
|
||||
<>
|
||||
{toc.length > 0 ? <PostToc items={toc} /> : null}
|
||||
@@ -123,6 +114,14 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
}
|
||||
>
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/announcements", label: "站点公告" },
|
||||
{ label: a.title },
|
||||
]}
|
||||
/>
|
||||
<SiteDocMasthead
|
||||
kicker="站点公告"
|
||||
title={a.title}
|
||||
|
||||
@@ -140,48 +140,52 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
right={right}
|
||||
breadcrumb={
|
||||
<SiteDocBreadcrumb
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "站点公告" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
mobileExtras={
|
||||
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
|
||||
}
|
||||
>
|
||||
<header className="mb-5">
|
||||
<h1
|
||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
<div className="panel overflow-hidden">
|
||||
<header
|
||||
className="px-5 sm:px-6 pt-5 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
站点公告
|
||||
</h1>
|
||||
<p className="meta mt-2">
|
||||
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="alert-error">{error}</p>
|
||||
) : announcements.length === 0 ? (
|
||||
<div className="panel p-10 text-center">
|
||||
<p className="font-medium" style={{ color: "var(--ink)" }}>
|
||||
暂无公告
|
||||
<SiteDocBreadcrumb
|
||||
className="mb-3"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "站点公告" },
|
||||
]}
|
||||
/>
|
||||
<h1
|
||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
站点公告
|
||||
</h1>
|
||||
<p className="meta mt-2">
|
||||
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
|
||||
</p>
|
||||
<p className="meta mt-1.5">有新消息时会显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel overflow-hidden">
|
||||
{featured ? <FeaturedAnnouncement a={featured} /> : null}
|
||||
{rest.map((a, i) => (
|
||||
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="alert-error m-5">{error}</p>
|
||||
) : announcements.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<p className="font-medium" style={{ color: "var(--ink)" }}>
|
||||
暂无公告
|
||||
</p>
|
||||
<p className="meta mt-1.5">有新消息时会显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{featured ? <FeaturedAnnouncement a={featured} /> : null}
|
||||
{rest.map((a, i) => (
|
||||
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!error ? (
|
||||
<Pagination total={total} page={page} size={size} basePath="/announcements" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
/* 帖子移动端底栏上方抬高,避免遮挡 */
|
||||
|
||||
9
frontend/app/healthz/route.ts
Normal file
9
frontend/app/healthz/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// 官方运行时容器健康检查用,不走后端、不做 SSR。
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
data-j13-chrome={isAdmin ? "admin" : "site"}
|
||||
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||
>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
@@ -119,11 +123,11 @@ export default async function RootLayout({
|
||||
dangerouslySetInnerHTML={{ __html: accentCSS }}
|
||||
/>
|
||||
) : null}
|
||||
{customCSS ? (
|
||||
{bgCSS ? (
|
||||
<style
|
||||
id="j13-custom-css"
|
||||
data-custom-css-source="ssr"
|
||||
dangerouslySetInnerHTML={{ __html: customCSS }}
|
||||
id="j13-bg-vars"
|
||||
data-bg-source="ssr"
|
||||
dangerouslySetInnerHTML={{ __html: bgCSS }}
|
||||
/>
|
||||
) : null}
|
||||
<a href="#main" className="skip-link">
|
||||
@@ -156,12 +160,9 @@ export default async function RootLayout({
|
||||
<Toaster />
|
||||
<TipHost />
|
||||
<AccountGuard />
|
||||
<ThemeSync initialAccent={accent || ""} initialCustomCSS={customCSS} />
|
||||
<ThemeSync initialAccent={accent || ""} initialBg={bgCfg} />
|
||||
<VisitBeacon />
|
||||
<RealtimeProvider userId={user ? user.id : null} />
|
||||
{customJS ? (
|
||||
<script id="j13-custom-js" dangerouslySetInnerHTML={{ __html: customJS }} />
|
||||
) : null}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -107,15 +107,6 @@ export default async function SitePagePublic({ params }: PageProps) {
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
right={right}
|
||||
breadcrumb={
|
||||
<SiteDocBreadcrumb
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/about", label: "关于本站" },
|
||||
{ label: p.title },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
mobileExtras={
|
||||
<>
|
||||
{toc.length > 0 ? <PostToc items={toc} /> : null}
|
||||
@@ -132,6 +123,14 @@ export default async function SitePagePublic({ params }: PageProps) {
|
||||
}
|
||||
>
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/about", label: "关于本站" },
|
||||
{ label: p.title },
|
||||
]}
|
||||
/>
|
||||
<SiteDocMasthead
|
||||
kicker="官方文件"
|
||||
title={p.title}
|
||||
|
||||
@@ -172,20 +172,21 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
<WorkspaceNav boards={boards} user={user} />
|
||||
|
||||
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
{activeBoard ? (
|
||||
<div className="mt-4 lg:mt-0">
|
||||
<SiteDocBreadcrumb
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: activeBoard.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="sr-only">{heading}</h1>
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
{activeBoard ? (
|
||||
<div className="px-3 sm:px-4 pt-3">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled !mb-0"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: activeBoard.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 pt-3 pb-2.5">
|
||||
<FeedTabs current={sort} boardId={boardId} />
|
||||
<span className="meta shrink-0 text-[12px] tabular-nums">
|
||||
|
||||
@@ -581,21 +581,17 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
</aside>
|
||||
|
||||
{/* 中栏:正文主体 */}
|
||||
<div className="min-w-0 relative">
|
||||
<SiteDocBreadcrumb
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: `/?board=${post.board.id}`, label: post.board.name },
|
||||
]}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<article className="w-full">
|
||||
<div className="panel p-6 sm:p-8">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: `/?board=${post.board.id}`, label: post.board.name },
|
||||
]}
|
||||
/>
|
||||
<header className="mb-7">
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<Link href={`/?board=${post.board.id}`} className="chip">
|
||||
{post.board.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap mb-6">
|
||||
{/* 仅置顶/推荐在标题左侧 */}
|
||||
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
|
||||
|
||||
@@ -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 ? <StartDMButton userId={data.user.id} /> : null}
|
||||
|
||||
@@ -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 (
|
||||
<footer className="mt-14" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<footer
|
||||
className="mt-14"
|
||||
style={{
|
||||
borderTop: "1px solid var(--line)",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||
<FooterTiming version={realVersion(pkg.version)} />
|
||||
|
||||
@@ -199,9 +199,7 @@ export default function Header({
|
||||
className="sticky top-0 z-50 border-b"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
background: "color-mix(in srgb, var(--panel) 86%, transparent)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function PinnedPosts({
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/post/${p.id}`}
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] j13-fade-up"
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)] j13-fade-up"
|
||||
style={{
|
||||
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
|
||||
animationDelay: `${i * 35}ms`,
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function PostRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--panel-2)] focus-within:bg-[var(--panel-2)] j13-fade-up ${
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-fade-up ${
|
||||
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
|
||||
}`}
|
||||
style={{
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Ban, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
|
||||
import { ROLES, ROLE_META } from "@/lib/roles";
|
||||
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import Modal from "@/components/Modal";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
|
||||
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
|
||||
export interface RoleAccountTarget {
|
||||
@@ -15,6 +19,10 @@ export interface RoleAccountTarget {
|
||||
role: string;
|
||||
board_ids?: number[];
|
||||
banned: boolean;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
points?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
@@ -125,9 +133,7 @@ export default function RoleAccountModal({
|
||||
if (willUnban) parts.push("已解封");
|
||||
toast(parts.join(" · ") || "已保存", "ok");
|
||||
onSaved({
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
nickname: target.nickname,
|
||||
...target,
|
||||
role: nextRole,
|
||||
board_ids: nextBoards,
|
||||
banned: nextBanned,
|
||||
@@ -140,26 +146,20 @@ export default function RoleAccountModal({
|
||||
};
|
||||
|
||||
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
|
||||
const displayName = target.nickname || target.username;
|
||||
const currentMeta = roleMeta(target.role);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="角色与账号"
|
||||
description={
|
||||
<>
|
||||
<span className="truncate block">
|
||||
@{target.username}({target.nickname})
|
||||
</span>
|
||||
<span className="block mt-1">
|
||||
角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
header={<IdentityHeader target={target} displayName={displayName} currentMetaLabel={currentMeta.label} />}
|
||||
onClose={onClose}
|
||||
busy={acting}
|
||||
maxWidthClass="max-w-[500px]"
|
||||
panelClassName="p-6 max-h-[88vh] overflow-y-auto"
|
||||
ariaLabel="角色与账号"
|
||||
maxWidthClass="max-w-[540px]"
|
||||
panelClassName="p-6 max-h-[88vh] flex flex-col overflow-hidden"
|
||||
bodyClassName="overflow-y-auto flex-1 min-h-0 -mx-1 px-1"
|
||||
ariaLabel={`设置 ${displayName} 的角色与账号`}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
|
||||
@@ -177,7 +177,7 @@ export default function RoleAccountModal({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
|
||||
<p className="mb-2.5 text-[12px] font-medium tracking-wide" style={{ color: "var(--ink-3)" }}>
|
||||
管理角色
|
||||
</p>
|
||||
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
|
||||
@@ -231,7 +231,7 @@ export default function RoleAccountModal({
|
||||
|
||||
{role === ROLES.BOARD_ADMIN && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[12px] font-medium tracking-wide mb-2" style={{ color: "var(--ink-3)" }}>
|
||||
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</p>
|
||||
{boards.length === 0 ? (
|
||||
@@ -280,7 +280,7 @@ export default function RoleAccountModal({
|
||||
)}
|
||||
|
||||
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
|
||||
账号状态
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
|
||||
@@ -289,17 +289,17 @@ export default function RoleAccountModal({
|
||||
role="radio"
|
||||
aria-checked={!banned}
|
||||
onClick={() => setBanned(false)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
className="rounded-xl px-3.5 py-3 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
|
||||
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium"
|
||||
className="flex items-center gap-1.5 text-[13.5px] font-medium"
|
||||
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
|
||||
>
|
||||
<RotateCcw size={14} /> 正常
|
||||
<RotateCcw size={15} /> 正常
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
可登录与互动
|
||||
@@ -310,17 +310,17 @@ export default function RoleAccountModal({
|
||||
role="radio"
|
||||
aria-checked={banned}
|
||||
onClick={() => setBanned(true)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
className="rounded-xl px-3.5 py-3 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
|
||||
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium"
|
||||
className="flex items-center gap-1.5 text-[13.5px] font-medium"
|
||||
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
|
||||
>
|
||||
<Ban size={14} /> 封禁
|
||||
<Ban size={15} /> 封禁
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
强制下线,禁止登录
|
||||
@@ -328,7 +328,7 @@ export default function RoleAccountModal({
|
||||
</button>
|
||||
</div>
|
||||
{willBan && (
|
||||
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
<p className="mt-2.5 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
|
||||
</p>
|
||||
)}
|
||||
@@ -336,3 +336,89 @@ export default function RoleAccountModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityHeader({
|
||||
target,
|
||||
displayName,
|
||||
currentMetaLabel,
|
||||
}: {
|
||||
target: RoleAccountTarget;
|
||||
displayName: string;
|
||||
currentMetaLabel: string;
|
||||
}) {
|
||||
const metaBits: string[] = [`ID ${target.id}`];
|
||||
if (typeof target.points === "number") {
|
||||
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
|
||||
}
|
||||
if (target.created_at) {
|
||||
metaBits.push(`注册 ${formatDate(target.created_at)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="text-[11px] font-medium uppercase tracking-[0.14em] mb-3"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
角色与账号
|
||||
</p>
|
||||
<div
|
||||
className="flex items-start gap-4 min-w-0 rounded-2xl p-3.5"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}
|
||||
<span
|
||||
className="inline-flex items-center justify-center shrink-0 rounded-full"
|
||||
style={{
|
||||
boxShadow: target.banned
|
||||
? "0 0 0 3px var(--panel-2), 0 0 0 4.5px var(--danger)"
|
||||
: "0 0 0 3px var(--panel-2), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<Avatar
|
||||
name={displayName}
|
||||
src={target.avatar || undefined}
|
||||
size={68}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] truncate" style={{ color: "var(--ink-2)" }}>
|
||||
@{target.username}
|
||||
{target.email ? ` · ${target.email}` : ""}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
|
||||
<RoleBadge role={target.role} boardIds={target.board_ids} />
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
|
||||
style={
|
||||
target.banned
|
||||
? { background: "var(--danger-soft)", color: "var(--danger)" }
|
||||
: { background: "var(--ok-soft)", color: "var(--ok)" }
|
||||
}
|
||||
>
|
||||
{target.banned ? "已封禁" : "正常"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] tabular-nums leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{metaBits.join(" · ")}
|
||||
<span className="sr-only">当前角色 {currentMetaLabel}</span>
|
||||
</p>
|
||||
<Link
|
||||
href={`/u/${target.id}`}
|
||||
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
查看个人主页 <ExternalLink size={12} aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ export default function SiteChrome({
|
||||
// 首屏跟 SSR;之后在渲染期直接读 pathname,软导航无 Header 闪动
|
||||
const isAdmin = mounted ? pathIsAdmin(pathname) : initialIsAdmin;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
|
||||
}, [isAdmin]);
|
||||
|
||||
if (isAdmin) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface StaffUserTarget {
|
||||
board_ids?: number[];
|
||||
/** 有权访客资料接口下发;未知时按未封禁处理 */
|
||||
banned?: boolean;
|
||||
avatar?: string;
|
||||
created_at?: string;
|
||||
points?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +130,9 @@ export default function StaffUserMenu({
|
||||
role: target.role,
|
||||
board_ids: target.board_ids,
|
||||
banned,
|
||||
avatar: target.avatar,
|
||||
created_at: target.created_at,
|
||||
points: target.points,
|
||||
};
|
||||
|
||||
const itemCls =
|
||||
|
||||
@@ -4,29 +4,33 @@ import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiGetSettings } from "@/lib/api";
|
||||
import { applyAccentTheme } from "@/lib/theme";
|
||||
import { applyCustomCSS } from "@/lib/customCode";
|
||||
import {
|
||||
applySiteBackground,
|
||||
bgConfigFromSettings,
|
||||
type SiteBgConfig,
|
||||
type SiteBgInput,
|
||||
} from "@/lib/bg";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import {
|
||||
VISIBLE_RECONCILE_GAP_MS,
|
||||
onDocumentVisible,
|
||||
} from "@/lib/visibilityReconcile";
|
||||
|
||||
// 站点外观同步器:
|
||||
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
|
||||
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
|
||||
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
|
||||
// 站点主题色 / 背景图同步器:
|
||||
// 管理员保存后,其他已打开标签页不会因软导航重跑根布局 SSR。
|
||||
// WS 推送 + 标签页重新可见时探测公开设置,热替换 accent 与背景。
|
||||
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
|
||||
|
||||
export default function ThemeSync({
|
||||
initialAccent,
|
||||
initialCustomCSS,
|
||||
initialBg,
|
||||
}: {
|
||||
initialAccent: string;
|
||||
initialCustomCSS: string;
|
||||
initialBg: SiteBgConfig;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
|
||||
const cssRef = useRef((initialCustomCSS || "").trim());
|
||||
const bgRef = useRef(initialBg);
|
||||
const lastFetchAtRef = useRef(0);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
@@ -42,12 +46,11 @@ export default function ThemeSync({
|
||||
applyAccentTheme(next || null);
|
||||
};
|
||||
|
||||
const applyCSS = (next: string) => {
|
||||
const applyBg = (raw: SiteBgInput) => {
|
||||
if (cancelled) return;
|
||||
const text = (next || "").trim();
|
||||
if (text === cssRef.current) return;
|
||||
cssRef.current = text;
|
||||
applyCustomCSS(text);
|
||||
const next = bgConfigFromSettings(raw);
|
||||
bgRef.current = next;
|
||||
applySiteBackground(next);
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
@@ -57,10 +60,10 @@ export default function ThemeSync({
|
||||
lastFetchAtRef.current = now;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const settings = await apiGetSettings();
|
||||
const s = await apiGetSettings();
|
||||
if (cancelled) return;
|
||||
applyAccent((settings.accent || "").trim().toLowerCase());
|
||||
applyCSS(settings.custom_css || "");
|
||||
applyAccent((s.accent || "").trim().toLowerCase());
|
||||
applyBg(s);
|
||||
} catch {
|
||||
// 同步失败静默,下次回前台再试
|
||||
} finally {
|
||||
@@ -71,7 +74,7 @@ export default function ThemeSync({
|
||||
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (!data) return;
|
||||
applyAccent((data.accent || "").trim().toLowerCase());
|
||||
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
|
||||
applyBg(data);
|
||||
});
|
||||
|
||||
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
|
||||
|
||||
@@ -46,20 +46,27 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
|
||||
<p className="text-[12.5px] leading-tight" style={{ color: "var(--ink)" }}>
|
||||
每日签到
|
||||
</p>
|
||||
<p className="meta mt-0.5 truncate text-[11px]">
|
||||
{guest
|
||||
? "登录后每日签到领积分"
|
||||
: checked
|
||||
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
|
||||
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
|
||||
{!guest && (
|
||||
<p className="meta mt-0.5 flex min-w-0 items-center gap-1 text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
{guest
|
||||
? "登录后每日签到领积分"
|
||||
: checked
|
||||
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
|
||||
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
|
||||
</span>
|
||||
{!guest ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/points" className="hover:text-[var(--accent)] transition-colors">
|
||||
<span className="shrink-0" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link
|
||||
href="/points"
|
||||
className="j13-checkin-flow shrink-0 rounded-sm px-0.5 -mx-0.5 hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
流水
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
{guest ? (
|
||||
|
||||
@@ -296,7 +296,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
|
||||
>
|
||||
<Avatar name={name} src={u.avatar} size={38} />
|
||||
<span
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home } from "lucide-react";
|
||||
import { PenSquare, Home, LayoutGrid } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
@@ -10,7 +10,7 @@ function boardHref(id: number) {
|
||||
return `/?board=${id}`;
|
||||
}
|
||||
|
||||
// 桌面左栏:板块导航
|
||||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||||
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
@@ -18,57 +18,57 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
|
||||
return (
|
||||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||||
<nav
|
||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] j13-rail-scroll"
|
||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
|
||||
aria-label="工作台导航"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
|
||||
<div className="space-y-0.5">
|
||||
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
|
||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
|
||||
<p className="j13-module-title shrink-0">
|
||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
板块目录
|
||||
{boards.length > 0 ? (
|
||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||
{boards.length} 个
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Home size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate text-[14px]">
|
||||
全部讨论
|
||||
<Home size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 板块目录:纯分区标题;下方卡片仅在中栏筛选该板帖子流 */}
|
||||
<div>
|
||||
<div className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)]">
|
||||
<span>板块目录</span>
|
||||
{boards.length > 0 && (
|
||||
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate text-[14px]">
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@ import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { href?: string; label: string };
|
||||
|
||||
/** 位置指引:桌面叠在中栏卡片上方的内边距里,不拉低卡片、不对齐到左栏 */
|
||||
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
|
||||
/** 位置指引:嵌在正文面板顶部,不单独成块 */
|
||||
export default function SiteDocBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: Crumb[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className="meta mb-2 flex items-center gap-1 flex-wrap text-[12.5px] leading-none lg:absolute lg:bottom-full lg:left-0 lg:right-0 lg:mb-0 lg:pb-2 lg:pl-1"
|
||||
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
|
||||
aria-label="面包屑"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function SiteDocShell({
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 中栏卡片上方的位置指引;桌面绝对定位,不占栅格高度 */
|
||||
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
|
||||
breadcrumb?: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
@@ -29,7 +29,7 @@ export default function SiteDocShell({
|
||||
{left}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 relative">
|
||||
<div className="min-w-0">
|
||||
{breadcrumb}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -677,10 +677,14 @@ export interface PublicSettings {
|
||||
attachment_max_count: number;
|
||||
/** 正文插图上限 MB;缺省 5 */
|
||||
image_max_mb: number;
|
||||
/** 全站自定义 CSS;空串=无 */
|
||||
custom_css: string;
|
||||
/** 全站自定义 JS;空串=无(保存后整页加载生效) */
|
||||
custom_js: string;
|
||||
/** 前台背景图 URL;空=默认画布 */
|
||||
bg_site_url: string;
|
||||
/** 前台铺放模式 */
|
||||
bg_site_mode: string;
|
||||
/** 后台背景图 URL;空=默认画布 */
|
||||
bg_admin_url: string;
|
||||
/** 后台铺放模式 */
|
||||
bg_admin_mode: string;
|
||||
}
|
||||
|
||||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||||
@@ -715,8 +719,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
attachment_max_mb: 20,
|
||||
attachment_max_count: 10,
|
||||
image_max_mb: 5,
|
||||
custom_css: "",
|
||||
custom_js: "",
|
||||
bg_site_url: "",
|
||||
bg_site_mode: "cover",
|
||||
bg_admin_url: "",
|
||||
bg_admin_mode: "cover",
|
||||
};
|
||||
|
||||
function normalizeAttachmentExts(raw: unknown): string[] {
|
||||
@@ -786,8 +792,10 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
attachment_max_mb: maxMB,
|
||||
attachment_max_count: maxCount,
|
||||
image_max_mb: imageMB,
|
||||
custom_css: typeof data?.custom_css === "string" ? data.custom_css : "",
|
||||
custom_js: typeof data?.custom_js === "string" ? data.custom_js : "",
|
||||
bg_site_url: typeof data?.bg_site_url === "string" ? data.bg_site_url : "",
|
||||
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
|
||||
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
|
||||
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1893,6 +1901,8 @@ export interface AdminUsersQuery {
|
||||
q?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
|
||||
@@ -1902,6 +1912,9 @@ function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
|
||||
if (query.q) params.set("q", query.q);
|
||||
if (query.role) params.set("role", query.role);
|
||||
if (query.status) params.set("status", query.status);
|
||||
if (query.sort && query.sort !== "id") params.set("sort", query.sort);
|
||||
if (query.order === "asc") params.set("order", "asc");
|
||||
else if (query.sort && query.sort !== "id") params.set("order", query.order || "desc");
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -2144,8 +2157,10 @@ export type UpdateSiteSettingsBody = {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
custom_css?: string;
|
||||
custom_js?: string;
|
||||
bg_site_url?: string;
|
||||
bg_site_mode?: string;
|
||||
bg_admin_url?: string;
|
||||
bg_admin_mode?: string;
|
||||
timeline_git_import?: string;
|
||||
};
|
||||
|
||||
@@ -2161,6 +2176,24 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
|
||||
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||
}
|
||||
|
||||
/** 上传站点背景并立即绑定前台或后台 */
|
||||
export async function apiUploadSiteBackground(
|
||||
surface: "site" | "admin",
|
||||
file: File
|
||||
): Promise<PublicSettings> {
|
||||
const form = new FormData();
|
||||
form.append("surface", surface);
|
||||
form.append("file", file, file.name || "background.jpg");
|
||||
const res = await fetchWithRefresh("/api/admin/upload/background", {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "上传背景图失败");
|
||||
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||
}
|
||||
|
||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||
|
||||
export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
||||
|
||||
173
frontend/lib/bg.ts
Normal file
173
frontend/lib/bg.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// 站点背景图(前台 / 后台分轨):仅接受本站 /uploads/backgrounds/ 下的文件。
|
||||
// 铺放模式映射到 CSS background-*,真正绘制在 body::before 固定层上,
|
||||
// 避免 background-attachment:fixed 在移动端掉帧。
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type BgSurface = "site" | "admin";
|
||||
|
||||
export type BgMode =
|
||||
| "cover"
|
||||
| "contain"
|
||||
| "repeat"
|
||||
| "repeat-x"
|
||||
| "repeat-y"
|
||||
| "stretch"
|
||||
| "center";
|
||||
|
||||
export const DEFAULT_BG_MODE: BgMode = "cover";
|
||||
|
||||
export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [
|
||||
{ key: "cover", label: "覆盖铺满", hint: "裁切以铺满视口" },
|
||||
{ key: "contain", label: "完整显示", hint: "完整放入,两侧留底色" },
|
||||
{ key: "stretch", label: "拉伸填满", hint: "变形拉伸至视口" },
|
||||
{ key: "repeat", label: "平铺", hint: "按原尺寸重复铺满" },
|
||||
{ key: "repeat-x", label: "横向平铺", hint: "只沿水平方向重复" },
|
||||
{ key: "repeat-y", label: "纵向平铺", hint: "只沿垂直方向重复" },
|
||||
{ key: "center", label: "居中原图", hint: "不缩放,居中显示" },
|
||||
];
|
||||
|
||||
export interface SiteBgConfig {
|
||||
bg_site_url: string;
|
||||
bg_site_mode: BgMode;
|
||||
bg_admin_url: string;
|
||||
bg_admin_mode: BgMode;
|
||||
}
|
||||
|
||||
export type SiteBgInput = {
|
||||
bg_site_url?: string | null;
|
||||
bg_site_mode?: string | null;
|
||||
bg_admin_url?: string | null;
|
||||
bg_admin_mode?: string | null;
|
||||
};
|
||||
|
||||
const BG_URL_RE = /^\/uploads\/backgrounds\/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$/;
|
||||
const MODE_SET = new Set<BgMode>(BG_MODE_OPTIONS.map((o) => o.key));
|
||||
|
||||
const SSR_STYLE_ID = "j13-bg-vars";
|
||||
const RUNTIME_STYLE_ID = "j13-bg-runtime";
|
||||
|
||||
export function normalizeBgURL(raw: string | null | undefined): string {
|
||||
const u = (raw || "").trim().toLowerCase();
|
||||
return BG_URL_RE.test(u) ? u : "";
|
||||
}
|
||||
|
||||
export function normalizeBgMode(raw: string | null | undefined): BgMode {
|
||||
const m = (raw || "").trim().toLowerCase() as BgMode;
|
||||
return MODE_SET.has(m) ? m : DEFAULT_BG_MODE;
|
||||
}
|
||||
|
||||
export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig {
|
||||
return {
|
||||
bg_site_url: normalizeBgURL(s?.bg_site_url),
|
||||
bg_site_mode: normalizeBgMode(s?.bg_site_mode),
|
||||
bg_admin_url: normalizeBgURL(s?.bg_admin_url),
|
||||
bg_admin_mode: normalizeBgMode(s?.bg_admin_mode),
|
||||
};
|
||||
}
|
||||
|
||||
function modeCSS(mode: BgMode): { size: string; repeat: string; position: string } {
|
||||
switch (mode) {
|
||||
case "contain":
|
||||
return { size: "contain", repeat: "no-repeat", position: "center" };
|
||||
case "repeat":
|
||||
return { size: "auto", repeat: "repeat", position: "0 0" };
|
||||
case "repeat-x":
|
||||
return { size: "auto", repeat: "repeat-x", position: "center top" };
|
||||
case "repeat-y":
|
||||
return { size: "auto", repeat: "repeat-y", position: "center top" };
|
||||
case "stretch":
|
||||
return { size: "100% 100%", repeat: "no-repeat", position: "center" };
|
||||
case "center":
|
||||
return { size: "auto", repeat: "no-repeat", position: "center" };
|
||||
default:
|
||||
return { size: "cover", repeat: "no-repeat", position: "center" };
|
||||
}
|
||||
}
|
||||
|
||||
function cssUrl(url: string): string {
|
||||
// 已由 regex 锁死路径,仍去掉引号,避免意外打断 url("…")
|
||||
return `url("${url.replace(/["'\\]/g, "")}")`;
|
||||
}
|
||||
|
||||
function surfaceVars(prefix: "site" | "admin", url: string, mode: BgMode): string {
|
||||
const m = modeCSS(mode);
|
||||
const image = url ? cssUrl(url) : "none";
|
||||
return [
|
||||
`--j13-bg-${prefix}-image:${image};`,
|
||||
`--j13-bg-${prefix}-size:${m.size};`,
|
||||
`--j13-bg-${prefix}-repeat:${m.repeat};`,
|
||||
`--j13-bg-${prefix}-position:${m.position};`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
/** SSR 内联:仅在至少一侧配置了图时输出 */
|
||||
export function bgStyleCSS(cfg: SiteBgConfig): string {
|
||||
if (!cfg.bg_site_url && !cfg.bg_admin_url) return "";
|
||||
return (
|
||||
"html{" +
|
||||
surfaceVars("site", cfg.bg_site_url, cfg.bg_site_mode) +
|
||||
surfaceVars("admin", cfg.bg_admin_url, cfg.bg_admin_mode) +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureRuntimeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (el) return el;
|
||||
el = document.createElement("style");
|
||||
el.id = RUNTIME_STYLE_ID;
|
||||
el.setAttribute("data-bg-source", "runtime");
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function sameConfig(a: SiteBgConfig, b: SiteBgConfig): boolean {
|
||||
return (
|
||||
a.bg_site_url === b.bg_site_url &&
|
||||
a.bg_site_mode === b.bg_site_mode &&
|
||||
a.bg_admin_url === b.bg_admin_url &&
|
||||
a.bg_admin_mode === b.bg_admin_mode
|
||||
);
|
||||
}
|
||||
|
||||
let lastApplied: SiteBgConfig | null = null;
|
||||
|
||||
/** 客户端热替换背景;禁止改写 SSR 节点 */
|
||||
export function applySiteBackground(input: SiteBgInput | null): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const cfg = bgConfigFromSettings(input);
|
||||
if (lastApplied && sameConfig(lastApplied, cfg)) return;
|
||||
lastApplied = cfg;
|
||||
|
||||
const html = document.documentElement;
|
||||
html.classList.toggle("j13-has-site-bg", !!cfg.bg_site_url);
|
||||
html.classList.toggle("j13-has-admin-bg", !!cfg.bg_admin_url);
|
||||
|
||||
const css = bgStyleCSS(cfg);
|
||||
if (!css) {
|
||||
const ssr = document.getElementById(SSR_STYLE_ID);
|
||||
if (ssr && ssr.textContent && ssr.textContent.trim()) {
|
||||
ensureRuntimeStyle().textContent =
|
||||
"html{" +
|
||||
surfaceVars("site", "", DEFAULT_BG_MODE) +
|
||||
surfaceVars("admin", "", DEFAULT_BG_MODE) +
|
||||
"}";
|
||||
} else {
|
||||
document.getElementById(RUNTIME_STYLE_ID)?.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ensureRuntimeStyle().textContent = css;
|
||||
}
|
||||
|
||||
export function previewBoxStyle(url: string, mode: BgMode): CSSProperties {
|
||||
const m = modeCSS(mode);
|
||||
return {
|
||||
backgroundColor: "var(--bg)",
|
||||
backgroundImage: url ? cssUrl(url) : "none",
|
||||
backgroundSize: m.size,
|
||||
backgroundRepeat: m.repeat,
|
||||
backgroundPosition: m.position,
|
||||
};
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// 全站自定义 CSS:SSR <style id="j13-custom-css"> + 客户端 #j13-custom-css-runtime。
|
||||
// 禁止对 SSR 节点 remove/改写;预览时 disable SSR,由 runtime 作为唯一源。
|
||||
|
||||
export const MAX_CUSTOM_CSS_CHARS = 48000;
|
||||
export const MAX_CUSTOM_JS_CHARS = 48000;
|
||||
|
||||
/** 空配置时展示的注释模板:保存前不生效,全部注释故误保存也无副作用 */
|
||||
export const SAMPLE_CUSTOM_CSS = `/* 姜十三 · 自定义 CSS 示例
|
||||
* 覆盖 globals.css 令牌或组件类。保存后对全站访客生效。
|
||||
* 勿写入 </style>
|
||||
*
|
||||
* 常用令牌:
|
||||
* --bg / --panel / --ink / --accent
|
||||
* --bg-halo 顶部光晕色(建议 ≤ 0.04)
|
||||
* --bg-noise-opacity 噪点层透明度
|
||||
* --r / --shadow-sm 卡片圆角与阴影
|
||||
*/
|
||||
|
||||
/* 改底色与光晕(浅 / 暗成对改)
|
||||
:root {
|
||||
--bg: #f7f7f5;
|
||||
--bg-halo: rgba(7, 192, 95, 0.04);
|
||||
--bg-noise-opacity: 0.03;
|
||||
--panel: #ffffff;
|
||||
--r: 12px;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.dark {
|
||||
--bg: #101211;
|
||||
--bg-halo: rgba(7, 192, 95, 0.03);
|
||||
--bg-noise-opacity: 0.04;
|
||||
}
|
||||
*/
|
||||
|
||||
/* 关掉光晕和噪点
|
||||
body::before,
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
|
||||
/* 隐藏页脚
|
||||
footer { display: none; }
|
||||
*/
|
||||
`;
|
||||
|
||||
export const SAMPLE_CUSTOM_JS = `// 姜十三 · 自定义 JS 示例
|
||||
// 仅整页加载执行一次(F5 / 首次进入),站内软导航不会重跑。
|
||||
// 勿写入 </script>
|
||||
//
|
||||
// 给 <html> 打标记,便于 CSS 写 html[data-custom] .panel { ... }
|
||||
// document.documentElement.dataset.custom = "1";
|
||||
//
|
||||
// 等 DOM 就绪后再改节点
|
||||
// document.addEventListener("DOMContentLoaded", function () {
|
||||
// // document.querySelector("footer")?.remove();
|
||||
// });
|
||||
`;
|
||||
|
||||
const SSR_STYLE_ID = "j13-custom-css";
|
||||
const RUNTIME_STYLE_ID = "j13-custom-css-runtime";
|
||||
|
||||
export function clipCustomSnippet(raw: string, max: number): string {
|
||||
const s = (raw || "").replace(/\u0000/g, "");
|
||||
if (s.length <= max) return s;
|
||||
return [...s].slice(0, max).join("");
|
||||
}
|
||||
|
||||
function ensureRuntimeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (el) return el;
|
||||
el = document.createElement("style");
|
||||
el.id = RUNTIME_STYLE_ID;
|
||||
el.setAttribute("data-custom-css-source", "runtime");
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function removeRuntimeStyle(): void {
|
||||
document.getElementById(RUNTIME_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
function ssrStyle(): HTMLStyleElement | null {
|
||||
return document.getElementById(SSR_STYLE_ID) as HTMLStyleElement | null;
|
||||
}
|
||||
|
||||
/** 将自定义 CSS 应用到当前文档。preview=true 时忽略 SSR 节点(外观页草稿)。 */
|
||||
export function applyCustomCSS(css: string, preview = false): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const text = clipCustomSnippet(css, MAX_CUSTOM_CSS_CHARS).trim();
|
||||
const ssr = ssrStyle();
|
||||
if (ssr) ssr.disabled = preview;
|
||||
|
||||
if (!text) {
|
||||
removeRuntimeStyle();
|
||||
if (!preview && ssr) ssr.disabled = false;
|
||||
return;
|
||||
}
|
||||
ensureRuntimeStyle().textContent = text;
|
||||
}
|
||||
@@ -12,9 +12,13 @@ export type HideSegment =
|
||||
body: string;
|
||||
};
|
||||
|
||||
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
|
||||
const HIDE_KINDS = new Set<string>(["login", "reply", "points", "password"]);
|
||||
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
|
||||
|
||||
function isHideKind(s: string): s is HideKind {
|
||||
return HIDE_KINDS.has(s);
|
||||
}
|
||||
|
||||
function isClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/hide]";
|
||||
}
|
||||
@@ -40,8 +44,8 @@ function parseOpen(trimmed: string): {
|
||||
kind = head.slice(0, eq);
|
||||
arg = head.slice(eq + 1);
|
||||
}
|
||||
kind = kind.toLowerCase() as HideKind;
|
||||
if (!HIDE_KINDS.has(kind)) return null;
|
||||
kind = kind.toLowerCase();
|
||||
if (!isHideKind(kind)) return null;
|
||||
|
||||
let idx = 1;
|
||||
let points = 0;
|
||||
|
||||
@@ -59,8 +59,10 @@ export interface RtSettingsData {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
custom_css?: string;
|
||||
custom_js?: string;
|
||||
bg_site_url?: string;
|
||||
bg_site_mode?: string;
|
||||
bg_admin_url?: string;
|
||||
bg_admin_mode?: string;
|
||||
}
|
||||
|
||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||
|
||||
@@ -157,7 +157,7 @@ function clampForLight(hex: string): string {
|
||||
function deriveDark(hex: string): string {
|
||||
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
|
||||
let l = Math.max(lRaw, 0.62);
|
||||
const surfaces = ["#181b21", "#101211", "#10162b"];
|
||||
const surfaces = ["#181b21", "#0c0d10", "#10162b"];
|
||||
let cur = hex;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
cur = rgbToHex(hslToRgb([h, s, l]));
|
||||
@@ -207,7 +207,7 @@ function buildAccentCSS(input: string): string {
|
||||
`--accent-hi:${hi};`,
|
||||
`--accent-soft:${rgba(light, 0.1)};`,
|
||||
`--accent-on:#ffffff;`,
|
||||
`--nav-active-bg:var(--panel);`,
|
||||
`--nav-active-bg:${rgba(light, 0.1)};`,
|
||||
`--nav-active-ink:${light};`,
|
||||
`--nav-active-line:${rgba(light, 0.38)};`,
|
||||
`}`,
|
||||
@@ -216,7 +216,7 @@ function buildAccentCSS(input: string): string {
|
||||
`--accent-hi:${darkHi};`,
|
||||
`--accent-soft:${rgba(dark, 0.16)};`,
|
||||
`--accent-on:#10162b;`,
|
||||
`--nav-active-bg:var(--panel);`,
|
||||
`--nav-active-bg:${rgba(dark, 0.14)};`,
|
||||
`--nav-active-ink:${darkHi};`,
|
||||
`--nav-active-line:${rgba(dark, 0.38)};`,
|
||||
`}`,
|
||||
|
||||
@@ -183,6 +183,6 @@ export async function middleware(req: NextRequest) {
|
||||
export const config = {
|
||||
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
|
||||
matcher: [
|
||||
"/((?!api/|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
|
||||
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ const API_TARGET =
|
||||
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。
|
||||
output: "standalone",
|
||||
images: {
|
||||
minimumCacheTTL: 14400, // 4 小时
|
||||
remotePatterns: [
|
||||
|
||||
0
frontend/public/.gitkeep
Normal file
0
frontend/public/.gitkeep
Normal file
Reference in New Issue
Block a user