chore: 去掉未上线前的旧兼容垫片,板块流收口到首页
鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
|
4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
|
||||||
5. 权限逻辑只在 Go 端,前端不做权限判定
|
5. 权限逻辑只在 Go 端,前端不做权限判定
|
||||||
6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
|
6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
|
||||||
|
7. 用户未明确告知「已正式部署到生产环境」之前:禁止为旧版本/旧契约写兼容冗余(双写双读、fallback、过渡字段、旧 cookie/旧 API 垫片);直接改到位并删旧代码,保持源码干净规范
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400
|
- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400
|
||||||
- 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列
|
- 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列
|
||||||
- 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400
|
- 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400
|
||||||
- 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(30s 角标轮询仅入口可见时);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...]
|
- 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(待审角标 WS `moderation:changed` + 回前台 HTTP 校准,无定时轮询);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...]
|
||||||
- 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组
|
- 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,12 +83,12 @@
|
|||||||
**不使用 localStorage 存储 token**(XSS 可窃取),采用三 Cookie + Token Version 方案:
|
**不使用 localStorage 存储 token**(XSS 可窃取),采用三 Cookie + Token Version 方案:
|
||||||
|
|
||||||
- `j13_token`:access token JWT,`HttpOnly + SameSite=Lax`,**15 分钟**过期,JS 不可读
|
- `j13_token`:access token JWT,`HttpOnly + SameSite=Lax`,**15 分钟**过期,JS 不可读
|
||||||
- `j13_refresh`:refresh token(DB 存储),`HttpOnly + SameSite=Lax`,`Path=/`(2026-09 起从 /api/auth 放宽,供 Next middleware 在页面请求中续期;clearAuthCookies 同时清 / 与 /api/auth 兼容旧 cookie),**7 天**过期
|
- `j13_refresh`:refresh token(DB 存储),`HttpOnly + SameSite=Lax`,`Path=/`(供 Next middleware 在页面请求中续期),**7 天**过期
|
||||||
- `j13_csrf`:CSRF token,非 HttpOnly(JS 可读),`SameSite=Lax`,7 天过期
|
- `j13_csrf`:CSRF token,非 HttpOnly(JS 可读),`SameSite=Lax`,7 天过期
|
||||||
|
|
||||||
**后端**(`service/auth.go`、`middleware/auth.go`、`middleware/csrf.go`、`middleware/security.go`):
|
**后端**(`service/auth.go`、`middleware/auth.go`、`middleware/csrf.go`、`middleware/security.go`):
|
||||||
- `User.TokenVersion` 字段:改密码/封禁/管理员强制下线时递增,使所有 JWT 立即失效
|
- `User.TokenVersion` 字段:改密码/封禁/管理员强制下线时递增,使所有 JWT 立即失效
|
||||||
- `parseToken` 优先从 `j13_token` cookie 读取,回退到 Authorization header
|
- `parseToken` 只从 `j13_token` cookie 读取(不接受 Authorization Bearer)
|
||||||
- `ValidateClaims` 每请求查 DB:校验 `token_version` 匹配 + `banned` 状态(不依赖 JWT 缓存值)
|
- `ValidateClaims` 每请求查 DB:校验 `token_version` 匹配 + `banned` 状态(不依赖 JWT 缓存值)
|
||||||
- `CSRFMiddleware` 对非 GET 方法校验 `X-CSRF-Token` header 与 `j13_csrf` cookie 一致
|
- `CSRFMiddleware` 对非 GET 方法校验 `X-CSRF-Token` header 与 `j13_csrf` cookie 一致
|
||||||
- 需登录 API 组挂载顺序:`RequireAuth → CSRFMiddleware`(未登录返回 401 而非 403)
|
- 需登录 API 组挂载顺序:`RequireAuth → CSRFMiddleware`(未登录返回 401 而非 403)
|
||||||
@@ -124,7 +125,7 @@
|
|||||||
- 三期(已完成 2026-09-15):帖子流「有新内容」提示 —— `feed:changed` 全员广播(staff 直发/审核通过帖评),首页与板块页 tip,点击 `router.refresh()`;不做自动插行/游客轮询
|
- 三期(已完成 2026-09-15):帖子流「有新内容」提示 —— `feed:changed` 全员广播(staff 直发/审核通过帖评),首页与板块页 tip,点击 `router.refresh()`;不做自动插行/游客轮询
|
||||||
|
|
||||||
|
|
||||||
- 端点 `GET /api/ws`(gorilla/websocket v1.5.3,挂在 r.Static 之后、pubAPI 之前,不走任何中间件组,鉴权在处理器内):cookie j13_token 优先、Bearer 回退,ParseToken+ValidateClaims 双校验失败 401 JSON;checkWSOrigin:无 Origin 放行,dev 允许 localhost/127.0.0.1 任意端口,生产要求 Origin host==Host
|
- 端点 `GET /api/ws`(gorilla/websocket v1.5.3,挂在 r.Static 之后、pubAPI 之前,不走任何中间件组,鉴权在处理器内):只读 cookie j13_token,ParseToken+ValidateClaims 双校验失败 401 JSON;checkWSOrigin:**必须带 Origin**,dev 允许 localhost/127.0.0.1 任意端口(页面 :3000 直连 :3001),生产要求 Origin host==Host(须反代同源)
|
||||||
- 代码:`backend/realtime/hub.go`(Hub:clients/rooms/userConnCnt 多标签页计数;register 0→1 才上线广播,最后一条连接断开才下线广播;房间 staff、个人房 user:{id};dispatch 满缓冲 forceClose 慢消费者;OnlineUserIDs 快照)、`backend/realtime/client.go`(writePump 唯一写连接:30s 协议 Ping、70s pongWait、send buf 32;readPump 只认 {"type":"ping"} 应用帧,每次心跳复查 ValidateClaims+TouchLastSeen(60s SQL 限频),失败写 **关闭码 4401**)、`handler/realtime.go`;Handlers.Hub 在 router.NewHub()
|
- 代码:`backend/realtime/hub.go`(Hub:clients/rooms/userConnCnt 多标签页计数;register 0→1 才上线广播,最后一条连接断开才下线广播;房间 staff、个人房 user:{id};dispatch 满缓冲 forceClose 慢消费者;OnlineUserIDs 快照)、`backend/realtime/client.go`(writePump 唯一写连接:30s 协议 Ping、70s pongWait、send buf 32;readPump 只认 {"type":"ping"} 应用帧,每次心跳复查 ValidateClaims+TouchLastSeen(60s SQL 限频),失败写 **关闭码 4401**)、`handler/realtime.go`;Handlers.Hub 在 router.NewHub()
|
||||||
- 信封单层 JSON `{type,data}`(encoding/json/v2);事件:`hello`(staff 的 data 带 online_user_ids 在线快照)、`pong`、`settings:changed`{accent}(BroadcastAll)、`presence:update`{user_id,online}(只 BroadcastRoom staff);UpdateSettings 保存后广播,accent 为归一化小写 trim 值
|
- 信封单层 JSON `{type,data}`(encoding/json/v2);事件:`hello`(staff 的 data 带 online_user_ids 在线快照)、`pong`、`settings:changed`{accent}(BroadcastAll)、`presence:update`{user_id,online}(只 BroadcastRoom staff);UpdateSettings 保存后广播,accent 为归一化小写 trim 值
|
||||||
- **在线判定新口径:有活跃 WS 即在线**(取代 last_seen_at 5 分钟启发式,更精确;用户管理页绿点由 presence 推送+hello 快照 reconcile,SSR initial.online 仍走旧口径)
|
- **在线判定新口径:有活跃 WS 即在线**(取代 last_seen_at 5 分钟启发式,更精确;用户管理页绿点由 presence 推送+hello 快照 reconcile,SSR initial.online 仍走旧口径)
|
||||||
@@ -219,6 +220,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Hard Constraints
|
## Hard Constraints
|
||||||
|
- 未正式上线前禁止旧兼容冗余:用户明确说「已正式部署到生产环境」之前,改契约直接改到位并删旧路径,禁止双写/双读/fallback/过渡字段等垫片
|
||||||
- Main comments count as floors; sub-comments do not count as floors
|
- Main comments count as floors; sub-comments do not count as floors
|
||||||
- Comment depth is limited to 10 levels
|
- Comment depth is limited to 10 levels
|
||||||
- 帖子详情页三栏宽度与首页统一,容器同为 max-w-[1440px]、间距同为 gap-6 / xl:gap-7
|
- 帖子详情页三栏宽度与首页统一,容器同为 max-w-[1440px]、间距同为 gap-6 / xl:gap-7
|
||||||
@@ -248,7 +250,7 @@
|
|||||||
- 管理员侧边栏导航在 `app/admin/AdminNav.tsx` 中维护,新增模块通过修改 `NAV_ITEMS` 实现
|
- 管理员侧边栏导航在 `app/admin/AdminNav.tsx` 中维护,新增模块通过修改 `NAV_ITEMS` 实现
|
||||||
- 用户管理列表支持分页(默认 20,上限 50)、用户名/昵称/邮箱 ILIKE 搜索、角色与状态筛选
|
- 用户管理列表支持分页(默认 20,上限 50)、用户名/昵称/邮箱 ILIKE 搜索、角色与状态筛选
|
||||||
- 用户管理操作后自动重新拉取数据以保证计数一致性;查询状态同步到 URL 参数(`?q=&filter=&page=`)
|
- 用户管理操作后自动重新拉取数据以保证计数一致性;查询状态同步到 URL 参数(`?q=&filter=&page=`)
|
||||||
- 管理员 API 路由需依次经过 RequireAuth → CSRF → RequireAdmin 中间件
|
- 管理员 API 路由经 RequireStaff → CSRF,功能点再由 RequirePerm 放行
|
||||||
- 用户管理列表展示自己账号时仅显示"当前账号"标识,无操作按钮
|
- 用户管理列表展示自己账号时仅显示"当前账号"标识,无操作按钮
|
||||||
- 危险操作(如封禁、降级)需显示明确的 confirm 提示文案
|
- 危险操作(如封禁、降级)需显示明确的 confirm 提示文案
|
||||||
- 移动端用户管理列表使用卡片布局,桌面端使用七列表格布局
|
- 移动端用户管理列表使用卡片布局,桌面端使用七列表格布局
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ alwaysApply: true
|
|||||||
## WebSocket(全站一条连接)
|
## WebSocket(全站一条连接)
|
||||||
|
|
||||||
- `GET /api/ws`;dev 直连 `ws://hostname:3001/api/ws`(Next rewrite 不透传 Upgrade)
|
- `GET /api/ws`;dev 直连 `ws://hostname:3001/api/ws`(Next rewrite 不透传 Upgrade)
|
||||||
|
- Origin **必须存在**;dev 允许 localhost/127.0.0.1 任意端口;生产要求 Origin host == 请求 Host(页面与 `/api/ws` 须同一 Host)
|
||||||
- 事件:`hello` / `pong` / `settings:changed` / `presence:update` / `chat:*` / `notification:new` / `feed:changed` / `moderation:changed`
|
- 事件:`hello` / `pong` / `settings:changed` / `presence:update` / `chat:*` / `notification:new` / `feed:changed` / `moderation:changed`
|
||||||
- 消息铁律:**先落库再 WS 广播**;WS 不可靠投递;未读靠 HTTP 对账
|
- 消息铁律:**先落库再 WS 广播**;WS 不可靠投递;未读靠 HTTP 对账
|
||||||
- 角标优先推送:通知落库统一 `OnNotifyNew`;待审变化推 staff `moderation:changed`;**禁止**再恢复 30s 轮询未读/待审
|
- 角标优先推送:通知落库统一 `OnNotifyNew`;待审变化推 staff `moderation:changed`;**禁止**再恢复 30s 轮询未读/待审
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ alwaysApply: true
|
|||||||
4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
|
4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
|
||||||
5. 权限逻辑只在 Go 端,前端不做权限判定
|
5. 权限逻辑只在 Go 端,前端不做权限判定
|
||||||
6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
|
6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
|
||||||
|
7. 用户未明确告知「已正式部署到生产环境」之前:禁止为旧版本/旧契约写兼容冗余(双写双读、fallback、过渡字段、旧 cookie/旧 API 垫片);直接改到位并删旧代码,保持源码干净规范
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Hard Constraints
|
## Hard Constraints
|
||||||
|
|
||||||
|
- **未正式上线前禁止旧兼容冗余**:在用户明确告知「已正式部署到生产环境」之前,改 API / cookie / schema / 前端契约时直接改到位并删旧路径;禁止双写、双读、fallback、过渡字段、兼容旧 cookie Path、兼容旧客户端等垫片。源码保持干净、规范;生产上线后再谈兼容。
|
||||||
- 主评论计楼层,子评论不计;评论深度上限 10
|
- 主评论计楼层,子评论不计;评论深度上限 10
|
||||||
- 详情页三栏宽度与首页统一:`max-w-[1440px]`、`gap-6` / `xl:gap-7`
|
- 详情页三栏宽度与首页统一:`max-w-[1440px]`、`gap-6` / `xl:gap-7`
|
||||||
- 禁止操作自己的管理员账号;至少保留一名未封禁管理员;角色白名单
|
- 禁止操作自己的管理员账号;至少保留一名未封禁管理员;角色白名单
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -40,7 +40,7 @@
|
|||||||
### 通知
|
### 通知
|
||||||
|
|
||||||
- 站内通知:评论你的帖子、回复你的评论、点赞你的帖子
|
- 站内通知:评论你的帖子、回复你的评论、点赞你的帖子
|
||||||
- 未读红点 SSR 直出 + 30s 轮询校正
|
- 未读红点 SSR 直出,实时靠 WebSocket 推送,回前台时 HTTP 校准
|
||||||
- 单条标记已读 / 全部已读
|
- 单条标记已读 / 全部已读
|
||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
- 全站 SSR/ISR,SEO 关键内容在初始 HTML 中
|
- 全站 SSR/ISR,SEO 关键内容在初始 HTML 中
|
||||||
- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD
|
- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD
|
||||||
- 动态 `sitemap.ts` + `robots.ts`
|
- 动态 `sitemap.ts` + `robots.ts`(由 Next.js 对外提供,后端不重复挂 `/sitemap.xml` / `/robots.txt`)
|
||||||
|
|
||||||
### 安全
|
### 安全
|
||||||
|
|
||||||
@@ -191,6 +191,8 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
|
|||||||
|
|
||||||
生产 HTTPS 下 cookie 名自动启用 `__Host-` 前缀。
|
生产 HTTPS 下 cookie 名自动启用 `__Host-` 前缀。
|
||||||
|
|
||||||
|
若本机曾跑过早期构建,浏览器里可能还留着 `Path=/api/auth` 的 `j13_refresh`(HttpOnly,页面脚本删不掉)。打开开发者工具 → Application → Cookies,删掉该条,或对该源执行「清除站点数据」。之后只应存在 `Path=/` 的三枚 cookie。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
@@ -201,6 +203,25 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
|
|||||||
2. **前端:** 设置 `BACKEND_URL` 为后端 HTTPS 地址,`npm run build` 后部署。Cloudflare Workers 部署需保持 `middleware.ts` 文件名不变(OpenNext 不识别 `proxy.ts`)。
|
2. **前端:** 设置 `BACKEND_URL` 为后端 HTTPS 地址,`npm run build` 后部署。Cloudflare Workers 部署需保持 `middleware.ts` 文件名不变(OpenNext 不识别 `proxy.ts`)。
|
||||||
3. **数据库:** 修改默认密码,启用 SSL 连接。
|
3. **数据库:** 修改默认密码,启用 SSL 连接。
|
||||||
4. **CORS:** 生产环境修改 `router.go` 中的 `AllowOrigins` 为实际域名。
|
4. **CORS:** 生产环境修改 `router.go` 中的 `AllowOrigins` 为实际域名。
|
||||||
|
5. **WebSocket:** 必须与页面同一 Host(见下节),不要把 API 单独放到另一个子域。
|
||||||
|
|
||||||
|
### WebSocket(开发 vs 生产)
|
||||||
|
|
||||||
|
浏览器发起 WebSocket 握手**一定会带 `Origin`**。后端拒绝空 Origin,这只挡住 curl / 脚本探测,**不影响 `npm run dev`,也不影响生产浏览器**。
|
||||||
|
|
||||||
|
**开发环境(`DEV_MODE=true`)**
|
||||||
|
|
||||||
|
- 页面在 `http://localhost:3000`,WS 直连 `ws://localhost:3001/api/ws`(Next.js rewrite 不透传 `Upgrade`)。
|
||||||
|
- 二者端口不同,不是浏览器意义上的同源。后端因此放行 Origin 主机为 `localhost` / `127.0.0.1` 的**任意端口**。
|
||||||
|
- Cookie 不按端口隔离,`:3000` 上的 `j13_token` 会随握手带到 `:3001`。不要把 `DEV_MODE` 改成 `false` 再跑这对端口,否则 Origin `localhost:3000` 对不上 Host `localhost:3001`,握手会被拒。
|
||||||
|
|
||||||
|
**生产环境(`DEV_MODE=false`)**
|
||||||
|
|
||||||
|
- Origin 的 host(含端口)必须与请求 `Host` **完全一致**。请用 Nginx/Caddy 把前端与 `/api/`、`/api/ws` 挂到同一域名,例如 `https://bbs.example.com`。
|
||||||
|
- 反向代理需透传 `Upgrade`、`Connection`,并保留原始 `Host` / `Origin`。
|
||||||
|
- 生产 cookie 使用 `__Host-` 前缀,要求 `Secure`、`Path=/`、**不能设 Domain**。因此 API **不能**放到 `api.example.com` 这类另一子域,否则登录 cookie 与 WS 都会失效。
|
||||||
|
- Cloudflare Workers / OpenNext **不能**透传任意 WebSocket。若前端部署在 Workers,实时通道必须由同源反代(Nginx 等)承接 `/api/ws`,而不是指望 Workers 升级连接。
|
||||||
|
- 可用 `NEXT_PUBLIC_WS_URL` 覆盖前端连线地址;一旦覆盖,Origin 仍须与后端看到的 Host 一致。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (h *Handlers) AnnouncementDetail(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 管理接口(RequireAdmin 兜底,前端不做权限判定) =====
|
// ===== 管理接口(RequireStaff + PermAnnouncements,前端不做权限判定) =====
|
||||||
|
|
||||||
// AdminListAnnouncements 全部公告(含草稿)
|
// AdminListAnnouncements 全部公告(含草稿)
|
||||||
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
|
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
|
||||||
|
|||||||
@@ -57,34 +57,23 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearAuthCookies 清除所有认证 cookie。
|
// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。
|
||||||
// 除当前名称外,还需兼容清除:
|
|
||||||
// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/)
|
|
||||||
// - 更早期 Path=/api/auth 的旧 refresh cookie
|
|
||||||
func clearAuthCookies(c *gin.Context, secure bool) {
|
func clearAuthCookies(c *gin.Context, secure bool) {
|
||||||
cookies := []struct{ name, path string }{
|
cookies := []struct {
|
||||||
{service.CookieName, "/"},
|
name string
|
||||||
{service.RefreshCookieName, "/"},
|
httpOnly bool
|
||||||
{service.CSRFCookieName, "/"},
|
}{
|
||||||
|
{service.CookieName, true},
|
||||||
|
{service.RefreshCookieName, true},
|
||||||
|
{service.CSRFCookieName, false},
|
||||||
}
|
}
|
||||||
// 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中
|
|
||||||
if service.CookieName != "j13_token" {
|
|
||||||
cookies = append(cookies,
|
|
||||||
struct{ name, path string }{"j13_token", "/"},
|
|
||||||
struct{ name, path string }{"j13_refresh", "/"},
|
|
||||||
struct{ name, path string }{"j13_csrf", "/"},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 更早期的 refresh cookie 只挂在 /api/auth 下
|
|
||||||
cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"})
|
|
||||||
|
|
||||||
for _, ck := range cookies {
|
for _, ck := range cookies {
|
||||||
http.SetCookie(c.Writer, &http.Cookie{
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
Name: ck.name,
|
Name: ck.name,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: ck.path,
|
Path: "/",
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: true,
|
HttpOnly: ck.httpOnly,
|
||||||
Secure: secure,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Boards 获取板块列表
|
// Boards 获取板块列表
|
||||||
@@ -15,3 +19,43 @@ func (h *Handlers) Boards(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
|
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoardSidebar 首页选中板块后的右栏聚合数据
|
||||||
|
func (h *Handlers) BoardSidebar(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.Board.Sidebar(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 热门榜点赞态 + 登录用户签到
|
||||||
|
if claims := middleware.CurrentUser(c); claims != nil {
|
||||||
|
ids := make([]uint, 0, len(data.Hot))
|
||||||
|
for _, p := range data.Hot {
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||||
|
for i := range data.Hot {
|
||||||
|
data.Hot[i].Liked = likedMap[data.Hot[i].ID]
|
||||||
|
}
|
||||||
|
if status, err := h.Checkin.Status(claims.ID); err == nil {
|
||||||
|
data.Checkin = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"board": data.Board,
|
||||||
|
"moderators": data.Moderators,
|
||||||
|
"stats": data.Stats,
|
||||||
|
"hot": data.Hot,
|
||||||
|
"active_users": data.ActiveUsers,
|
||||||
|
"checkin": data.Checkin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,18 +25,13 @@ func parseWSChatRoom(room string) (uint, bool) {
|
|||||||
|
|
||||||
// 实时总线 WebSocket 入口:GET /api/ws
|
// 实时总线 WebSocket 入口:GET /api/ws
|
||||||
//
|
//
|
||||||
// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带),
|
// 鉴权:与 REST 一致,读 HttpOnly 的 j13_token cookie(浏览器握手自动携带);
|
||||||
// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。
|
// ParseToken + ValidateClaims 实时校验 token_version 与封禁。
|
||||||
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
|
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
|
||||||
// 防止跨站页面在用户浏览器中发起握手。
|
// 防止跨站页面在用户浏览器中发起握手。
|
||||||
func (h *Handlers) RealtimeWS(c *gin.Context) {
|
func (h *Handlers) RealtimeWS(c *gin.Context) {
|
||||||
tokenStr, err := c.Cookie(service.CookieName)
|
tokenStr, err := c.Cookie(service.CookieName)
|
||||||
if err != nil || tokenStr == "" {
|
if err != nil || tokenStr == "" {
|
||||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
||||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tokenStr == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,17 +100,17 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkWSOrigin 校验握手 Origin:
|
// checkWSOrigin 校验握手 Origin(仅服务浏览器前端):
|
||||||
// - 无 Origin(非浏览器/native 客户端)放行
|
// - 必须带 Origin(浏览器 WS 握手总会带;拒绝空 Origin 的脚本/非浏览器探测)
|
||||||
// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001)
|
// - dev:允许 localhost / 127.0.0.1 任意端口(页面 :3000 直连后端 :3001,端口不同但 cookie 仍会带上)
|
||||||
// - 生产:Origin 主机必须与请求 Host 同源
|
// - 生产:Origin 的 host 必须与请求 Host 完全一致(需反向代理把页面与 /api/ws 挂到同一 Host)
|
||||||
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
|
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
u, err := url.Parse(origin)
|
u, err := url.Parse(origin)
|
||||||
if err != nil || u.Scheme != "http" && u.Scheme != "https" {
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if h.Cfg.DevMode {
|
if h.Cfg.DevMode {
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const siteBaseURL = "http://localhost:3000"
|
|
||||||
|
|
||||||
// RobotsTxt robots.txt
|
|
||||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
|
||||||
content := fmt.Sprintf(`User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Sitemap: %s/sitemap.xml
|
|
||||||
`, siteBaseURL)
|
|
||||||
c.String(http.StatusOK, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SitemapXML 生成 sitemap
|
|
||||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
|
||||||
type urlEntry struct {
|
|
||||||
Loc string `xml:"loc"`
|
|
||||||
Lastmod string `xml:"lastmod"`
|
|
||||||
Changefreq string `xml:"changefreq"`
|
|
||||||
Priority string `xml:"priority"`
|
|
||||||
}
|
|
||||||
type urlset struct {
|
|
||||||
XMLName xml.Name `xml:"urlset"`
|
|
||||||
Xmlns string `xml:"xmlns,attr"`
|
|
||||||
URLs []urlEntry `xml:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02")
|
|
||||||
entries := []urlEntry{
|
|
||||||
{Loc: siteBaseURL + "/", Lastmod: now, Changefreq: "daily", Priority: "1.0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 板块页
|
|
||||||
var boards []model.Board
|
|
||||||
model.DB.Find(&boards)
|
|
||||||
for _, b := range boards {
|
|
||||||
entries = append(entries, urlEntry{
|
|
||||||
Loc: fmt.Sprintf("%s/board/%d", siteBaseURL, b.ID),
|
|
||||||
Lastmod: now,
|
|
||||||
Changefreq: "daily",
|
|
||||||
Priority: "0.8",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 帖子详情页
|
|
||||||
var posts []model.Post
|
|
||||||
model.DB.Where("status = ?", model.ContentStatusPublished).
|
|
||||||
Order("created_at DESC").Limit(1000).Find(&posts)
|
|
||||||
for _, p := range posts {
|
|
||||||
entries = append(entries, urlEntry{
|
|
||||||
Loc: fmt.Sprintf("%s/post/%d", siteBaseURL, p.ID),
|
|
||||||
Lastmod: p.UpdatedAt.Format("2006-01-02"),
|
|
||||||
Changefreq: "weekly",
|
|
||||||
Priority: "0.6",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 站点公告
|
|
||||||
var anns []model.Announcement
|
|
||||||
model.DB.Where("published = ?", true).Order("created_at DESC").Limit(200).Find(&anns)
|
|
||||||
for _, a := range anns {
|
|
||||||
entries = append(entries, urlEntry{
|
|
||||||
Loc: fmt.Sprintf("%s/announcement/%d", siteBaseURL, a.ID),
|
|
||||||
Lastmod: a.UpdatedAt.Format("2006-01-02"),
|
|
||||||
Changefreq: "weekly",
|
|
||||||
Priority: "0.5",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
us := urlset{
|
|
||||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
|
||||||
URLs: entries,
|
|
||||||
}
|
|
||||||
output, err := xml.MarshalIndent(us, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Header("Content-Type", "application/xml")
|
|
||||||
c.String(http.StatusOK, xml.Header+string(output))
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -60,27 +58,6 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireAdmin 必须管理员(保留兼容;等价于"管理员及以上",不含纯板块管理员)
|
|
||||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
user, ok := m.parseToken(c)
|
|
||||||
if !ok {
|
|
||||||
if c.GetBool(AccountBannedKey) {
|
|
||||||
bannedJSON(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if model.RoleLevel(model.Role(user.Role)) < model.RoleLevel(model.RoleAdmin) {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Set("user", user)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActorKey 实时权限快照在 gin.Context 中的键
|
// ActorKey 实时权限快照在 gin.Context 中的键
|
||||||
const ActorKey = "actor"
|
const ActorKey = "actor"
|
||||||
|
|
||||||
@@ -121,20 +98,13 @@ func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseToken 解析并校验 token:
|
// parseToken 解析并校验 token:
|
||||||
// 1. 优先从 HttpOnly cookie 读取,回退 Authorization header
|
// 1. 从 HttpOnly cookie(j13_token / 生产 __Host-j13_token)读取
|
||||||
// 2. 校验 JWT 签名和过期
|
// 2. 校验 JWT 签名和过期
|
||||||
// 3. 查 DB 实时校验 token_version 和 banned 状态
|
// 3. 查 DB 实时校验 token_version 和 banned 状态
|
||||||
func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool) {
|
func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool) {
|
||||||
tokenStr, err := c.Cookie(service.CookieName)
|
tokenStr, err := c.Cookie(service.CookieName)
|
||||||
if err != nil || tokenStr == "" {
|
if err != nil || tokenStr == "" {
|
||||||
auth := c.GetHeader("Authorization")
|
return nil, false
|
||||||
if auth == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
|
||||||
if tokenStr == auth {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
claims, err := m.auth.ParseToken(tokenStr)
|
claims, err := m.auth.ParseToken(tokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
// 控制 Referer 信息泄露
|
// 控制 Referer 信息泄露
|
||||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
// XSS 防护(旧浏览器兼容,现代浏览器靠 CSP)
|
|
||||||
c.Header("X-XSS-Protection", "1; mode=block")
|
|
||||||
|
|
||||||
// 禁止浏览器缓存敏感页面(可按需覆盖)
|
// 禁止浏览器缓存敏感页面(可按需覆盖)
|
||||||
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ func InitDB(dsn string) error {
|
|||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 旧 post_type=normal → discussion
|
|
||||||
if err := db.Exec(`UPDATE posts SET post_type = ? WHERE post_type = ? OR post_type = '' OR post_type IS NULL`,
|
|
||||||
PostTypeDiscussion, PostTypeNormal).Error; err != nil {
|
|
||||||
return fmt.Errorf("帖子类型归一失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
|
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
|
||||||
if err := backfillPointsFromCheckin(db); err != nil {
|
if err := backfillPointsFromCheckin(db); err != nil {
|
||||||
return fmt.Errorf("积分余额回填失败: %w", err)
|
return fmt.Errorf("积分余额回填失败: %w", err)
|
||||||
|
|||||||
@@ -54,14 +54,13 @@ const (
|
|||||||
ContentStatusRejected = "rejected"
|
ContentStatusRejected = "rejected"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 帖子类型(discussion 为默认;兼容旧值 normal → discussion)
|
// 帖子类型(discussion 为默认)
|
||||||
const (
|
const (
|
||||||
PostTypeDiscussion = "discussion" // 讨论(默认)
|
PostTypeDiscussion = "discussion" // 讨论(默认)
|
||||||
PostTypeQuestion = "question" // 问答
|
PostTypeQuestion = "question" // 问答
|
||||||
PostTypePoll = "poll" // 投票
|
PostTypePoll = "poll" // 投票
|
||||||
PostTypeBounty = "bounty" // 悬赏
|
PostTypeBounty = "bounty" // 悬赏
|
||||||
PostTypeLottery = "lottery" // 抽奖
|
PostTypeLottery = "lottery" // 抽奖
|
||||||
PostTypeNormal = "normal" // 旧值,读取时归一为 discussion
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidPostType 发帖类型白名单
|
// ValidPostType 发帖类型白名单
|
||||||
@@ -73,11 +72,8 @@ func ValidPostType(t string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizePostType 归一化类型(空/旧 normal → discussion)
|
// NormalizePostType 归一化类型(空或未知 → discussion)
|
||||||
func NormalizePostType(t string) string {
|
func NormalizePostType(t string) string {
|
||||||
if t == "" || t == PostTypeNormal {
|
|
||||||
return PostTypeDiscussion
|
|
||||||
}
|
|
||||||
if ValidPostType(t) {
|
if ValidPostType(t) {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
r.Use(cors.New(cors.Config{
|
r.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
|
AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-CSRF-Token"},
|
AllowHeaders: []string{"Origin", "Content-Type", "X-CSRF-Token"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
MaxAge: 12 * time.Hour,
|
MaxAge: 12 * time.Hour,
|
||||||
}))
|
}))
|
||||||
@@ -98,10 +98,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 上传文件静态服务(data/uploads → /uploads)
|
// 上传文件静态服务(data/uploads → /uploads)
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
|
||||||
// 健康检查 & SEO
|
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
||||||
r.GET("/health", h.Health)
|
r.GET("/health", h.Health)
|
||||||
r.GET("/robots.txt", h.RobotsTxt)
|
|
||||||
r.GET("/sitemap.xml", h.SitemapXML)
|
|
||||||
|
|
||||||
// 实时通信总线(WebSocket,cookie 鉴权 + Origin 校验,处理器内部完成鉴权升级)
|
// 实时通信总线(WebSocket,cookie 鉴权 + Origin 校验,处理器内部完成鉴权升级)
|
||||||
r.GET("/api/ws", h.RealtimeWS)
|
r.GET("/api/ws", h.RealtimeWS)
|
||||||
@@ -111,6 +109,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
{
|
{
|
||||||
pubAPI.GET("/me", h.Me)
|
pubAPI.GET("/me", h.Me)
|
||||||
pubAPI.GET("/boards", h.Boards)
|
pubAPI.GET("/boards", h.Boards)
|
||||||
|
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||||
pubAPI.GET("/overview", h.Overview)
|
pubAPI.GET("/overview", h.Overview)
|
||||||
pubAPI.GET("/posts", h.Posts)
|
pubAPI.GET("/posts", h.Posts)
|
||||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||||
@@ -197,10 +196,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 各功能点再由 RequirePerm 按角色放行;板块范围在 service 层按 Actor.BoardIDs 隔离
|
// 各功能点再由 RequirePerm 按角色放行;板块范围在 service 层按 Actor.BoardIDs 隔离
|
||||||
staffAPI := r.Group("/api/admin", authMW.RequireStaff(), middleware.CSRFMiddleware())
|
staffAPI := r.Group("/api/admin", authMW.RequireStaff(), middleware.CSRFMiddleware())
|
||||||
{
|
{
|
||||||
staffAPI.GET("/dashboard", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"message": "admin dashboard"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 内容审核队列(任意管理角色;板块管理员只见授权板块)
|
// 内容审核队列(任意管理角色;板块管理员只见授权板块)
|
||||||
staffAPI.GET("/moderation/pending-posts", h.AdminPendingPosts)
|
staffAPI.GET("/moderation/pending-posts", h.AdminPendingPosts)
|
||||||
staffAPI.GET("/moderation/pending-comments", h.AdminPendingComments)
|
staffAPI.GET("/moderation/pending-comments", h.AdminPendingComments)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -20,6 +22,32 @@ type BoardWithCount struct {
|
|||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoardModerator 板块管理员公开资料
|
||||||
|
type BoardModerator struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoardSidebarStats 板级统计(不含在线)
|
||||||
|
type BoardSidebarStats struct {
|
||||||
|
PostCount int64 `json:"post_count"`
|
||||||
|
TodayPosts int64 `json:"today_posts"`
|
||||||
|
CommentCount int64 `json:"comment_count"`
|
||||||
|
TodayComments int64 `json:"today_comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoardSidebarData 首页选中板块后的右栏聚合
|
||||||
|
type BoardSidebarData struct {
|
||||||
|
Board BoardWithCount `json:"board"`
|
||||||
|
Moderators []BoardModerator `json:"moderators"`
|
||||||
|
Stats BoardSidebarStats `json:"stats"`
|
||||||
|
Hot []PostListItem `json:"hot"`
|
||||||
|
ActiveUsers []ActiveUser `json:"active_users"`
|
||||||
|
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
|
// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
|
||||||
func (s *BoardService) List() ([]BoardWithCount, error) {
|
func (s *BoardService) List() ([]BoardWithCount, error) {
|
||||||
var boards []model.Board
|
var boards []model.Board
|
||||||
@@ -56,3 +84,141 @@ func (s *BoardService) Get(id uint) (*model.Board, error) {
|
|||||||
}
|
}
|
||||||
return &board, nil
|
return &board, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar 首页板块右栏:管理员 + 概况统计 + 本板热门/活跃
|
||||||
|
func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
|
||||||
|
board, err := s.Get(boardID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // 含 gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &BoardSidebarData{
|
||||||
|
Board: BoardWithCount{Board: *board},
|
||||||
|
Moderators: []BoardModerator{},
|
||||||
|
Hot: []PostListItem{},
|
||||||
|
ActiveUsers: []ActiveUser{},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
weekStart := startOfWeek(now)
|
||||||
|
|
||||||
|
// 帖子总数 / 今日新帖
|
||||||
|
if err := s.db.Model(&model.Post{}).
|
||||||
|
Where("board_id = ? AND status = ?", boardID, model.ContentStatusPublished).
|
||||||
|
Count(&data.Stats.PostCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.Board.PostCount = data.Stats.PostCount
|
||||||
|
if err := s.db.Model(&model.Post{}).
|
||||||
|
Where("board_id = ? AND status = ? AND created_at >= ?", boardID, model.ContentStatusPublished, dayStart).
|
||||||
|
Count(&data.Stats.TodayPosts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 评论总数 / 今日评论(经 posts.board_id)
|
||||||
|
if err := s.db.Model(&model.Comment{}).
|
||||||
|
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
|
||||||
|
Where("posts.board_id = ? AND posts.status = ? AND comments.status = ?",
|
||||||
|
boardID, model.ContentStatusPublished, model.ContentStatusPublished).
|
||||||
|
Count(&data.Stats.CommentCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.Comment{}).
|
||||||
|
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
|
||||||
|
Where("posts.board_id = ? AND posts.status = ? AND comments.status = ? AND comments.created_at >= ?",
|
||||||
|
boardID, model.ContentStatusPublished, model.ContentStatusPublished, dayStart).
|
||||||
|
Count(&data.Stats.TodayComments).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 板块管理员:仅 board_admin 且授权本板、未封禁
|
||||||
|
var mods []model.User
|
||||||
|
if err := s.db.Table("users").
|
||||||
|
Select("users.id, users.username, users.nickname, users.avatar").
|
||||||
|
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
|
||||||
|
Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
|
||||||
|
Order("users.id ASC").
|
||||||
|
Find(&mods).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, u := range mods {
|
||||||
|
data.Moderators = append(data.Moderators, BoardModerator{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本板热门
|
||||||
|
var hotPosts []model.Post
|
||||||
|
if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished).
|
||||||
|
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
||||||
|
Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.Hot = toPostListItems(hotPosts)
|
||||||
|
|
||||||
|
// 本板本周活跃
|
||||||
|
type activityRow struct {
|
||||||
|
UserID uint
|
||||||
|
PostCount int64
|
||||||
|
CommentCount int64
|
||||||
|
}
|
||||||
|
var rows []activityRow
|
||||||
|
activitySQL := `
|
||||||
|
SELECT a.user_id AS user_id,
|
||||||
|
SUM(a.is_post) AS post_count,
|
||||||
|
SUM(1 - a.is_post) AS comment_count
|
||||||
|
FROM (
|
||||||
|
SELECT user_id, 1 AS is_post FROM posts
|
||||||
|
WHERE status = ? AND board_id = ? AND created_at >= ? AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.user_id, 0 AS is_post FROM comments c
|
||||||
|
JOIN posts p ON p.id = c.post_id AND p.deleted_at IS NULL
|
||||||
|
WHERE c.status = ? AND c.created_at >= ? AND c.deleted_at IS NULL
|
||||||
|
AND p.board_id = ? AND p.status = ?
|
||||||
|
) AS a
|
||||||
|
JOIN users AS u ON u.id = a.user_id
|
||||||
|
WHERE u.banned = ? AND u.deleted_at IS NULL
|
||||||
|
GROUP BY a.user_id
|
||||||
|
ORDER BY COUNT(*) DESC, a.user_id ASC
|
||||||
|
LIMIT 5`
|
||||||
|
if err := s.db.Raw(activitySQL,
|
||||||
|
model.ContentStatusPublished, boardID, weekStart,
|
||||||
|
model.ContentStatusPublished, weekStart, boardID, model.ContentStatusPublished,
|
||||||
|
false).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
ids := make([]uint, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, r.UserID)
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userMap := make(map[uint]model.User, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
userMap[u.ID] = u
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
u, ok := userMap[r.UserID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data.ActiveUsers = append(data.ActiveUsers, ActiveUser{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
PostCount: r.PostCount,
|
||||||
|
CommentCount: r.CommentCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type PostListQuery struct {
|
|||||||
BoardID uint
|
BoardID uint
|
||||||
Page int
|
Page int
|
||||||
Size int
|
Size int
|
||||||
Sort string // latest | hot | new
|
Sort string // latest | recommended | new
|
||||||
Keyword string // 搜索关键词
|
Keyword string // 搜索关键词
|
||||||
Recommended bool // 仅精华帖
|
Recommended bool // 仅精华帖
|
||||||
ViewerID uint // 当前查看者(0=游客)
|
ViewerID uint // 当前查看者(0=游客)
|
||||||
@@ -209,7 +209,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
|||||||
SELECT MAX(c.created_at) FROM comments c
|
SELECT MAX(c.created_at) FROM comments c
|
||||||
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
|
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
|
||||||
), created_at)) DESC`
|
), created_at)) DESC`
|
||||||
if q.Sort == "hot" {
|
if q.Sort == "recommended" {
|
||||||
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
||||||
}
|
}
|
||||||
// new:纯按发帖时间(新帖子)
|
// new:纯按发帖时间(新帖子)
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export default function ModerationAdmin({
|
|||||||
<div key={p.id} className="panel p-4 sm:p-5">
|
<div key={p.id} className="panel p-4 sm:p-5">
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-2.5">
|
<div className="flex items-center gap-2 flex-wrap mb-2.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/board/${p.board.id}`}
|
href={`/?board=${p.board.id}`}
|
||||||
className="chip"
|
className="chip"
|
||||||
style={{ fontSize: 11.5 }}
|
style={{ fontSize: 11.5 }}
|
||||||
>
|
>
|
||||||
@@ -354,7 +354,7 @@ export default function ModerationAdmin({
|
|||||||
{c.user.nickname}
|
{c.user.nickname}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/board/${c.board.id}`}
|
href={`/?board=${c.board.id}`}
|
||||||
className="chip"
|
className="chip"
|
||||||
style={{ fontSize: 11 }}
|
style={{ fontSize: 11 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react";
|
|
||||||
import { fetchPosts, fetchBoards } from "@/lib/api";
|
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
|
||||||
import { getMeCached } from "@/lib/serverData";
|
|
||||||
import Pagination from "@/components/Pagination";
|
|
||||||
import PostRow from "@/components/PostRow";
|
|
||||||
import PinnedPosts from "@/components/PinnedPosts";
|
|
||||||
import FeedNewTip from "@/components/home/FeedNewTip";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
searchParams: Promise<{ page?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
||||||
const { id } = await params;
|
|
||||||
const { boards } = await fetchBoards();
|
|
||||||
const board = boards.find((b) => b.id === Number(id));
|
|
||||||
return {
|
|
||||||
title: board ? `${board.name} · 姜十三论坛` : "板块 · 姜十三论坛",
|
|
||||||
description: board?.description || "板块帖子列表",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BoardPage({ params, searchParams }: PageProps) {
|
|
||||||
const { id } = await params;
|
|
||||||
const { page: pageStr } = await searchParams;
|
|
||||||
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
|
|
||||||
const cookie = authCookieHeader(await cookies());
|
|
||||||
const [postsData, boardsData, me] = await Promise.all([
|
|
||||||
fetchPosts(page, 20, Number(id), "latest", "", false, cookie),
|
|
||||||
fetchBoards(),
|
|
||||||
getMeCached(cookie || undefined),
|
|
||||||
]);
|
|
||||||
const boardIndex = boardsData.boards.findIndex((b) => b.id === Number(id));
|
|
||||||
const board = boardIndex >= 0 ? boardsData.boards[boardIndex] : null;
|
|
||||||
const user = me.user;
|
|
||||||
|
|
||||||
// 第一页:置顶帖独立成区,列表只保留非置顶
|
|
||||||
const pinned = page === 1 ? postsData.posts.filter((p) => p.pinned > 0) : [];
|
|
||||||
const rows = page === 1 ? postsData.posts.filter((p) => p.pinned <= 0) : postsData.posts;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 面包屑 */}
|
|
||||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
|
||||||
<ChevronRight size={13} />
|
|
||||||
<span style={{ color: "var(--accent)" }}>{board?.name || "未知板块"}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
|
|
||||||
style={{ background: "var(--accent-soft)" }}
|
|
||||||
/>
|
|
||||||
<div className="relative flex items-end justify-between gap-4 flex-wrap">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span
|
|
||||||
className={`av-${Math.max(0, boardIndex) % 8} w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center text-2xl font-extrabold shrink-0`}
|
|
||||||
>
|
|
||||||
{board?.name.slice(0, 1) || "?"}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
|
||||||
{board?.name || "未知板块"}
|
|
||||||
</h1>
|
|
||||||
{board?.description && (
|
|
||||||
<p className="mt-1 text-sm" style={{ color: "var(--ink-2)" }}>
|
|
||||||
{board.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="meta mt-1.5">{postsData.total} 篇讨论</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href={`/compose?board=${id}`} className="btn btn-primary">
|
|
||||||
<PenSquare size={15} /> 在此板块发帖
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_290px] gap-6">
|
|
||||||
<div>
|
|
||||||
<PinnedPosts posts={pinned} showBoard={false} />
|
|
||||||
{rows.length > 0 ? (
|
|
||||||
<div className="panel overflow-hidden">
|
|
||||||
<div className="px-4 sm:px-5 py-3.5 flex items-center justify-between" style={{ borderBottom: "1px solid var(--line)" }}>
|
|
||||||
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>全部讨论</span>
|
|
||||||
<span className="meta text-xs">第 {page} 页</span>
|
|
||||||
</div>
|
|
||||||
{user && (
|
|
||||||
<div className="pt-2.5">
|
|
||||||
<FeedNewTip
|
|
||||||
sort="latest"
|
|
||||||
boardId={Number(id)}
|
|
||||||
page={page}
|
|
||||||
userId={user.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rows.map((post, i) => (
|
|
||||||
<PostRow key={post.id} post={post} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
pinned.length === 0 && (
|
|
||||||
<div className="panel text-center py-20 px-6">
|
|
||||||
<span
|
|
||||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<MessagesSquare size={26} />
|
|
||||||
</span>
|
|
||||||
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>该板块还没有帖子</p>
|
|
||||||
<p className="meta mt-1.5">来发第一篇,抢占沙发</p>
|
|
||||||
<Link href={`/compose?board=${id}`} className="btn btn-primary mt-6">
|
|
||||||
<PenSquare size={15} /> 写新帖
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<Pagination total={postsData.total} page={page} size={20} basePath={`/board/${id}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside>
|
|
||||||
<div className="panel p-5">
|
|
||||||
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>其他板块</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{boardsData.boards
|
|
||||||
.map((b, i) => ({ b, i }))
|
|
||||||
.filter(({ b }) => b.id !== Number(id))
|
|
||||||
.map(({ b, i }) => (
|
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
href={`/board/${b.id}`}
|
|
||||||
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
|
||||||
>
|
|
||||||
<span className={`av-${i % 8} w-8 h-8 rounded-xl flex items-center justify-center text-[13px] font-bold shrink-0`}>
|
|
||||||
{b.name.slice(0, 1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium flex-1 truncate group-hover:text-[var(--accent)] transition-colors" style={{ color: "var(--ink)" }}>
|
|
||||||
{b.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { ChevronRight, MessagesSquare, LayoutGrid } from "lucide-react";
|
|
||||||
import { fetchBoards } from "@/lib/api";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "板块目录 · 姜十三论坛",
|
|
||||||
description: "浏览论坛全部板块,找到感兴趣的话题社区",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 板块汇总列表页:首页左栏「板块目录」标题的落地页,
|
|
||||||
// 展示全部板块(介绍 + 讨论量),点击进入板块独立页
|
|
||||||
export default async function BoardsPage() {
|
|
||||||
const { boards } = await fetchBoards();
|
|
||||||
const totalPosts = boards.reduce((sum, b) => sum + (b.post_count || 0), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 面包屑 */}
|
|
||||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
|
||||||
<ChevronRight size={13} />
|
|
||||||
<span style={{ color: "var(--accent)" }}>板块目录</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* 页头 */}
|
|
||||||
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
|
|
||||||
style={{ background: "var(--accent-soft)" }}
|
|
||||||
/>
|
|
||||||
<div className="relative flex items-center gap-4">
|
|
||||||
<span
|
|
||||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center shrink-0"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={28} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
|
||||||
板块目录
|
|
||||||
</h1>
|
|
||||||
<p className="meta mt-1.5">
|
|
||||||
共 {boards.length} 个板块 · {totalPosts} 篇讨论
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 板块卡片网格 */}
|
|
||||||
{boards.length === 0 ? (
|
|
||||||
<div className="panel text-center py-20 px-6">
|
|
||||||
<span
|
|
||||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<MessagesSquare size={26} />
|
|
||||||
</span>
|
|
||||||
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有任何板块</p>
|
|
||||||
<p className="meta mt-1.5">板块由管理员创建,敬请期待</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{boards.map((b, i) => (
|
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
href={`/board/${b.id}`}
|
|
||||||
className="j13-module group flex items-start gap-3.5 transition-[border-color,background] duration-150 hover:border-[var(--nav-active-line)] hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
|
||||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
|
||||||
>
|
|
||||||
<span className={`av-${i % 8} j13-bcard-ico !w-11 !h-11 !text-lg`} aria-hidden="true">
|
|
||||||
{b.name.slice(0, 1)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
|
|
||||||
{b.name}
|
|
||||||
</span>
|
|
||||||
<span className="meta shrink-0 text-[11.5px] tabular-nums">{b.post_count} 篇</span>
|
|
||||||
</span>
|
|
||||||
{b.description?.trim() && (
|
|
||||||
<span
|
|
||||||
className="block mt-1 text-[12.5px] leading-relaxed line-clamp-2"
|
|
||||||
style={{ color: "var(--ink-3)" }}
|
|
||||||
>
|
|
||||||
{b.description.trim()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="mt-2.5 inline-flex items-center gap-0.5 text-[12px] font-bold transition-colors group-hover:text-[var(--accent)]"
|
|
||||||
style={{ color: "var(--ink-2)" }}
|
|
||||||
>
|
|
||||||
进入板块
|
|
||||||
<ChevronRight
|
|
||||||
size={12}
|
|
||||||
className="transition-transform group-hover:translate-x-0.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import ThemeSync from "@/components/ThemeSync";
|
|||||||
import RealtimeProvider from "@/components/RealtimeProvider";
|
import RealtimeProvider from "@/components/RealtimeProvider";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
import { accentStyleCSS } from "@/lib/theme";
|
import { accentStyleCSS } from "@/lib/theme";
|
||||||
|
|
||||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
@@ -59,11 +59,9 @@ export default async function RootLayout({
|
|||||||
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
|
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
|
||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
// SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts),
|
// SSR 直出登录态(access 过期时 middleware 已先行轮转并注入请求头,见 middleware.ts),
|
||||||
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。
|
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。
|
||||||
// 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
|
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
|
||||||
// token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
|
|
||||||
// Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
|
|
||||||
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
||||||
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
||||||
const { accent } = await getPublicSettingsCached();
|
const { accent } = await getPublicSettingsCached();
|
||||||
@@ -72,8 +70,7 @@ export default async function RootLayout({
|
|||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
let unread = 0;
|
let unread = 0;
|
||||||
let chatUnread = 0;
|
let chatUnread = 0;
|
||||||
const hasAuthCookie =
|
const hasAuthCookie = !!cookieStore.get(TOKEN_COOKIE)?.value;
|
||||||
!!cookieStore.get(TOKEN_COOKIE)?.value || !!cookieStore.get(REFRESH_COOKIE)?.value;
|
|
||||||
if (hasAuthCookie) {
|
if (hasAuthCookie) {
|
||||||
// 用户信息与未读数合并为一次请求;只转发最小化的 access cookie
|
// 用户信息与未读数合并为一次请求;只转发最小化的 access cookie
|
||||||
const me = await getMeCached(authCookieHeader(cookieStore) || undefined);
|
const me = await getMeCached(authCookieHeader(cookieStore) || undefined);
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default function NotFound() {
|
|||||||
<Link href="/" className="btn btn-primary">
|
<Link href="/" className="btn btn-primary">
|
||||||
<Home size={15} /> 返回首页
|
<Home size={15} /> 返回首页
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/board/1" className="btn btn-line">
|
<Link href="/" className="btn btn-line">
|
||||||
逛逛板块
|
逛逛讨论
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { PenSquare, SearchX, MessagesSquare } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
fetchPosts,
|
fetchPosts,
|
||||||
fetchOverview,
|
fetchOverview,
|
||||||
|
fetchBoardSidebar,
|
||||||
type OverviewResponse,
|
type OverviewResponse,
|
||||||
|
type BoardSidebarResponse,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
@@ -15,6 +17,11 @@ import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
|
|||||||
import FeedNewTip from "@/components/home/FeedNewTip";
|
import FeedNewTip from "@/components/home/FeedNewTip";
|
||||||
import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
|
import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
|
||||||
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
|
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
|
||||||
|
import {
|
||||||
|
BoardPanels,
|
||||||
|
BoardPanelsMobile,
|
||||||
|
BoardMissingPanel,
|
||||||
|
} from "@/components/home/BoardPanels";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "姜十三论坛 · 技术讨论社区",
|
title: "姜十三论坛 · 技术讨论社区",
|
||||||
@@ -27,8 +34,6 @@ interface HomePageProps {
|
|||||||
|
|
||||||
function normalizeSort(v?: string): FeedSort {
|
function normalizeSort(v?: string): FeedSort {
|
||||||
if (v === "new" || v === "recommended") return v;
|
if (v === "new" || v === "recommended") return v;
|
||||||
// 兼容旧的 ?sort=hot 链接:落到最接近的推荐帖
|
|
||||||
if (v === "hot") return "recommended";
|
|
||||||
return "latest";
|
return "latest";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,22 +86,30 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
page,
|
page,
|
||||||
20,
|
20,
|
||||||
isSearch || boardId <= 0 ? undefined : boardId,
|
isSearch || boardId <= 0 ? undefined : boardId,
|
||||||
sort === "recommended" ? "hot" : sort,
|
sort,
|
||||||
keyword,
|
keyword,
|
||||||
sort === "recommended",
|
sort === "recommended",
|
||||||
cookie
|
cookie
|
||||||
);
|
);
|
||||||
// 聚合面板失败不阻塞帖子流
|
// 聚合面板失败不阻塞帖子流
|
||||||
const overviewPromise: Promise<OverviewResponse | null> = fetchOverview(cookie).catch(() => null);
|
const overviewPromise: Promise<OverviewResponse | null> = fetchOverview(cookie).catch(() => null);
|
||||||
|
const boardSidebarPromise: Promise<BoardSidebarResponse | null> =
|
||||||
|
boardId > 0 && !isSearch
|
||||||
|
? fetchBoardSidebar(boardId, cookie).catch(() => null)
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
const [me, postsData, overview] = await Promise.all([
|
const [me, postsData, overview, boardSidebar] = await Promise.all([
|
||||||
mePromise,
|
mePromise,
|
||||||
postsPromise,
|
postsPromise,
|
||||||
overviewPromise,
|
overviewPromise,
|
||||||
|
boardSidebarPromise,
|
||||||
]);
|
]);
|
||||||
const user = me.user;
|
const user = me.user;
|
||||||
const boards = overview?.boards ?? [];
|
const boards = overview?.boards ?? [];
|
||||||
const activeBoard = boards.find((b) => b.id === boardId) || null;
|
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
|
||||||
|
const showBoardPanels = boardId > 0 && !isSearch;
|
||||||
|
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
|
||||||
|
const boardPanelsMissing = showBoardPanels && !boardSidebar;
|
||||||
|
|
||||||
// 搜索态:单栏占满,无 Tabs/侧栏
|
// 搜索态:单栏占满,无 Tabs/侧栏
|
||||||
if (isSearch) {
|
if (isSearch) {
|
||||||
@@ -193,31 +206,61 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右栏:社区面板 */}
|
{/* 右栏:选中板块时切板级面板,否则全站社区面板 */}
|
||||||
{overview && (
|
{showBoardPanels ? (
|
||||||
<CommunityPanels
|
boardSidebar ? (
|
||||||
stats={overview.stats}
|
<BoardPanels
|
||||||
hot={overview.hot}
|
board={boardSidebar.board}
|
||||||
activeUsers={overview.active_users}
|
moderators={boardSidebar.moderators}
|
||||||
announcements={overview.announcements}
|
stats={boardSidebar.stats}
|
||||||
newUsers={overview.new_users}
|
hot={boardSidebar.hot}
|
||||||
checkin={overview.checkin ?? undefined}
|
activeUsers={boardSidebar.active_users}
|
||||||
/>
|
checkin={boardSidebar.checkin ?? undefined}
|
||||||
|
/>
|
||||||
|
) : boardPanelsMissing ? (
|
||||||
|
<BoardMissingPanel />
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
overview && (
|
||||||
|
<CommunityPanels
|
||||||
|
stats={overview.stats}
|
||||||
|
hot={overview.hot}
|
||||||
|
activeUsers={overview.active_users}
|
||||||
|
announcements={overview.announcements}
|
||||||
|
newUsers={overview.new_users}
|
||||||
|
checkin={overview.checkin ?? undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 窄屏:面板下沉到帖子流之后 */}
|
{/* 窄屏:面板下沉到帖子流之后 */}
|
||||||
{overview && (
|
{showBoardPanels ? (
|
||||||
<div className="mt-6">
|
boardSidebar ? (
|
||||||
<CommunityPanelsMobile
|
<div className="mt-6">
|
||||||
stats={overview.stats}
|
<BoardPanelsMobile
|
||||||
hot={overview.hot}
|
board={boardSidebar.board}
|
||||||
activeUsers={overview.active_users}
|
moderators={boardSidebar.moderators}
|
||||||
announcements={overview.announcements}
|
stats={boardSidebar.stats}
|
||||||
newUsers={overview.new_users}
|
hot={boardSidebar.hot}
|
||||||
checkin={overview.checkin ?? undefined}
|
activeUsers={boardSidebar.active_users}
|
||||||
/>
|
checkin={boardSidebar.checkin ?? undefined}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
overview && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<CommunityPanelsMobile
|
||||||
|
stats={overview.stats}
|
||||||
|
hot={overview.hot}
|
||||||
|
activeUsers={overview.active_users}
|
||||||
|
announcements={overview.announcements}
|
||||||
|
newUsers={overview.new_users}
|
||||||
|
checkin={overview.checkin ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function BoardCard({ board }: { board: Board }) {
|
|||||||
<p className="j13-module-title mb-3" id="board-card-title">
|
<p className="j13-module-title mb-3" id="board-card-title">
|
||||||
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/board/${board.id}`} className="flex items-center gap-3 group">
|
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
||||||
<span
|
<span
|
||||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
|
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
|
||||||
>
|
>
|
||||||
@@ -140,10 +140,10 @@ function BoardCard({ board }: { board: Board }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/board/${board.id}`}
|
href={`/?board=${board.id}`}
|
||||||
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
||||||
>
|
>
|
||||||
进入板块 <ArrowRight size={13} />
|
查看该板讨论 <ArrowRight size={13} />
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -266,7 +266,7 @@ function RelatedPosts({
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<Link
|
<Link
|
||||||
href={`/board/${boardId}`}
|
href={`/?board=${boardId}`}
|
||||||
className="j13-raillink justify-center mt-1.5 text-[12.5px]"
|
className="j13-raillink justify-center mt-1.5 text-[12.5px]"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
@@ -334,7 +334,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
fetchComments(id, commentPage, cookie),
|
fetchComments(id, commentPage, cookie),
|
||||||
getMeCached(cookie || undefined),
|
getMeCached(cookie || undefined),
|
||||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||||
fetchPosts(1, 8, post.board_id, "hot", "", false, cookie).catch(() => null),
|
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const comments = commentsData.comments;
|
const comments = commentsData.comments;
|
||||||
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
||||||
@@ -424,7 +424,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||||
<ChevronRight size={13} />
|
<ChevronRight size={13} />
|
||||||
<Link href={`/board/${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
|
<Link href={`/?board=${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
|
||||||
{post.board.name}
|
{post.board.name}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -432,7 +432,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<div className="panel p-6 sm:p-8">
|
<div className="panel p-6 sm:p-8">
|
||||||
<header className="mb-7">
|
<header className="mb-7">
|
||||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
<Link href={`/board/${post.board.id}`} className="chip">
|
<Link href={`/?board=${post.board.id}`} className="chip">
|
||||||
{post.board.name}
|
{post.board.name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { fetchPosts, fetchBoards } from "@/lib/api";
|
import { fetchPosts } from "@/lib/api";
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = "http://localhost:3000";
|
const baseUrl = "http://localhost:3000";
|
||||||
@@ -8,16 +8,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { boards } = await fetchBoards();
|
|
||||||
for (const board of boards) {
|
|
||||||
entries.push({
|
|
||||||
url: `${baseUrl}/board/${board.id}`,
|
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "daily",
|
|
||||||
priority: 0.8,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { posts } = await fetchPosts(1, 100);
|
const { posts } = await fetchPosts(1, 100);
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
entries.push({
|
entries.push({
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
LayoutGrid,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
@@ -87,8 +86,8 @@ export default function Header({
|
|||||||
});
|
});
|
||||||
}), [router]);
|
}), [router]);
|
||||||
|
|
||||||
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
|
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用等边缘情况。
|
||||||
// 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
|
// 正常 F5 下结果与 SSR 一致,不产生可见闪动;
|
||||||
// 无可读 CSRF cookie 说明必然是游客,跳过请求
|
// 无可读 CSRF cookie 说明必然是游客,跳过请求
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAuthCookieHint()) return;
|
if (!hasAuthCookieHint()) return;
|
||||||
@@ -238,13 +237,6 @@ export default function Header({
|
|||||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
|
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
|
||||||
首页
|
首页
|
||||||
</NavPill>
|
</NavPill>
|
||||||
<NavPill
|
|
||||||
href="/boards"
|
|
||||||
active={pathname === "/boards" || pathname?.startsWith("/board/") === true}
|
|
||||||
icon={<LayoutGrid size={15} />}
|
|
||||||
>
|
|
||||||
板块
|
|
||||||
</NavPill>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -378,12 +370,6 @@ export default function Header({
|
|||||||
</form>
|
</form>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
|
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
|
||||||
<MobileLink
|
|
||||||
href="/boards"
|
|
||||||
active={pathname === "/boards" || pathname?.startsWith("/board/") === true}
|
|
||||||
>
|
|
||||||
板块
|
|
||||||
</MobileLink>
|
|
||||||
{user && <MobileLink href="/compose">发帖</MobileLink>}
|
{user && <MobileLink href="/compose">发帖</MobileLink>}
|
||||||
{user && (
|
{user && (
|
||||||
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
|
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function PostRow({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{boardHref && (
|
{boardHref && (
|
||||||
<Link href={`/board/${post.board.id}`} className="j13-board-tag">
|
<Link href={`/?board=${post.board.id}`} className="j13-board-tag">
|
||||||
{post.board.name}
|
{post.board.name}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 外观设置页存在"未保存的本地实时预览",该页面内暂停同步,
|
// 外观设置页存在"未保存的本地实时预览",该页面内暂停同步,
|
||||||
// 避免轮询结果覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
|
// 避免 HTTP 校准覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
|
||||||
if (pathname?.startsWith("/admin/appearance")) return;
|
if (pathname?.startsWith("/admin/appearance")) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
307
frontend/components/home/BoardPanels.tsx
Normal file
307
frontend/components/home/BoardPanels.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Flame,
|
||||||
|
Users,
|
||||||
|
MessageCircle,
|
||||||
|
Heart,
|
||||||
|
Shield,
|
||||||
|
LayoutGrid,
|
||||||
|
PenLine,
|
||||||
|
PenSquare,
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type {
|
||||||
|
PostListItem,
|
||||||
|
ActiveUser,
|
||||||
|
BoardWithCount,
|
||||||
|
BoardModerator,
|
||||||
|
BoardSidebarStats,
|
||||||
|
CheckinStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
|
import { PulseCheckin } from "./CheckinCard";
|
||||||
|
import { formatCompact } from "@/lib/format";
|
||||||
|
|
||||||
|
/* ---------- 1. 板块管理员 ---------- */
|
||||||
|
|
||||||
|
function Moderators({ items }: { items: BoardModerator[] }) {
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label="板块管理员">
|
||||||
|
<p className="j13-module-title mb-2">
|
||||||
|
<Shield size={15} style={{ color: "var(--accent)" }} /> 板块管理员
|
||||||
|
</p>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="meta px-0.5 py-1.5">暂无板块管理员</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{items.map((u) => {
|
||||||
|
const name = u.nickname || u.username;
|
||||||
|
return (
|
||||||
|
<li key={u.id}>
|
||||||
|
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||||
|
<Avatar name={name} src={u.avatar || undefined} size={30} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. 板块概况 ---------- */
|
||||||
|
|
||||||
|
function BoardIntro({ board }: { board: BoardWithCount }) {
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label="板块概况">
|
||||||
|
<p className="j13-module-title mb-2">
|
||||||
|
<LayoutGrid size={15} style={{ color: "var(--accent)" }} /> 板块概况
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] font-medium leading-snug" style={{ color: "var(--ink)" }}>
|
||||||
|
{board.name}
|
||||||
|
</p>
|
||||||
|
{board.description ? (
|
||||||
|
<p className="meta mt-1.5 text-[12.5px] leading-relaxed line-clamp-3">{board.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="meta mt-1.5">暂无板块介绍</p>
|
||||||
|
)}
|
||||||
|
<p className="meta mt-2 tabular-nums">讨论 {formatCompact(board.post_count)} 篇</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link href={`/compose?board=${board.id}`} className="btn btn-primary btn-sm w-full justify-center">
|
||||||
|
<PenSquare size={14} /> 在此发帖
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 3. 板块数据 + 签到 ---------- */
|
||||||
|
|
||||||
|
function BoardPulse({
|
||||||
|
stats,
|
||||||
|
checkin,
|
||||||
|
}: {
|
||||||
|
stats: BoardSidebarStats;
|
||||||
|
checkin?: CheckinStatus | null;
|
||||||
|
}) {
|
||||||
|
const cells = [
|
||||||
|
{
|
||||||
|
icon: <PenLine size={12.5} />,
|
||||||
|
label: "话题总数",
|
||||||
|
value: formatCompact(stats.post_count),
|
||||||
|
foot: `+${formatCompact(stats.today_posts)} 今日`,
|
||||||
|
up: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TrendingUp size={12.5} />,
|
||||||
|
label: "今日新帖",
|
||||||
|
value: formatCompact(stats.today_posts),
|
||||||
|
foot: "本日新增",
|
||||||
|
up: stats.today_posts > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MessageCircle size={12.5} />,
|
||||||
|
label: "回复总数",
|
||||||
|
value: formatCompact(stats.comment_count),
|
||||||
|
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
||||||
|
up: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Heart size={12.5} />,
|
||||||
|
label: "今日回复",
|
||||||
|
value: formatCompact(stats.today_comments),
|
||||||
|
foot: "本日新增",
|
||||||
|
up: stats.today_comments > 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label="板块数据">
|
||||||
|
<p className="j13-module-title">
|
||||||
|
<BarChart3 size={15} style={{ color: "var(--accent)" }} /> 板块数据
|
||||||
|
</p>
|
||||||
|
<div className="j13-pulse">
|
||||||
|
{cells.map((c) => (
|
||||||
|
<div key={c.label} className="j13-pulse-cell">
|
||||||
|
<p className="j13-pulse-lab">
|
||||||
|
<span style={{ color: "var(--accent)" }} aria-hidden="true">
|
||||||
|
{c.icon}
|
||||||
|
</span>
|
||||||
|
{c.label}
|
||||||
|
</p>
|
||||||
|
<p className="j13-pulse-num">{c.value}</p>
|
||||||
|
<p className={`j13-pulse-foot${c.up ? " is-up" : ""}`}>
|
||||||
|
{c.up && <TrendingUp size={10.5} aria-hidden="true" />}
|
||||||
|
{c.foot}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="j13-pulse-checkin">
|
||||||
|
<PulseCheckin initial={checkin} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 4. 本板热门 ---------- */
|
||||||
|
|
||||||
|
function BoardHot({ posts }: { posts: PostListItem[] }) {
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label="本板热门">
|
||||||
|
<p className="j13-module-title mb-1.5">
|
||||||
|
<Flame size={15} style={{ color: "var(--clay)" }} /> 本板热门
|
||||||
|
</p>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="meta px-1.5 py-3">本板还没有热门讨论</p>
|
||||||
|
) : (
|
||||||
|
<ol className="j13-ranklist">
|
||||||
|
{posts.map((p, i) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link href={`/post/${p.id}`} className="j13-rank group" data-tip={p.title}>
|
||||||
|
<span className="j13-rank-no">{i + 1}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className="block truncate text-[13px] font-medium leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MessageCircle size={11.5} /> {p.comment_count}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Heart size={11.5} /> {p.like_count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 5. 本板活跃 ---------- */
|
||||||
|
|
||||||
|
function BoardActive({ users }: { users: ActiveUser[] }) {
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label="本板活跃成员">
|
||||||
|
<p className="j13-module-title mb-2">
|
||||||
|
<Users size={15} style={{ color: "var(--accent)" }} /> 本板活跃
|
||||||
|
<span
|
||||||
|
className="ml-auto font-normal normal-case tracking-normal text-[11px]"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
本周
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="meta px-1.5 py-2">本周本板还没有人互动</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{users.map((u) => {
|
||||||
|
const name = u.nickname || u.username;
|
||||||
|
return (
|
||||||
|
<li key={u.id}>
|
||||||
|
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||||
|
<Avatar name={name} src={u.avatar || undefined} size={30} />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className="block truncate text-[13px] font-medium leading-tight"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="mt-px flex items-center gap-2.5 text-[11px] meta">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[13px] tabular-nums"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
||||||
|
>
|
||||||
|
{u.post_count + u.comment_count}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 装配 ---------- */
|
||||||
|
|
||||||
|
type BoardPanelsProps = {
|
||||||
|
board: BoardWithCount;
|
||||||
|
moderators: BoardModerator[];
|
||||||
|
stats: BoardSidebarStats;
|
||||||
|
hot: PostListItem[];
|
||||||
|
activeUsers: ActiveUser[];
|
||||||
|
checkin?: CheckinStatus | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BoardPanelsBody(props: BoardPanelsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Moderators items={props.moderators} />
|
||||||
|
<BoardIntro board={props.board} />
|
||||||
|
<BoardPulse stats={props.stats} checkin={props.checkin} />
|
||||||
|
<BoardHot posts={props.hot} />
|
||||||
|
<BoardActive users={props.activeUsers} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardPanels(props: BoardPanelsProps) {
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
|
||||||
|
<div className="sticky top-24 space-y-4">
|
||||||
|
<BoardPanelsBody {...props} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardPanelsMobile(props: BoardPanelsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 lg:hidden" aria-label="板块面板">
|
||||||
|
<BoardPanelsBody {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 板块不存在时的简卡 */
|
||||||
|
export function BoardMissingPanel() {
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
|
||||||
|
<div className="sticky top-24">
|
||||||
|
<section className="j13-module">
|
||||||
|
<p className="j13-module-title mb-2">
|
||||||
|
<LayoutGrid size={15} style={{ color: "var(--ink-3)" }} /> 板块
|
||||||
|
</p>
|
||||||
|
<p className="meta">板块不存在或已删除</p>
|
||||||
|
<Link href="/" className="btn btn-line btn-sm mt-3 w-full justify-center">
|
||||||
|
返回全部讨论
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { PenSquare, Home, ChevronRight } from "lucide-react";
|
import { PenSquare, Home } from "lucide-react";
|
||||||
import type { BoardWithCount, User } from "@/lib/api";
|
import type { BoardWithCount, User } from "@/lib/api";
|
||||||
|
|
||||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||||
@@ -34,26 +34,14 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 板块目录:标题可点击进入板块汇总列表页(/boards);
|
{/* 板块目录:纯分区标题;下方卡片仅在中栏筛选该板帖子流 */}
|
||||||
下方板块卡片点击不离开工作台,仅在中栏筛选该板块帖子流 */}
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<div className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)]">
|
||||||
href="/boards"
|
<span>板块目录</span>
|
||||||
className="group px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)] hover:text-[var(--accent)] transition-colors"
|
|
||||||
aria-label="进入板块目录列表页"
|
|
||||||
>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<span>板块目录</span>
|
|
||||||
<ChevronRight
|
|
||||||
size={12}
|
|
||||||
className="ml-0.5 opacity-0 -translate-x-0.5 group-hover:opacity-100 group-hover:translate-x-0 transition-all"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{boards.length > 0 && (
|
{boards.length > 0 && (
|
||||||
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
|
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{boards.map((b, i) => (
|
{boards.map((b, i) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -134,6 +134,32 @@ export interface OverviewResponse {
|
|||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 板块右栏:管理员公开资料 */
|
||||||
|
export interface BoardModerator {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 板块右栏:板级统计 */
|
||||||
|
export interface BoardSidebarStats {
|
||||||
|
post_count: number;
|
||||||
|
today_posts: number;
|
||||||
|
comment_count: number;
|
||||||
|
today_comments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页选中板块后的右栏聚合 */
|
||||||
|
export interface BoardSidebarResponse {
|
||||||
|
board: BoardWithCount;
|
||||||
|
moderators: BoardModerator[];
|
||||||
|
stats: BoardSidebarStats;
|
||||||
|
hot: PostListItem[];
|
||||||
|
active_users: ActiveUser[];
|
||||||
|
checkin?: CheckinStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostListItem {
|
export interface PostListItem {
|
||||||
id: number;
|
id: number;
|
||||||
board_id: number;
|
board_id: number;
|
||||||
@@ -462,7 +488,7 @@ export async function fetchPosts(
|
|||||||
page = 1,
|
page = 1,
|
||||||
size = 20,
|
size = 20,
|
||||||
boardId?: number,
|
boardId?: number,
|
||||||
sort = "latest",
|
sort = "latest", // latest | recommended | new
|
||||||
keyword = "",
|
keyword = "",
|
||||||
recommended = false,
|
recommended = false,
|
||||||
cookieHeader?: string
|
cookieHeader?: string
|
||||||
@@ -483,6 +509,19 @@ export async function fetchOverview(cookieHeader?: string): Promise<OverviewResp
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SSR 板块右栏;失败时抛错,由首页降级 */
|
||||||
|
export async function fetchBoardSidebar(
|
||||||
|
boardId: number,
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<BoardSidebarResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/boards/${boardId}/sidebar`,
|
||||||
|
ssrInit(cookieHeaders(cookieHeader))
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("获取板块侧栏失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
|
export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
|
||||||
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
|
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
|
||||||
if (!res.ok) throw new Error("获取板块失败");
|
if (!res.ok) throw new Error("获取板块失败");
|
||||||
@@ -1375,7 +1414,7 @@ export async function apiAdminUserAuditMessages(
|
|||||||
// ===== 站点外观设置 =====
|
// ===== 站点外观设置 =====
|
||||||
|
|
||||||
export async function apiGetSettings(): Promise<PublicSettings> {
|
export async function apiGetSettings(): Promise<PublicSettings> {
|
||||||
// no-store:主题同步轮询必须读到管理员刚保存的最新配色,不走浏览器缓存
|
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
|
||||||
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
||||||
if (!res.ok) throw new Error("获取站点设置失败");
|
if (!res.ok) throw new Error("获取站点设置失败");
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
|
|||||||
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
||||||
|
|
||||||
export function normalizePostType(t?: string): PostTypeValue {
|
export function normalizePostType(t?: string): PostTypeValue {
|
||||||
if (t === "normal" || !t) return "discussion";
|
if (!t) return "discussion";
|
||||||
if (POST_TYPES.some((x) => x.value === t)) return t as PostTypeValue;
|
if (POST_TYPES.some((x) => x.value === t)) return t as PostTypeValue;
|
||||||
return "discussion";
|
return "discussion";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
|
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
|
||||||
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
|
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
|
||||||
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
|
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
|
||||||
// - 轮询仅作兜底:回前台 / 进页时 HTTP 校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
||||||
|
|
||||||
export const RT_HELLO = "hello";
|
export const RT_HELLO = "hello";
|
||||||
export const RT_PONG = "pong";
|
export const RT_PONG = "pong";
|
||||||
|
|||||||
Reference in New Issue
Block a user