From 03480c1c9b43b755a1f244d0620238490339f11a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:35:57 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(events):=20=E5=93=8D=E5=BA=94=20PR=20#2?= =?UTF-8?q?98=20Copilot=20CR=20=E2=80=94=20=E8=B7=AF=E7=94=B1=20/=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=20/=20=E6=97=A5=E6=9C=9F=20/=20=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 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 换成原生 。后台允许管理员填外链封面 URL, next/image 需要 remotePatterns 白名单,命中不了就运行时 500 把首页悬浮窗 整个挂掉。改原生 和 /events 列表页保持一致。 4) 按用户决定:砍掉 data/event.json 兜底路径。前后端分离架构下后端可用是硬前提, 失败直接返回空数组,让 ActivityTicker / FloatWindow 静默不渲染。同时解决 Copilot 原 CR 里 JSON 结构异常会让 fallback 本身 throw 的问题。 5) ActivityTicker 站内链接改 next/link,保留 client-side navigation + prefetch, 之前用 会触发整页刷新。同步简化逻辑: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 同上。 --- app/admin/events/AdminGuard.tsx | 6 +- app/admin/events/[id]/edit/page.tsx | 26 +++++-- app/components/ActivityTicker.tsx | 36 ++++------ app/components/float-window/FloatWindow.tsx | 15 ++-- app/events/[id]/page.tsx | 27 ++++++-- app/events/page.tsx | 5 +- lib/events-fetch.ts | 76 ++++++--------------- 7 files changed, 92 insertions(+), 99 deletions(-) diff --git a/app/admin/events/AdminGuard.tsx b/app/admin/events/AdminGuard.tsx index 1f558eb5..2ae4f9a4 100644 --- a/app/admin/events/AdminGuard.tsx +++ b/app/admin/events/AdminGuard.tsx @@ -23,7 +23,11 @@ export function AdminGuard({ children }: { children: ReactNode }) { useEffect(() => { if (status === "unauthenticated") { - router.replace("/login?next=/admin/events"); + // 注意:登录页 / SignInButton 当前没实现 next 参数透传(走 GitHub OAuth + // 走 /oauth/render/github,回调后固定落在首页 #token=xxx)。这里就不带 + // ?next=,避免"看起来支持其实不生效"的迷惑。登录成功后用户需自己再点 + // 个人主页里的"管理员界面"按钮返回这里。 + router.replace("/login"); } }, [status, router]); diff --git a/app/admin/events/[id]/edit/page.tsx b/app/admin/events/[id]/edit/page.tsx index 8a367af4..56a7675e 100644 --- a/app/admin/events/[id]/edit/page.tsx +++ b/app/admin/events/[id]/edit/page.tsx @@ -21,21 +21,35 @@ interface Param { export default function EditEventPage({ params }: Param) { // React 19 的 new-style async params:用 use() 同步解包 Promise const { id } = use(params); - const eventId = Number(id); + // 路由参数不受控,可能是 "abc"、"12ab" 之类非法字符串;直接 Number() 得到 NaN + // 会让后续请求变成 /api/admin/events/NaN 并报一条迷惑错误("活动不存在")。 + // 用 parseInt 严格解析 + Number.isFinite 双重校验,非法 id 直接传 null + // 让下游渲染一个"id 非法"的错误态,不打请求。 + const parsed = Number.parseInt(id, 10); + const eventId = Number.isFinite(parsed) && parsed > 0 ? parsed : null; return ( - + ); } -function EditEventInner({ eventId }: { eventId: number }) { +function EditEventInner({ + eventId, + rawId, +}: { + eventId: number | null; + rawId: string; +}) { const [event, setEvent] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loading, setLoading] = useState(eventId !== null); + const [error, setError] = useState( + eventId === null ? `非法的活动 id: ${rawId}` : null, + ); useEffect(() => { + if (eventId === null) return; let cancelled = false; (async () => { try { @@ -63,7 +77,7 @@ function EditEventInner({ eventId }: { eventId: number }) {
- Admin · Events · Edit #{eventId} + Admin · Events · Edit #{eventId ?? rawId}

编辑活动 diff --git a/app/components/ActivityTicker.tsx b/app/components/ActivityTicker.tsx index ee378e5e..9f720f98 100644 --- a/app/components/ActivityTicker.tsx +++ b/app/components/ActivityTicker.tsx @@ -1,19 +1,17 @@ +import Link from "next/link"; import { cn } from "@/lib/utils"; import { fetchHomepageEvents, type HomepageEvent } from "@/lib/events-fetch"; /** * 首页顶部活动轮播。 * - * 数据源已经从 data/event.json 迁移到后端 /api/events(管理员在 /admin/events 维护)。 - * BACKEND_URL 不可用 / 后端抖动时自动 fallback 到 event.json,保证首屏不会空白。 + * 数据源:后端 /api/events(管理员在 /admin/events 维护)。 + * 后端失败时返回空数组,组件 return null 不渲染轮播(整条不显示)。 * * 为什么是 Server Component: * - 没有交互状态(纯 CSS 跑马灯动画) * - SSR 时已经能拿到数据,避免 client fetch 造成首屏闪烁 * - revalidate: 300 让 Neon 压力稳定在每 5min 一次 SSR - * - * MAX_ITEMS 和 ROTATION_MS 原先在 event.json 的 settings 字段,迁后端后没对应列, - * 直接在这里作为常量;如果真需要动态,再开一张 events_settings 表。 */ const MAX_ITEMS = 3; @@ -76,16 +74,11 @@ function TickerItem({ event: HomepageEvent; isLast: boolean; }) { - // 优先跳详情页(有 id 时),否则老行为:直接 Discord / playback - const href = - event.id != null - ? `/events/${event.id}` - : event.deprecated - ? event.playback || - "https://involutionhell.com/docs/jobs/event-keynote/event-takeway" - : event.discord; - - const isInternal = href.startsWith("/"); + // 轮播点击始终跳站内详情页(后端必然返回带 id 的 EventView) + const href = `/events/${event.id}`; + const linkLabel = `${event.name} — ${event.deprecated ? "Archives Available" : "Event Active"}`; + const linkClass = + "font-sans text-xs font-bold uppercase tracking-widest hover:text-[#CC0000]"; return (
@@ -94,15 +87,10 @@ function TickerItem({ Update ) : null} - - {event.name} —{" "} - {event.deprecated ? "Archives Available" : "Event Active"} - + {/* 站内链接:用 next/link 保留 client-side navigation + prefetch */} + + {linkLabel} + Edition 1.0.0 diff --git a/app/components/float-window/FloatWindow.tsx b/app/components/float-window/FloatWindow.tsx index 522fdba8..d02c4e23 100644 --- a/app/components/float-window/FloatWindow.tsx +++ b/app/components/float-window/FloatWindow.tsx @@ -2,7 +2,6 @@ import { useState, useCallback } from "react"; import { usePathname } from "next/navigation"; -import Image from "next/image"; import { motion, AnimatePresence } from "motion/react"; import type { HomepageEvent } from "@/lib/events-fetch"; import { cn } from "@/lib/utils"; @@ -119,13 +118,19 @@ export function FloatWindow({ event }: FloatWindowProps) { {/* Content */}
{/* 图片区域 - 默认为灰度(暗黑模式除外) */} + {/* + 用原生 不走 next/image:活动后台允许管理员填外链封面 URL, + next/image 需要提前把外链域名加到 next.config.mjs remotePatterns 白名单, + 否则运行时会 500,把 FloatWindow 整个挂掉。原生 只负责渲染, + 安全性由后端 EventRequest.coverUrl 做 URL scheme 校验承担。 + / events 列表页也是这个策略,保持一致。 + */}
- {currentEvent.name} {/* 突发新闻徽章 */} diff --git a/app/events/[id]/page.tsx b/app/events/[id]/page.tsx index b853a4f2..875c8d8f 100644 --- a/app/events/[id]/page.tsx +++ b/app/events/[id]/page.tsx @@ -253,16 +253,27 @@ function toYoutubeEmbed(url: string | null | undefined): string | null { if (!url) return null; try { const u = new URL(url); - if (u.hostname === "youtu.be") { + // 严格白名单:只匹配 youtu.be 精确、或 youtube.com / youtube-nocookie.com + // 精确 + *.youtube.com / *.youtube-nocookie.com 子域名。 + // 之前用 endsWith("youtube.com") 会把 evilyoutube.com 也判对,放进 iframe 后 + // 相当于把任意第三方站点嵌进详情页。 + const isYoutuBe = u.hostname === "youtu.be"; + const isYoutubeHost = + u.hostname === "youtube.com" || + u.hostname === "www.youtube.com" || + u.hostname.endsWith(".youtube.com"); + const isYoutubeNocookieHost = + u.hostname === "youtube-nocookie.com" || + u.hostname === "www.youtube-nocookie.com" || + u.hostname.endsWith(".youtube-nocookie.com"); + + if (isYoutuBe) { return `https://www.youtube.com/embed/${u.pathname.slice(1)}`; } - if ( - u.hostname.endsWith("youtube.com") || - u.hostname.endsWith("youtube-nocookie.com") - ) { + if (isYoutubeHost || isYoutubeNocookieHost) { const videoId = u.searchParams.get("v"); if (videoId) return `https://www.youtube.com/embed/${videoId}`; - // 已经是 /embed/ 路径 + // 已经是 /embed/ 路径(此时原 URL 就是白名单内 host + /embed/xxx,可直接返回) if (u.pathname.startsWith("/embed/")) return url; } } catch { @@ -272,8 +283,10 @@ function toYoutubeEmbed(url: string | null | undefined): string | null { } function formatDateTime(iso: string): string { + // Invalid Date 检查同 /events/page.tsx 的 formatDate:new Date() 不抛。 + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; try { - const d = new Date(iso); return d.toLocaleString("zh-CN", { year: "numeric", month: "short", diff --git a/app/events/page.tsx b/app/events/page.tsx index 55e27396..b63a4469 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -185,8 +185,11 @@ function EventCard({ event }: { event: EventView }) { } function formatDate(iso: string): string { + // new Date(iso) 遇到非法字符串不 throw,只会返回一个 getTime() === NaN 的 Invalid Date, + // 直接调 toLocaleDateString 会输出字面量 "Invalid Date",所以必须显式检查。 + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; try { - const d = new Date(iso); return d.toLocaleDateString("zh-CN", { year: "numeric", month: "short", diff --git a/lib/events-fetch.ts b/lib/events-fetch.ts index 22dd7d78..5e6b382c 100644 --- a/lib/events-fetch.ts +++ b/lib/events-fetch.ts @@ -3,28 +3,25 @@ * * 做的事: * 1. 调后端 /api/events 拿 published + archived 列表 - * 2. 转成首页 ActivityTicker / FloatWindow 原本认识的老 schema(name / discord / playback / - * coverUrl / deprecated),这样这两个组件内部渲染逻辑不需要大改 - * 3. 后端挂了 / BACKEND_URL 没配 → fallback 到 data/event.json,保证首页不会因为后端抖动白屏 + * 2. 转成首页 ActivityTicker / FloatWindow 原本认识的老 schema + * (name / discord / playback / coverUrl / deprecated),组件渲染逻辑不大改 * - * 为什么保留 event.json: - * - 开发环境没启后端时,首页还能跑 - * - 生产 fallback 场景(后端挂了一会儿),首屏轮播不至于完全消失 - * - JSON 是静态构建时就在的,没有运行时开销 + * 失败策略: + * 前后端分离架构下,后端可用是硬前提。任何失败(BACKEND_URL 未配 / 网络异常 / + * 非 2xx / JSON 解析错)都返回空数组,让 ActivityTicker 和 FloatWindow 渲染出空态 + * 或直接不出现,同时把异常打到 server log 供排查。不再维护 data/event.json 兜底。 */ import type { EventView } from "@/app/events/types"; -import eventsJson from "@/data/event.json"; -/** Hero / FloatWindow 老版本识别的字段结构(沿用 ActivityEvent schema) */ +/** Hero / FloatWindow 识别的字段结构 */ export interface HomepageEvent { + id: number; name: string; discord: string; playback?: string; coverUrl: string; deprecated: boolean; - /** 新加字段:后端返回的 id,给 "详情" 链接用。JSON fallback 时为 null */ - id: number | null; } interface ApiResponse { @@ -34,11 +31,10 @@ interface ApiResponse { } /** - * 把后端 EventView 映射成 HomepageEvent 老 schema。 - * - discord: 直接用 discordLink;没有时拿活动详情页 URL 兜底 - * - playback: 直接用 playbackUrl - * - coverUrl: 后端 coverUrl 可能为 null;fallback 到一张默认 placeholder - * - deprecated: status=archived 或已过 endTime 时为 true + * 把后端 EventView 映射成 HomepageEvent。 + * - discord: 优先 discordLink;没有时回落到站内详情页 + * - coverUrl: 后端可能为 null,用一张默认 placeholder + * - deprecated: status=archived|cancelled 或已过 endTime */ function toHomepageEvent(ev: EventView): HomepageEvent { return { @@ -52,40 +48,14 @@ function toHomepageEvent(ev: EventView): HomepageEvent { }; } -/** 从 data/event.json fallback(后端不可用时用)。和老组件期待的结构 1:1。 */ -function fallbackFromJson(): HomepageEvent[] { - // event.json 没 id,给 null 占位;order 保持 JSON 里顺序 - interface JsonItem { - name: string; - discord: string; - playback?: string; - coverUrl: string; - deprecated: boolean; - } - const items = (eventsJson as { events: JsonItem[] }).events; - return items.map((e) => ({ - id: null, - name: e.name, - discord: e.discord, - playback: e.playback, - coverUrl: e.coverUrl, - deprecated: e.deprecated, - })); -} - /** - * 拿首页要用的活动列表。 - * - 成功:按"未过期 → 已过期"排序后返回 - * - 失败:fallback 到 JSON - * 失败原因记日志,不向外抛,避免首页 SSR 500 + * 拿首页要用的活动列表。任何失败都返回空数组(记日志,不向外抛,避免首页 SSR 500)。 */ export async function fetchHomepageEvents(): Promise { const backendUrl = process.env.BACKEND_URL; if (!backendUrl) { - console.warn( - "[fetchHomepageEvents] BACKEND_URL 未配置,使用 event.json fallback", - ); - return fallbackFromJson(); + console.warn("[fetchHomepageEvents] BACKEND_URL 未配置,返回空列表"); + return []; } try { @@ -98,22 +68,18 @@ export async function fetchHomepageEvents(): Promise { }); if (!res.ok) { console.warn( - `[fetchHomepageEvents] 后端 ${res.status} ${res.statusText},走 JSON fallback`, + `[fetchHomepageEvents] 后端 ${res.status} ${res.statusText}`, ); - return fallbackFromJson(); + return []; } const json = (await res.json()) as ApiResponse; - if (!json.success || !json.data || json.data.length === 0) { - // 后端虽然返回成功但是空数据,还是走 fallback 保持首页有内容 - return fallbackFromJson(); - } - const items = json.data + if (!json.success || !json.data) return []; + return json.data .map(toHomepageEvent) // 未过期的活动排前面 .sort((a, b) => Number(a.deprecated) - Number(b.deprecated)); - return items; } catch (err) { - console.warn("[fetchHomepageEvents] 网络异常走 JSON fallback:", err); - return fallbackFromJson(); + console.warn("[fetchHomepageEvents] 网络异常:", err); + return []; } } From ad5f414ecee202367658475ea8cef4a4b6a5fced Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:52:49 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(admin):=20/admin=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=20+=20/admin/users=20=E8=B6=85=E7=AE=A1?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=20+=20PR=20#299=20CR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR #299 Copilot CR 修复 1) /admin/events/[id]/edit 的 id 校验改成严格正则 ^[1-9]\d*$。之前用 Number.parseInt 会把 "12ab" 宽松解析成 12,错误编辑到 id=12 的活动。 2) lib/events-fetch.ts 在后端返回 success=false / 缺 data 的分支补一条 console.warn,携带 message / success / hasData,让 server log 能定位。 3) FloatWindow 注释笔误 "/ events" 修成 "/events"。 ## /admin 后台首页 app/admin/layout.tsx Server Component 承载 Header / Footer(之前在 events/layout.tsx 里做的,现在提到 /admin 根 layout 避免重复)。 app/admin/page.tsx 是管理员登录后的入口卡片页: - admin 看到"活动管理"卡片 - superadmin 额外看到"用户管理"卡片(红色强调色,Superadmin only 徽章) 个人主页"管理员界面"按钮的 href 从 /admin/events 改成 /admin。 ## /admin/users 超管用户管理 app/admin/users/page.tsx 是 superadmin 专属页面: - 表格列出所有用户,含头像 / username / displayName / email / roles tag - 每行一个 admin checkbox,乐观更新 + 失败回滚 - superadmin 行禁用 checkbox(产品规则) - 自己的行禁用 checkbox(防止唯一超管自我锁死) - 搜索框前端即时过滤 + 后端同词支持 ?q= ## AdminGuard 支持 required 参数 原先只做 admin 判定。扩展成 required: "admin" | "superadmin": - /admin/events/* 依然 required="admin"(superadmin 也带 admin 角色自动通过) - /admin/users required="superadmin" 严格卡 ## next.config.mjs rewrites 新增 /api/admin/users 和 /api/admin/users/:path* 两条代理规则。 ## 配套后端 involutionhell-backend fix/events-cr-local 分支同步新增 /api/admin/users CRUD(@SaCheckRole("superadmin"))和 EventInterestRepositoryTests 集成测试(7 条 all green)。 --- app/admin/events/AdminGuard.tsx | 38 ++- app/admin/events/[id]/edit/page.tsx | 11 +- app/admin/events/layout.tsx | 26 +- app/admin/layout.tsx | 25 ++ app/admin/page.tsx | 102 ++++++++ app/admin/users/lib.ts | 63 +++++ app/admin/users/page.tsx | 257 ++++++++++++++++++++ app/components/float-window/FloatWindow.tsx | 2 +- app/u/[username]/AdminLinkIfOwnerAdmin.tsx | 2 +- lib/events-fetch.ts | 11 +- next.config.mjs | 26 +- 11 files changed, 517 insertions(+), 46 deletions(-) create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/users/lib.ts create mode 100644 app/admin/users/page.tsx diff --git a/app/admin/events/AdminGuard.tsx b/app/admin/events/AdminGuard.tsx index 2ae4f9a4..e2a805fa 100644 --- a/app/admin/events/AdminGuard.tsx +++ b/app/admin/events/AdminGuard.tsx @@ -3,13 +3,17 @@ /** * 前端管理员页的权限包装器。 * - * 做的事: + * 行为: * - 未登录:跳 /login - * - 登录但不是 admin:渲染 403 提示 - * - 是 admin:渲染 children + * - 登录但不满足 required 角色:渲染 403 提示 + * - 通过:渲染 children * - * 注意这个只是 UX 层的保护——真正的安全由后端 @SaCheckRole("admin") 兜底。 - * 用户只要能绕过这里也拿不到数据,后端直接返回 401/403。 + * required 取值: + * "admin" → roles 包含 admin(superadmin 也通过,因为他们 roles 里也会带 admin) + * "superadmin" → roles 必须包含 superadmin + * + * 这个只是 UX 层保护——真正的安全由后端 @SaCheckRole(...) 兜底,绕过 UI + * 也拿不到数据。 */ import { useEffect } from "react"; @@ -17,7 +21,13 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/use-auth"; import type { ReactNode } from "react"; -export function AdminGuard({ children }: { children: ReactNode }) { +interface Props { + children: ReactNode; + /** 默认 "admin"(事件管理等通用后台页);用户管理页传 "superadmin" */ + required?: "admin" | "superadmin"; +} + +export function AdminGuard({ children, required = "admin" }: Props) { const { user, status } = useAuth(); const router = useRouter(); @@ -43,18 +53,24 @@ export function AdminGuard({ children }: { children: ReactNode }) { if (status === "unauthenticated") return null; - const isAdmin = user?.roles?.includes("admin") ?? false; - if (!isAdmin) { + const roles = user?.roles ?? []; + const passes = required === "superadmin" + ? roles.includes("superadmin") + : roles.includes("admin"); // superadmin 在 seed 里也会带 admin,所以这里一起通过 + if (!passes) { return (
403 · Forbidden
-

你不是管理员

+

+ {required === "superadmin" ? "需要超级管理员权限" : "你不是管理员"} +

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

diff --git a/app/admin/events/[id]/edit/page.tsx b/app/admin/events/[id]/edit/page.tsx index 56a7675e..cd09da75 100644 --- a/app/admin/events/[id]/edit/page.tsx +++ b/app/admin/events/[id]/edit/page.tsx @@ -21,12 +21,11 @@ interface Param { export default function EditEventPage({ params }: Param) { // React 19 的 new-style async params:用 use() 同步解包 Promise const { id } = use(params); - // 路由参数不受控,可能是 "abc"、"12ab" 之类非法字符串;直接 Number() 得到 NaN - // 会让后续请求变成 /api/admin/events/NaN 并报一条迷惑错误("活动不存在")。 - // 用 parseInt 严格解析 + Number.isFinite 双重校验,非法 id 直接传 null - // 让下游渲染一个"id 非法"的错误态,不打请求。 - const parsed = Number.parseInt(id, 10); - const eventId = Number.isFinite(parsed) && parsed > 0 ? parsed : null; + // 路由参数不受控,可能是 "abc"、"12ab" 之类非法字符串。 + // 必须"整串都是正整数"严格校验——不能用 parseInt,因为 "12ab" 会被宽松解析成 + // 12 从而错误编辑到 id=12 的活动。用正则 ^[1-9]\d*$ 拒绝前导零 / 非数字字符 / + // 负号 / 小数点,非法时传 null 让下游渲染错误态,不打请求。 + const eventId = /^[1-9]\d*$/.test(id) ? Number(id) : null; return ( diff --git a/app/admin/events/layout.tsx b/app/admin/events/layout.tsx index 5ab7c002..30c8f8b2 100644 --- a/app/admin/events/layout.tsx +++ b/app/admin/events/layout.tsx @@ -1,25 +1,11 @@ import type { ReactNode } from "react"; -import { Header } from "@/app/components/Header"; -import { Footer } from "@/app/components/Footer"; /** - * Admin 活动后台的共享 layout: - * 因为 Header / Footer 是 Server Component(用 next-intl 的 getTranslations), - * 不能直接在 "use client" 的 page.tsx 里渲染(会触发 - * "getTranslations is not supported in Client Components" 500)。 - * 所以把 Header / Footer 提到这个 Server Component layout 里, - * 让 admin page 自己是纯 client 组件只管渲染内容区。 + * /admin/events/* 子树的 layout。 + * + * 之前这里单独挂 Header / Footer 是因为当时还没有 /admin/layout.tsx。现在根 admin + * 已经有共享 layout,这层只是透传,保留文件是为了 Next 路由分段还能命中。 */ -export default function AdminEventsLayout({ - children, -}: { - children: ReactNode; -}) { - return ( - <> -
- {children} -