Skip to content

feat(events): /events 列表+详情+感兴趣 + /admin/events 管理台 + 个人主页管理员入口#298

Merged
longsizhuo merged 1 commit intomainfrom
feat/events-management
Apr 17, 2026
Merged

feat(events): /events 列表+详情+感兴趣 + /admin/events 管理台 + 个人主页管理员入口#298
longsizhuo merged 1 commit intomainfrom
feat/events-management

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

配套后端 PR:InvolutionHell/involutionhell-backend#9

背景

用户的原始需求:给管理员(`longsizhuo` / `Mira190` / `Crokily`)的个人主页加"管理员界面"按钮,点进去能维护活动——不用每次改 `data/event.json` 发版。

评审会结论(开了 team:developer / PM / user-tester 三个视角并行讨论):

  • 全员一致:核心价值是回放归档 + 详情页,活动本身一次性消耗,回放才是留存引擎
  • 砍掉:Discord 双向同步(tester 不信任)、iCal(UNSW 学生用率极低)
  • 做:`/events` 列表 + 详情页内嵌 YouTube + 轻量"感兴趣"(比 RSVP 门槛低 10 倍)

新增路由

路由 说明
`/events` 公开活动列表,按"正在进行 / 即将开始 / 历史活动"三段排版
`/events/[id]` 详情页。YouTube 链接自动内嵌 `<iframe>`,非 YouTube 退回按钮
`/admin/events` 管理员活动列表 + 删除(带二次确认)
`/admin/events/new` 新建活动表单
`/admin/events/[id]/edit` 编辑活动

个人主页管理员入口

新增 `app/u/[username]/AdminLinkIfOwnerAdmin.tsx`。渲染条件三者同时满足:

  1. 当前已登录
  2. 当前登录用户就是这个 profile 的 owner
  3. 当前用户 `roles.includes("admin")`

也就是:只有 admin 本人访问自己的主页时才看到按钮,路人 / 其他 admin / 非 admin 登录者一律看不到——沿用 `EditLinkIfOwner` 模式,避免 user tester 提到的"看到朋友主页有管理员按钮会社交尴尬问出来"。

ActivityTicker / FloatWindow 迁到 API

新增 `lib/events-fetch.ts`:SSR 调后端 `/api/events`,后端抖动 / BACKEND_URL 未配 自动 fallback 到 `data/event.json`,首页永远不白屏。

  • `ActivityTicker` 从同步 import JSON 改成 async Server Component
  • `FloatWindow` 改成接 `event` prop,由 `app/page.tsx` Server Component 拉完数据传下来

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 降级界面
```

刻意不做

  • 单独 /admin 首页(本次只开 /admin/events 一个入口)
  • speakers 头像 / profile URL 填写(先只记 name)
  • tag 过滤子页(P3)

配套后端 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 降级界面)。
Copilot AI review requested due to automatic review settings April 16, 2026 21:22
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Apr 16, 2026 9:28pm
website-preview Ready Ready Preview, Comment Apr 16, 2026 9:28pm

@longsizhuo
Copy link
Copy Markdown
Member Author

开发文档已同步到 Wiki:Events-Feature — 覆盖 schema / API / 权限模型 / 扩展指引 / 常见坑。

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里跳转到 /login?next=/admin/events,但当前登录页(app/login/page.tsx + SignInButton)不会读取/传递 next 参数,导致登录完成后无法回到 admin 页面(query 只是“看起来有”,实际上不生效)。建议要么移除该参数避免误导,要么在登录入口把 next/redirect 透传到 /oauth/render/github 并在回调后执行重定向。

Suggested change
router.replace("/login?next=/admin/events");
router.replace("/login");

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +29
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>
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把路由参数直接 Number(id),当 id 不是合法数字时会得到 NaN,随后请求会变成 /api/admin/events/NaN,错误信息也会比较迷惑。建议在进入请求前校验 Number.isFinite(eventId),非法时直接渲染错误/跳回列表/显示 404。

Copilot uses AI. Check for mistakes.
Comment on lines +16 to 20
* 数据来源:
* - 之前从 data/event.json 直接 import
* - 现在由上游 Server Component(app/page.tsx)调 lib/events-fetch.ts 拉 /api/events
* 后通过 event prop 传进来;后端失败时 fetch 内部会 fallback 到 JSON
*/
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

现在 coverUrl 可能来自后端/后台表单(可填外链),但组件内部仍用 next/image 渲染封面;next/image 会对远端域名做 allowlist 校验,未配置的域名会运行时报错。建议在这里明确约束 coverUrl 只允许站内资源,或把封面渲染改为原生 (与 /events 列表页一致),避免后台填外链导致首页悬浮窗直接炸。

Copilot uses AI. Check for mistakes.
Comment thread lib/events-fetch.ts
coverUrl: string;
deprecated: boolean;
}
const items = (eventsJson as { events: JsonItem[] }).events;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallbackFromJson 这里直接把 eventsJson 断言成 { events: JsonItem[] } 并取 .events;如果 event.json 结构改动或文件内容意外(例如缺 events 字段),fallback 会在这里直接 throw,反而让“后端挂了不白屏”的兜底失效。建议复用现有的 Zod schema(app/types/event.ts 的 ActivityEventsConfigSchema)做 parse/校验,或至少对 items 做 Array.isArray 防御性处理。

Suggested change
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)
: [];

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +105
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>
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TickerItem 对站内链接(href 以 / 开头)仍然用原生 <a>,会触发整页导航,丢掉 Next 的 client-side navigation / prefetch(跑马灯点击体验会明显变慢)。建议对 internal href 改用 next/link(external 仍用 )。

Copilot uses AI. Check for mistakes.
Comment thread app/events/[id]/page.tsx
Comment on lines +259 to +267
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;
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toYoutubeEmbed 里用 hostname.endsWith("youtube.com") / "youtube-nocookie.com" 会把 evilyoutube.com 这类非 YouTube 域名误判为可嵌入,导致把任意第三方站点放进 iframe。建议改成严格白名单(如 hostname === "youtube.com" || hostname.endsWith(".youtube.com"),nocookie 同理),并拒绝其它域名。

Copilot uses AI. Check for mistakes.
Comment thread app/events/page.tsx
Comment on lines +188 to +199
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;
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatDate 这里用 try/catch 捕获不了非法日期字符串:new Date(iso) 不会 throw,只会得到 Invalid Date,最终可能渲染出 "Invalid Date"。建议在 toLocaleDateString 前加 Number.isNaN(d.getTime()) 判断,非法时回退到原始字符串。

Suggested change
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",
});

Copilot uses AI. Check for mistakes.
Comment thread app/events/[id]/page.tsx
Comment on lines +275 to +286
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;
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatDateTime 同上:new Date(iso) 遇到非法字符串不会 throw,try/catch 无法兜底,可能输出 "Invalid Date"。建议检查 Number.isNaN(d.getTime()),非法时返回原 iso。

Suggested change
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 uses AI. Check for mistakes.
@longsizhuo longsizhuo merged commit fb33cbe into main Apr 17, 2026
12 checks passed
longsizhuo pushed a commit that referenced this pull request Apr 17, 2026
修复 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 同上。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants