feat(events): /events 列表+详情+感兴趣 + /admin/events 管理台 + 个人主页管理员入口#298
feat(events): /events 列表+详情+感兴趣 + /admin/events 管理台 + 个人主页管理员入口#298longsizhuo merged 1 commit intomainfrom
Conversation
配套后端 involutionhell-backend feat/events-management 分支。
## 新增前端路由
- /events 公开活动列表(正在进行 / 即将开始 / 历史活动 三段)
- /events/[id] 详情页 + YouTube 回放内嵌 + 感兴趣按钮(登录后可点)
- /admin/events 管理员活动列表 + 删除
- /admin/events/new 新建活动表单
- /admin/events/[id]/edit 编辑活动
## 个人主页管理员入口
新增 app/u/[username]/AdminLinkIfOwnerAdmin.tsx:只有"登录用户 + 是本人 + roles 含 admin"
三条件同时满足才渲染"管理员界面"按钮,对其他人(路人、非 admin 登录者)一律 null
避免社交尴尬(user tester 明确提到"看到朋友主页有管理员按钮会问出来")。
## ActivityTicker / FloatWindow 迁 API
新增 lib/events-fetch.ts 做服务端拉取 + JSON fallback。
- ActivityTicker 改成 async Server Component,SSR 直接拿 /api/events 数据
- FloatWindow 改成接 event prop,由 app/page.tsx Server Component 拉完数据传下来
- BACKEND_URL 未配 / 后端抖动 → 自动 fallback 到 data/event.json,首页不会白屏
## next.config.mjs rewrites
加了 /api/events 和 /api/admin/events 两对 rewrites(Next [:path*] 不匹配空路径,
所以 /api/events 和 /api/events/:path* 都要显式列)。
## 权限模型
前端通过 useAuth().user.roles.includes("admin") 判定;没加 isAdmin 派生字段,
UserView.roles 已经能满足。真正的安全由后端 @SaCheckRole 兜底,前端 AdminGuard
只负责 UX(非 admin 看到 403 降级界面)。
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
开发文档已同步到 Wiki:Events-Feature — 覆盖 schema / API / 权限模型 / 扩展指引 / 常见坑。 |
There was a problem hiding this comment.
Pull request overview
该 PR 为站点引入“活动(Events)”模块的公开列表/详情/感兴趣能力,并增加管理员后台用于维护活动数据,同时把首页活动展示从静态 data/event.json 迁移到后端 API(失败时回退 JSON),以及在管理员本人个人主页增加后台入口。
Changes:
- 新增
/events列表页与/events/[id]详情页(含回放内嵌与“感兴趣”交互)。 - 新增
/admin/events管理台(列表、新建、编辑、删除)与前端 AdminGuard UX 保护。 - 首页 ActivityTicker/FloatWindow 改为通过服务端拉取活动数据(失败回退
event.json),并在个人主页为管理员本人增加后台入口。
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| next.config.mjs | 增加 /api/events、/api/admin/events rewrite 到后端以支持活动读接口与后台 CRUD |
| lib/events-fetch.ts | 新增首页活动 SSR 拉取逻辑(后端失败时回退 data/event.json) |
| app/page.tsx | 首页改为 async Server Component,拉取活动并传给 FloatWindow |
| app/components/ActivityTicker.tsx | ActivityTicker 改为服务端 fetch 活动数据并渲染跑马灯 |
| app/components/float-window/FloatWindow.tsx | FloatWindow 改为从上游传入单条活动数据(不再直接 import JSON) |
| app/events/types.ts | 新增 Events 模块前后端对齐的 TS 类型定义 |
| app/events/page.tsx | 新增 /events 公开活动列表页(ongoing/upcoming/past 三段) |
| app/events/[id]/page.tsx | 新增活动详情页(回放嵌入、讲师/标签、感兴趣按钮等) |
| app/events/[id]/InterestButton.tsx | 新增“感兴趣”客户端按钮(乐观更新 + 调用 interest API) |
| app/admin/events/layout.tsx | 新增 admin/events 的 Server layout(承载 Header/Footer) |
| app/admin/events/AdminGuard.tsx | 新增 AdminGuard:未登录跳登录、非 admin 展示 403、admin 渲染内容 |
| app/admin/events/lib.ts | 新增 admin 侧 Events API client(带 satoken header) |
| app/admin/events/page.tsx | 新增 /admin/events 列表页(含删除二次确认) |
| app/admin/events/new/page.tsx | 新增 /admin/events/new 新建页(复用表单) |
| app/admin/events/[id]/edit/page.tsx | 新增编辑页:拉取详情后复用表单编辑 |
| app/admin/events/EventForm.tsx | 新增活动新建/编辑共用表单与字段处理 |
| app/u/[username]/AdminLinkIfOwnerAdmin.tsx | 新增个人主页“管理员界面”入口(仅 admin 本人访问自己主页可见) |
| app/u/[username]/page.tsx | 在个人主页插入 AdminLinkIfOwnerAdmin |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| useEffect(() => { | ||
| if (status === "unauthenticated") { | ||
| router.replace("/login?next=/admin/events"); |
There was a problem hiding this comment.
这里跳转到 /login?next=/admin/events,但当前登录页(app/login/page.tsx + SignInButton)不会读取/传递 next 参数,导致登录完成后无法回到 admin 页面(query 只是“看起来有”,实际上不生效)。建议要么移除该参数避免误导,要么在登录入口把 next/redirect 透传到 /oauth/render/github 并在回调后执行重定向。
| router.replace("/login?next=/admin/events"); | |
| router.replace("/login"); |
| export default function EditEventPage({ params }: Param) { | ||
| // React 19 的 new-style async params:用 use() 同步解包 Promise | ||
| const { id } = use(params); | ||
| const eventId = Number(id); | ||
|
|
||
| return ( | ||
| <AdminGuard> | ||
| <EditEventInner eventId={eventId} /> | ||
| </AdminGuard> |
There was a problem hiding this comment.
这里把路由参数直接 Number(id),当 id 不是合法数字时会得到 NaN,随后请求会变成 /api/admin/events/NaN,错误信息也会比较迷惑。建议在进入请求前校验 Number.isFinite(eventId),非法时直接渲染错误/跳回列表/显示 404。
| * 数据来源: | ||
| * - 之前从 data/event.json 直接 import | ||
| * - 现在由上游 Server Component(app/page.tsx)调 lib/events-fetch.ts 拉 /api/events | ||
| * 后通过 event prop 传进来;后端失败时 fetch 内部会 fallback 到 JSON | ||
| */ |
There was a problem hiding this comment.
| coverUrl: string; | ||
| deprecated: boolean; | ||
| } | ||
| const items = (eventsJson as { events: JsonItem[] }).events; |
There was a problem hiding this comment.
fallbackFromJson 这里直接把 eventsJson 断言成 { events: JsonItem[] } 并取 .events;如果 event.json 结构改动或文件内容意外(例如缺 events 字段),fallback 会在这里直接 throw,反而让“后端挂了不白屏”的兜底失效。建议复用现有的 Zod schema(app/types/event.ts 的 ActivityEventsConfigSchema)做 parse/校验,或至少对 items 做 Array.isArray 防御性处理。
| const items = (eventsJson as { events: JsonItem[] }).events; | |
| function isJsonItem(value: unknown): value is JsonItem { | |
| if (!value || typeof value !== "object") { | |
| return false; | |
| } | |
| const item = value as Record<string, unknown>; | |
| return ( | |
| typeof item.name === "string" && | |
| typeof item.discord === "string" && | |
| (item.playback === undefined || typeof item.playback === "string") && | |
| typeof item.coverUrl === "string" && | |
| typeof item.deprecated === "boolean" | |
| ); | |
| } | |
| const config = | |
| eventsJson && typeof eventsJson === "object" | |
| ? (eventsJson as { events?: unknown }) | |
| : {}; | |
| const items = Array.isArray(config.events) | |
| ? config.events.filter(isJsonItem) | |
| : []; |
| const isInternal = href.startsWith("/"); | ||
|
|
||
| return ( | ||
| <div className="flex items-center gap-4 whitespace-nowrap"> | ||
| {isLast ? ( | ||
| <span className="bg-[#CC0000] text-white px-2 py-0.5 font-mono text-[10px] uppercase tracking-tighter shrink-0"> | ||
| Update | ||
| </span> | ||
| ) : null} | ||
| <a | ||
| href={href} | ||
| target={isInternal ? undefined : "_blank"} | ||
| rel={isInternal ? undefined : "noopener noreferrer"} | ||
| className="font-sans text-xs font-bold uppercase tracking-widest hover:text-[#CC0000]" | ||
| > | ||
| {event.name} —{" "} | ||
| {event.deprecated ? "Archives Available" : "Event Active"} | ||
| </a> |
There was a problem hiding this comment.
TickerItem 对站内链接(href 以 / 开头)仍然用原生 <a>,会触发整页导航,丢掉 Next 的 client-side navigation / prefetch(跑马灯点击体验会明显变慢)。建议对 internal href 改用 next/link(external 仍用 )。
| if ( | ||
| u.hostname.endsWith("youtube.com") || | ||
| u.hostname.endsWith("youtube-nocookie.com") | ||
| ) { | ||
| const videoId = u.searchParams.get("v"); | ||
| if (videoId) return `https://www.youtube.com/embed/${videoId}`; | ||
| // 已经是 /embed/ 路径 | ||
| if (u.pathname.startsWith("/embed/")) return url; | ||
| } |
There was a problem hiding this comment.
toYoutubeEmbed 里用 hostname.endsWith("youtube.com") / "youtube-nocookie.com" 会把 evilyoutube.com 这类非 YouTube 域名误判为可嵌入,导致把任意第三方站点放进 iframe。建议改成严格白名单(如 hostname === "youtube.com" || hostname.endsWith(".youtube.com"),nocookie 同理),并拒绝其它域名。
| try { | ||
| const d = new Date(iso); | ||
| return d.toLocaleDateString("zh-CN", { | ||
| year: "numeric", | ||
| month: "short", | ||
| day: "numeric", | ||
| hour: "2-digit", | ||
| minute: "2-digit", | ||
| }); | ||
| } catch { | ||
| return iso; | ||
| } |
There was a problem hiding this comment.
formatDate 这里用 try/catch 捕获不了非法日期字符串:new Date(iso) 不会 throw,只会得到 Invalid Date,最终可能渲染出 "Invalid Date"。建议在 toLocaleDateString 前加 Number.isNaN(d.getTime()) 判断,非法时回退到原始字符串。
| try { | |
| const d = new Date(iso); | |
| return d.toLocaleDateString("zh-CN", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| } catch { | |
| return iso; | |
| } | |
| const d = new Date(iso); | |
| if (Number.isNaN(d.getTime())) { | |
| return iso; | |
| } | |
| return d.toLocaleDateString("zh-CN", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); |
| try { | ||
| const d = new Date(iso); | ||
| return d.toLocaleString("zh-CN", { | ||
| year: "numeric", | ||
| month: "short", | ||
| day: "numeric", | ||
| hour: "2-digit", | ||
| minute: "2-digit", | ||
| }); | ||
| } catch { | ||
| return iso; | ||
| } |
There was a problem hiding this comment.
formatDateTime 同上:new Date(iso) 遇到非法字符串不会 throw,try/catch 无法兜底,可能输出 "Invalid Date"。建议检查 Number.isNaN(d.getTime()),非法时返回原 iso。
| try { | |
| const d = new Date(iso); | |
| return d.toLocaleString("zh-CN", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| } catch { | |
| return iso; | |
| } | |
| const d = new Date(iso); | |
| if (Number.isNaN(d.getTime())) { | |
| return iso; | |
| } | |
| return d.toLocaleString("zh-CN", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); |
修复 Copilot 提出的 8 条问题:
1) AdminGuard 跳转去掉 ?next=/admin/events —— 登录页和 SignInButton 当前没实现
next 参数透传,留着会让人误以为登录后会自动回跳。
2) /admin/events/[id]/edit 的 id 用 Number.isFinite 严格校验,非法时不发请求,
直接渲染"非法 id"错误态,避免 /api/admin/events/NaN 这种迷惑错误。
3) FloatWindow 封面从 next/image 换成原生 <img>。后台允许管理员填外链封面 URL,
next/image 需要 remotePatterns 白名单,命中不了就运行时 500 把首页悬浮窗
整个挂掉。改原生 <img> 和 /events 列表页保持一致。
4) 按用户决定:砍掉 data/event.json 兜底路径。前后端分离架构下后端可用是硬前提,
失败直接返回空数组,让 ActivityTicker / FloatWindow 静默不渲染。同时解决
Copilot 原 CR 里 JSON 结构异常会让 fallback 本身 throw 的问题。
5) ActivityTicker 站内链接改 next/link,保留 client-side navigation + prefetch,
之前用 <a> 会触发整页刷新。同步简化逻辑:API 一定带 id,不再走"无 id 时
fallback Discord/playback"分支。
6) toYoutubeEmbed 的 YouTube 白名单从 hostname.endsWith("youtube.com") 收紧为
精确匹配 + 合法子域(www. 或 *.)。之前会把 evilyoutube.com 判成可嵌入域名,
相当于把任意第三方站点放进 iframe。
7) /events 列表 formatDate 加 Number.isNaN(d.getTime()) 检查:new Date(iso)
遇到非法字符串不 throw,只会返回 Invalid Date,try/catch 捕获不到,最终
会渲染出字面量 "Invalid Date"。
8) /events/[id] formatDateTime 同上。
配套后端 PR:InvolutionHell/involutionhell-backend#9
背景
用户的原始需求:给管理员(`longsizhuo` / `Mira190` / `Crokily`)的个人主页加"管理员界面"按钮,点进去能维护活动——不用每次改 `data/event.json` 发版。
评审会结论(开了 team:developer / PM / user-tester 三个视角并行讨论):
新增路由
个人主页管理员入口
新增 `app/u/[username]/AdminLinkIfOwnerAdmin.tsx`。渲染条件三者同时满足:
也就是:只有 admin 本人访问自己的主页时才看到按钮,路人 / 其他 admin / 非 admin 登录者一律看不到——沿用 `EditLinkIfOwner` 模式,避免 user tester 提到的"看到朋友主页有管理员按钮会社交尴尬问出来"。
ActivityTicker / FloatWindow 迁到 API
新增 `lib/events-fetch.ts`:SSR 调后端 `/api/events`,后端抖动 / BACKEND_URL 未配 自动 fallback 到 `data/event.json`,首页永远不白屏。
next.config.mjs rewrites
加 `/api/events` + `/api/admin/events` 两对 rewrite。Next `:path*` 不匹配空路径,所以 `/api/events` 和 `/api/events/:path*` 都要显式写。
权限判定
前端 `useAuth().user.roles.includes("admin")`——`UserView.roles` 已经暴露,没加派生 `isAdmin` 字段。真正安全由后端 `@SaCheckRole` 兜底,前端 `AdminGuard` 只是 UX(给非 admin 一个 403 降级界面 + 跳 login)。
本地 E2E
```
公开:
/ 200(Hero ticker + FloatWindow 从 API 拉数据)
/events 200(4 条,按时间分 3 段)
/events/4 (Open.Onion) 200
/events/1 (Mock Interview) 200
管理员(admin 登录):
/admin/events 200
/admin/events/new 200
/admin/events/4/edit 200
创建 → 编辑 → 删除 链路全绿
非 admin(alice 登录):
/admin/events 403 降级界面
```
刻意不做