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 ? ( +