From e4abaae179ee342800b4cf604c13d2d92d23ca61 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 21:19:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(events):=20/events=20=E5=88=97=E8=A1=A8+?= =?UTF-8?q?=E8=AF=A6=E6=83=85+=E6=84=9F=E5=85=B4=E8=B6=A3=20+=20/admin/eve?= =?UTF-8?q?nts=20=E7=AE=A1=E7=90=86=E5=8F=B0=20+=20=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E7=AE=A1=E7=90=86=E5=91=98=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配套后端 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 降级界面)。 --- app/admin/events/AdminGuard.tsx | 61 ++++ app/admin/events/EventForm.tsx | 290 ++++++++++++++++++++ app/admin/events/[id]/edit/page.tsx | 85 ++++++ app/admin/events/layout.tsx | 25 ++ app/admin/events/lib.ts | 79 ++++++ app/admin/events/new/page.tsx | 36 +++ app/admin/events/page.tsx | 203 ++++++++++++++ app/components/ActivityTicker.tsx | 144 +++++----- app/components/float-window/FloatWindow.tsx | 37 ++- app/events/[id]/InterestButton.tsx | 101 +++++++ app/events/[id]/page.tsx | 287 +++++++++++++++++++ app/events/page.tsx | 200 ++++++++++++++ app/events/types.ts | 57 ++++ app/page.tsx | 15 +- app/u/[username]/AdminLinkIfOwnerAdmin.tsx | 46 ++++ app/u/[username]/page.tsx | 6 + lib/events-fetch.ts | 119 ++++++++ next.config.mjs | 19 ++ 18 files changed, 1723 insertions(+), 87 deletions(-) create mode 100644 app/admin/events/AdminGuard.tsx create mode 100644 app/admin/events/EventForm.tsx create mode 100644 app/admin/events/[id]/edit/page.tsx create mode 100644 app/admin/events/layout.tsx create mode 100644 app/admin/events/lib.ts create mode 100644 app/admin/events/new/page.tsx create mode 100644 app/admin/events/page.tsx create mode 100644 app/events/[id]/InterestButton.tsx create mode 100644 app/events/[id]/page.tsx create mode 100644 app/events/page.tsx create mode 100644 app/events/types.ts create mode 100644 app/u/[username]/AdminLinkIfOwnerAdmin.tsx create mode 100644 lib/events-fetch.ts diff --git a/app/admin/events/AdminGuard.tsx b/app/admin/events/AdminGuard.tsx new file mode 100644 index 00000000..1f558eb5 --- /dev/null +++ b/app/admin/events/AdminGuard.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** + * 前端管理员页的权限包装器。 + * + * 做的事: + * - 未登录:跳 /login + * - 登录但不是 admin:渲染 403 提示 + * - 是 admin:渲染 children + * + * 注意这个只是 UX 层的保护——真正的安全由后端 @SaCheckRole("admin") 兜底。 + * 用户只要能绕过这里也拿不到数据,后端直接返回 401/403。 + */ + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/use-auth"; +import type { ReactNode } from "react"; + +export function AdminGuard({ children }: { children: ReactNode }) { + const { user, status } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (status === "unauthenticated") { + router.replace("/login?next=/admin/events"); + } + }, [status, router]); + + if (status === "loading") { + return ( +
+

+ Loading… +

+
+ ); + } + + if (status === "unauthenticated") return null; + + const isAdmin = user?.roles?.includes("admin") ?? false; + if (!isAdmin) { + return ( +
+
+
+ 403 · Forbidden +
+

你不是管理员

+

+ 管理员界面仅对 roles 包含 admin{" "} + 的账号开放。 如果你认为这是误报,联系站点维护者。 +

+
+
+ ); + } + + return <>{children}; +} diff --git a/app/admin/events/EventForm.tsx b/app/admin/events/EventForm.tsx new file mode 100644 index 00000000..b1c4f63a --- /dev/null +++ b/app/admin/events/EventForm.tsx @@ -0,0 +1,290 @@ +"use client"; + +/** + * Admin 侧的活动表单(新建 + 编辑共用)。 + * + * 表单字段基本是 EventRequest 的直接映射。 + * 几个 UX 细节: + * - startTime / endTime 用 ,提交前转成 ISO + * - speakers 简化成"每行一条 name",头像/profile URL 暂不支持(延后) + * - tags 用"逗号分隔"单行输入 + * - cover_url / discord / playback 都是可选 + * - 提交后跳 /admin/events 列表 + */ + +import { useState, useRef, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import type { EventRequest, EventView, EventStatus } from "@/app/events/types"; +import { createEvent, updateEvent } from "./lib"; + +interface Props { + /** undefined 表示新建;传入 EventView 表示编辑 */ + initial?: EventView; +} + +const STATUS_OPTIONS: EventStatus[] = [ + "draft", + "published", + "archived", + "cancelled", +]; + +export function EventForm({ initial }: Props) { + const router = useRouter(); + const formRef = useRef(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + + const fd = new FormData(e.currentTarget); + const req: EventRequest = { + title: String(fd.get("title") ?? "").trim(), + description: String(fd.get("description") ?? ""), + coverUrl: emptyToNull(fd.get("coverUrl")), + startTime: datetimeLocalToIso(fd.get("startTime")), + endTime: datetimeLocalToIso(fd.get("endTime")), + discordLink: emptyToNull(fd.get("discordLink")), + playbackUrl: emptyToNull(fd.get("playbackUrl")), + tags: splitCsv(fd.get("tags")), + speakers: parseSpeakers(fd.get("speakers")), + status: (fd.get("status") as EventStatus) ?? "draft", + }; + + try { + if (initial) { + await updateEvent(initial.id, req); + } else { + await createEvent(req); + } + router.push("/admin/events"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + setSubmitting(false); + } + }; + + return ( +
+ + + + +
+ + +
+ + + + + + + s.name).join("\n") ?? ""} + hint="目前只记录姓名;头像 / profile URL 后续版本支持。" + /> + +
+ + +

+ draft = 仅管理员可见;published = 公开;archived = 历史;cancelled = + 取消。 +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+ + ); +} + +interface FieldProps { + label: string; + name: string; + type?: string; + required?: boolean; + defaultValue?: string; + multiline?: boolean; + rows?: number; + hint?: string; +} + +function Field({ + label, + name, + type = "text", + required, + defaultValue, + multiline, + rows, + hint, +}: FieldProps) { + const base = + "border border-[var(--foreground)] px-3 py-2 bg-[var(--background)] text-[var(--foreground)] font-sans text-sm focus:outline-none focus:border-[#CC0000]"; + return ( +
+ + {multiline ? ( +