Skip to content

Commit e4abaae

Browse files
committed
feat(events): /events 列表+详情+感兴趣 + /admin/events 管理台 + 个人主页管理员入口
配套后端 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 降级界面)。
1 parent 495d49a commit e4abaae

18 files changed

Lines changed: 1723 additions & 87 deletions

File tree

app/admin/events/AdminGuard.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client";
2+
3+
/**
4+
* 前端管理员页的权限包装器。
5+
*
6+
* 做的事:
7+
* - 未登录:跳 /login
8+
* - 登录但不是 admin:渲染 403 提示
9+
* - 是 admin:渲染 children
10+
*
11+
* 注意这个只是 UX 层的保护——真正的安全由后端 @SaCheckRole("admin") 兜底。
12+
* 用户只要能绕过这里也拿不到数据,后端直接返回 401/403。
13+
*/
14+
15+
import { useEffect } from "react";
16+
import { useRouter } from "next/navigation";
17+
import { useAuth } from "@/lib/use-auth";
18+
import type { ReactNode } from "react";
19+
20+
export function AdminGuard({ children }: { children: ReactNode }) {
21+
const { user, status } = useAuth();
22+
const router = useRouter();
23+
24+
useEffect(() => {
25+
if (status === "unauthenticated") {
26+
router.replace("/login?next=/admin/events");
27+
}
28+
}, [status, router]);
29+
30+
if (status === "loading") {
31+
return (
32+
<main className="pt-32 pb-16 min-h-screen flex items-center justify-center">
33+
<p className="font-mono text-xs uppercase tracking-widest text-neutral-500">
34+
Loading…
35+
</p>
36+
</main>
37+
);
38+
}
39+
40+
if (status === "unauthenticated") return null;
41+
42+
const isAdmin = user?.roles?.includes("admin") ?? false;
43+
if (!isAdmin) {
44+
return (
45+
<main className="pt-32 pb-16 min-h-screen flex items-center justify-center px-6">
46+
<div className="max-w-lg border border-[#CC0000] p-8 text-center">
47+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#CC0000] mb-3">
48+
403 · Forbidden
49+
</div>
50+
<h1 className="font-serif text-2xl font-black mb-3">你不是管理员</h1>
51+
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
52+
管理员界面仅对 <code>roles</code> 包含 <code>admin</code>{" "}
53+
的账号开放。 如果你认为这是误报,联系站点维护者。
54+
</p>
55+
</div>
56+
</main>
57+
);
58+
}
59+
60+
return <>{children}</>;
61+
}

app/admin/events/EventForm.tsx

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
"use client";
2+
3+
/**
4+
* Admin 侧的活动表单(新建 + 编辑共用)。
5+
*
6+
* 表单字段基本是 EventRequest 的直接映射。
7+
* 几个 UX 细节:
8+
* - startTime / endTime 用 <input type="datetime-local">,提交前转成 ISO
9+
* - speakers 简化成"每行一条 name",头像/profile URL 暂不支持(延后)
10+
* - tags 用"逗号分隔"单行输入
11+
* - cover_url / discord / playback 都是可选
12+
* - 提交后跳 /admin/events 列表
13+
*/
14+
15+
import { useState, useRef, type FormEvent } from "react";
16+
import { useRouter } from "next/navigation";
17+
import type { EventRequest, EventView, EventStatus } from "@/app/events/types";
18+
import { createEvent, updateEvent } from "./lib";
19+
20+
interface Props {
21+
/** undefined 表示新建;传入 EventView 表示编辑 */
22+
initial?: EventView;
23+
}
24+
25+
const STATUS_OPTIONS: EventStatus[] = [
26+
"draft",
27+
"published",
28+
"archived",
29+
"cancelled",
30+
];
31+
32+
export function EventForm({ initial }: Props) {
33+
const router = useRouter();
34+
const formRef = useRef<HTMLFormElement>(null);
35+
const [submitting, setSubmitting] = useState(false);
36+
const [error, setError] = useState<string | null>(null);
37+
38+
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
39+
e.preventDefault();
40+
setSubmitting(true);
41+
setError(null);
42+
43+
const fd = new FormData(e.currentTarget);
44+
const req: EventRequest = {
45+
title: String(fd.get("title") ?? "").trim(),
46+
description: String(fd.get("description") ?? ""),
47+
coverUrl: emptyToNull(fd.get("coverUrl")),
48+
startTime: datetimeLocalToIso(fd.get("startTime")),
49+
endTime: datetimeLocalToIso(fd.get("endTime")),
50+
discordLink: emptyToNull(fd.get("discordLink")),
51+
playbackUrl: emptyToNull(fd.get("playbackUrl")),
52+
tags: splitCsv(fd.get("tags")),
53+
speakers: parseSpeakers(fd.get("speakers")),
54+
status: (fd.get("status") as EventStatus) ?? "draft",
55+
};
56+
57+
try {
58+
if (initial) {
59+
await updateEvent(initial.id, req);
60+
} else {
61+
await createEvent(req);
62+
}
63+
router.push("/admin/events");
64+
router.refresh();
65+
} catch (err) {
66+
setError(err instanceof Error ? err.message : "保存失败");
67+
setSubmitting(false);
68+
}
69+
};
70+
71+
return (
72+
<form
73+
ref={formRef}
74+
onSubmit={onSubmit}
75+
className="flex flex-col gap-6 max-w-3xl"
76+
>
77+
<Field
78+
label="标题"
79+
name="title"
80+
required
81+
defaultValue={initial?.title ?? ""}
82+
/>
83+
<Field
84+
label="描述"
85+
name="description"
86+
multiline
87+
rows={6}
88+
defaultValue={initial?.description ?? ""}
89+
hint="支持普通文本,段落用空行分隔。"
90+
/>
91+
<Field
92+
label="封面 URL"
93+
name="coverUrl"
94+
defaultValue={initial?.coverUrl ?? ""}
95+
hint="建议 16:9,/event/ 目录下的 webp 最佳。也支持外链。"
96+
/>
97+
98+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
99+
<Field
100+
label="开始时间"
101+
name="startTime"
102+
type="datetime-local"
103+
defaultValue={isoToDatetimeLocal(initial?.startTime)}
104+
/>
105+
<Field
106+
label="结束时间"
107+
name="endTime"
108+
type="datetime-local"
109+
defaultValue={isoToDatetimeLocal(initial?.endTime)}
110+
/>
111+
</div>
112+
113+
<Field
114+
label="Discord 链接"
115+
name="discordLink"
116+
defaultValue={initial?.discordLink ?? ""}
117+
hint="活动当天的 Discord 入口(频道 / Scheduled Event URL)。"
118+
/>
119+
<Field
120+
label="回放链接"
121+
name="playbackUrl"
122+
defaultValue={initial?.playbackUrl ?? ""}
123+
hint="YouTube / 站内 /docs 文章 / Drive 链接均可;YouTube 会被内嵌到详情页。"
124+
/>
125+
126+
<Field
127+
label="Tags (逗号分隔)"
128+
name="tags"
129+
defaultValue={initial?.tags?.join(", ") ?? ""}
130+
hint="如:career, interview, mock。目前只做展示,未来会支持按 tag 筛选。"
131+
/>
132+
133+
<Field
134+
label="Speakers (每行一条)"
135+
name="speakers"
136+
multiline
137+
rows={4}
138+
defaultValue={initial?.speakers?.map((s) => s.name).join("\n") ?? ""}
139+
hint="目前只记录姓名;头像 / profile URL 后续版本支持。"
140+
/>
141+
142+
<div className="flex flex-col gap-1.5">
143+
<label className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
144+
状态
145+
</label>
146+
<select
147+
name="status"
148+
defaultValue={initial?.status ?? "draft"}
149+
className="border border-[var(--foreground)] px-3 py-2 bg-[var(--background)] text-[var(--foreground)] font-mono text-xs uppercase"
150+
>
151+
{STATUS_OPTIONS.map((s) => (
152+
<option key={s} value={s}>
153+
{s}
154+
</option>
155+
))}
156+
</select>
157+
<p className="text-[11px] text-neutral-500 leading-relaxed">
158+
draft = 仅管理员可见;published = 公开;archived = 历史;cancelled =
159+
取消。
160+
</p>
161+
</div>
162+
163+
{error && (
164+
<div className="border border-[#CC0000] p-3 text-sm text-[#CC0000] font-mono">
165+
{error}
166+
</div>
167+
)}
168+
169+
<div className="flex gap-3 pt-2">
170+
<button
171+
type="submit"
172+
disabled={submitting}
173+
className="font-mono text-xs uppercase tracking-widest px-5 py-2 border border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)] hover:bg-[#CC0000] hover:border-[#CC0000] transition-colors disabled:opacity-50"
174+
>
175+
{submitting ? "保存中…" : initial ? "保存修改" : "创建活动"}
176+
</button>
177+
<button
178+
type="button"
179+
onClick={() => router.push("/admin/events")}
180+
className="font-mono text-xs uppercase tracking-widest px-5 py-2 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
181+
>
182+
取消
183+
</button>
184+
</div>
185+
</form>
186+
);
187+
}
188+
189+
interface FieldProps {
190+
label: string;
191+
name: string;
192+
type?: string;
193+
required?: boolean;
194+
defaultValue?: string;
195+
multiline?: boolean;
196+
rows?: number;
197+
hint?: string;
198+
}
199+
200+
function Field({
201+
label,
202+
name,
203+
type = "text",
204+
required,
205+
defaultValue,
206+
multiline,
207+
rows,
208+
hint,
209+
}: FieldProps) {
210+
const base =
211+
"border border-[var(--foreground)] px-3 py-2 bg-[var(--background)] text-[var(--foreground)] font-sans text-sm focus:outline-none focus:border-[#CC0000]";
212+
return (
213+
<div className="flex flex-col gap-1.5">
214+
<label
215+
htmlFor={name}
216+
className="font-mono text-[10px] uppercase tracking-widest text-neutral-500"
217+
>
218+
{label} {required && <span className="text-[#CC0000]">*</span>}
219+
</label>
220+
{multiline ? (
221+
<textarea
222+
id={name}
223+
name={name}
224+
required={required}
225+
rows={rows ?? 4}
226+
defaultValue={defaultValue}
227+
className={`${base} resize-y`}
228+
/>
229+
) : (
230+
<input
231+
id={name}
232+
name={name}
233+
type={type}
234+
required={required}
235+
defaultValue={defaultValue}
236+
className={base}
237+
/>
238+
)}
239+
{hint && (
240+
<p className="text-[11px] text-neutral-500 leading-relaxed">{hint}</p>
241+
)}
242+
</div>
243+
);
244+
}
245+
246+
function emptyToNull(v: FormDataEntryValue | null): string | null {
247+
if (v == null) return null;
248+
const s = String(v).trim();
249+
return s ? s : null;
250+
}
251+
252+
function splitCsv(v: FormDataEntryValue | null): string[] {
253+
if (!v) return [];
254+
return String(v)
255+
.split(",")
256+
.map((x) => x.trim())
257+
.filter(Boolean);
258+
}
259+
260+
/** 每行一个 speaker 名字 → [{name}] */
261+
function parseSpeakers(v: FormDataEntryValue | null) {
262+
if (!v) return [];
263+
return String(v)
264+
.split(/\r?\n/)
265+
.map((l) => l.trim())
266+
.filter(Boolean)
267+
.map((name) => ({ name }));
268+
}
269+
270+
/** <input type="datetime-local"> 的 value 是 "YYYY-MM-DDTHH:mm" 本地时间,无时区;
271+
* 转成 UTC ISO 字符串再给后端。空值返回 null。 */
272+
function datetimeLocalToIso(v: FormDataEntryValue | null): string | null {
273+
if (!v) return null;
274+
const s = String(v).trim();
275+
if (!s) return null;
276+
// new Date("YYYY-MM-DDTHH:mm") 浏览器按本地时区解析
277+
const d = new Date(s);
278+
if (Number.isNaN(d.getTime())) return null;
279+
return d.toISOString();
280+
}
281+
282+
/** ISO 字符串 → datetime-local 输入值。未设置时返回 "". */
283+
function isoToDatetimeLocal(iso: string | null | undefined): string {
284+
if (!iso) return "";
285+
const d = new Date(iso);
286+
if (Number.isNaN(d.getTime())) return "";
287+
// datetime-local 需要 YYYY-MM-DDTHH:mm(无秒无时区)
288+
const pad = (n: number) => String(n).padStart(2, "0");
289+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
290+
}

0 commit comments

Comments
 (0)