Skip to content

Commit 1f391a9

Browse files
feat(community): M7+M8 admin page + user shares page
- app/admin/community/page.tsx: 待审列表 + 通过/拒绝按钮, 复用 AdminGuard - app/admin/community/lib.ts: listPendingLinks / approveLink / rejectLink - app/admin/community/layout.tsx: 透传 layout - app/u/[username]/shares/page.tsx: 本人可见自己所有状态的分享, 带状态 badge - next.config.mjs: 加 /api/admin/community 与 /api/admin/community/:path* rewrite - typecheck 通过
1 parent c1f1dfb commit 1f391a9

5 files changed

Lines changed: 466 additions & 0 deletions

File tree

app/admin/community/layout.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { ReactNode } from "react";
2+
3+
/**
4+
* /admin/community/* 子树的 layout。
5+
*
6+
* 根 /admin/layout.tsx 已经挂了 Header / Footer,这层仅透传。
7+
* 保留文件是让 Next 路由分段能命中,必要时在这里插入 community 专属的 Tab / sidebar。
8+
*/
9+
export default function AdminCommunityLayout({
10+
children,
11+
}: {
12+
children: ReactNode;
13+
}) {
14+
return <>{children}</>;
15+
}

app/admin/community/lib.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
3+
/**
4+
* Admin 侧 Community 的 API client(纯 client)。
5+
*
6+
* 参照 /admin/events/lib.ts 的做法:
7+
* - 所有请求带 satoken header(从 localStorage 读)
8+
* - 响应统一解包后端 ApiResponse<T>
9+
*
10+
* 对应后端:/api/admin/community/* (走 @SaCheckRole("admin"))
11+
*/
12+
13+
import type { SharedLinkView } from "@/app/feed/types";
14+
15+
interface ApiResponse<T> {
16+
success: boolean;
17+
data?: T;
18+
message?: string;
19+
}
20+
21+
function token(): string | null {
22+
if (typeof window === "undefined") return null;
23+
return localStorage.getItem("satoken");
24+
}
25+
26+
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
27+
const t = token();
28+
const res = await fetch(url, {
29+
...init,
30+
headers: {
31+
"content-type": "application/json",
32+
accept: "application/json",
33+
...(t ? { satoken: t } : {}),
34+
...(init.headers ?? {}),
35+
},
36+
});
37+
const json = (await res.json()) as ApiResponse<T>;
38+
if (!res.ok || !json.success) {
39+
throw new Error(json.message ?? `请求失败 ${res.status}`);
40+
}
41+
if (json.data === undefined) {
42+
throw new Error("后端返回 success 但没有 data");
43+
}
44+
return json.data;
45+
}
46+
47+
/** 拉取管理员待审列表(PENDING_MANUAL + FLAGGED) */
48+
export function listPendingLinks(): Promise<SharedLinkView[]> {
49+
return request<SharedLinkView[]>("/api/admin/community/pending");
50+
}
51+
52+
/** 通过一条链接,状态置 APPROVED */
53+
export function approveLink(id: number): Promise<SharedLinkView> {
54+
return request<SharedLinkView>(`/api/admin/community/${id}/approve`, {
55+
method: "POST",
56+
});
57+
}
58+
59+
/** 拒绝一条链接,状态置 REJECTED */
60+
export function rejectLink(
61+
id: number,
62+
reason?: string,
63+
): Promise<SharedLinkView> {
64+
return request<SharedLinkView>(`/api/admin/community/${id}/reject`, {
65+
method: "POST",
66+
body: JSON.stringify({ reason: reason ?? null }),
67+
});
68+
}

app/admin/community/page.tsx

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"use client";
2+
3+
/**
4+
* /admin/community — 管理员审核社区分享链接。
5+
*
6+
* 权限:包在 <AdminGuard> 里。
7+
* 数据:GET /api/admin/community/pending 拉 PENDING_MANUAL + FLAGGED 两种状态。
8+
* 交互:每条两个动作——通过(→ APPROVED)/ 拒绝(→ REJECTED)。
9+
*
10+
* 为什么不用复杂表格:v1 预计审核频率很低(每周一次扫),
11+
* 简单的卡片列表加两按钮足矣;后续量大了再做分页 + 批量操作。
12+
*/
13+
14+
import { useEffect, useState } from "react";
15+
import Image from "next/image";
16+
import { AdminGuard } from "@/app/admin/events/AdminGuard";
17+
import type { SharedLinkView } from "@/app/feed/types";
18+
import { approveLink, listPendingLinks, rejectLink } from "./lib";
19+
20+
export default function AdminCommunityPage() {
21+
return (
22+
<AdminGuard>
23+
<AdminCommunityInner />
24+
</AdminGuard>
25+
);
26+
}
27+
28+
// FLAGGED 的原因标签(来自后端 AI 判定的 flags JSON)
29+
function renderFlagBadges(link: SharedLinkView) {
30+
// flags 目前前端 DTO 里没直接暴露,这里预留位——M7 后端返回 flags 后再补
31+
if (link.status !== "FLAGGED") return null;
32+
return (
33+
<span className="rounded-full bg-red-100 text-red-900 px-2 py-0.5 text-xs font-medium">
34+
AI 判定需要复核
35+
</span>
36+
);
37+
}
38+
39+
function AdminCommunityInner() {
40+
const [links, setLinks] = useState<SharedLinkView[]>([]);
41+
const [loading, setLoading] = useState(true);
42+
const [error, setError] = useState<string | null>(null);
43+
// 记录正在处理的 link id,避免一条链接按两次
44+
const [workingId, setWorkingId] = useState<number | null>(null);
45+
46+
const load = async () => {
47+
setLoading(true);
48+
setError(null);
49+
try {
50+
setLinks(await listPendingLinks());
51+
} catch (e) {
52+
setError(e instanceof Error ? e.message : "加载失败");
53+
} finally {
54+
setLoading(false);
55+
}
56+
};
57+
58+
useEffect(() => {
59+
void load();
60+
}, []);
61+
62+
const onApprove = async (id: number) => {
63+
setWorkingId(id);
64+
try {
65+
await approveLink(id);
66+
// 审核后直接从列表中移除(通过的不再出现在待审)
67+
setLinks((xs) => xs.filter((x) => x.id !== id));
68+
} catch (e) {
69+
alert(e instanceof Error ? e.message : "通过失败");
70+
} finally {
71+
setWorkingId(null);
72+
}
73+
};
74+
75+
const onReject = async (id: number) => {
76+
const reason = prompt("拒绝原因(可选,留空直接拒绝):") ?? undefined;
77+
setWorkingId(id);
78+
try {
79+
await rejectLink(id, reason || undefined);
80+
setLinks((xs) => xs.filter((x) => x.id !== id));
81+
} catch (e) {
82+
alert(e instanceof Error ? e.message : "拒绝失败");
83+
} finally {
84+
setWorkingId(null);
85+
}
86+
};
87+
88+
return (
89+
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
90+
<div className="max-w-6xl mx-auto px-6 lg:px-8">
91+
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
92+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
93+
Admin · Community
94+
</div>
95+
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase mt-2 tracking-tight">
96+
社区分享审核
97+
</h1>
98+
<p className="mt-3 text-sm text-neutral-500">
99+
这里列出所有 PENDING_MANUAL(非白名单域名)和 FLAGGED(AI 判定风险)
100+
的链接。审核频率预期很低(每周一次),按需处理即可。
101+
</p>
102+
</header>
103+
104+
{loading && <p className="text-sm text-neutral-500">加载中...</p>}
105+
106+
{error && (
107+
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
108+
加载失败:{error}
109+
<button
110+
className="ml-3 underline"
111+
type="button"
112+
onClick={() => void load()}
113+
>
114+
重试
115+
</button>
116+
</div>
117+
)}
118+
119+
{!loading && !error && links.length === 0 && (
120+
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-neutral-500">
121+
当前没有需要审核的链接。
122+
</div>
123+
)}
124+
125+
{!loading && links.length > 0 && (
126+
<ul className="space-y-4">
127+
{links.map((link) => (
128+
<li
129+
key={link.id}
130+
className="border border-[var(--foreground)]/40 p-4 flex flex-col md:flex-row gap-4"
131+
>
132+
{/* 左:OG 封面缩略图(没抓到就占位) */}
133+
<div className="w-full md:w-40 aspect-[16/9] flex-shrink-0 bg-neutral-100 dark:bg-neutral-900 relative overflow-hidden">
134+
{link.ogCover ? (
135+
<Image
136+
src={link.ogCover}
137+
alt={link.ogTitle ?? link.url}
138+
fill
139+
sizes="160px"
140+
className="object-cover"
141+
unoptimized
142+
/>
143+
) : (
144+
<span className="absolute inset-0 flex items-center justify-center text-3xl font-bold text-neutral-400">
145+
{link.host[0]?.toUpperCase() ?? "?"}
146+
</span>
147+
)}
148+
</div>
149+
150+
{/* 中:元信息 */}
151+
<div className="flex-1 min-w-0">
152+
<div className="flex items-center gap-2 flex-wrap">
153+
<span
154+
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
155+
link.status === "FLAGGED"
156+
? "bg-red-100 text-red-900"
157+
: "bg-orange-100 text-orange-900"
158+
}`}
159+
>
160+
{link.status === "FLAGGED" ? "AI 标记" : "非白名单"}
161+
</span>
162+
{renderFlagBadges(link)}
163+
<span className="text-xs text-neutral-500 font-mono">
164+
{link.host}
165+
</span>
166+
</div>
167+
<a
168+
href={link.url}
169+
target="_blank"
170+
rel="noopener noreferrer"
171+
className="block mt-2 font-semibold text-base hover:underline truncate"
172+
title={link.ogTitle ?? link.url}
173+
>
174+
{link.ogTitle ?? link.url}
175+
</a>
176+
{link.ogDescription && (
177+
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-300 line-clamp-2">
178+
{link.ogDescription}
179+
</p>
180+
)}
181+
{link.recommendation && (
182+
<p className="mt-2 text-xs text-neutral-500 italic">
183+
推荐:{link.recommendation}
184+
</p>
185+
)}
186+
<p className="mt-2 text-xs text-neutral-400">
187+
提交人 #{link.submitterId} ·{" "}
188+
{new Date(link.createdAt).toLocaleString()}
189+
</p>
190+
</div>
191+
192+
{/* 右:操作按钮 */}
193+
<div className="flex md:flex-col gap-2 md:w-32 md:flex-shrink-0">
194+
<button
195+
type="button"
196+
disabled={workingId === link.id}
197+
onClick={() => void onApprove(link.id)}
198+
className="flex-1 px-3 py-2 text-sm font-mono uppercase tracking-wider bg-[var(--foreground)] text-[var(--background)] hover:bg-emerald-600 transition-colors disabled:opacity-50"
199+
>
200+
{workingId === link.id ? "..." : "通过"}
201+
</button>
202+
<button
203+
type="button"
204+
disabled={workingId === link.id}
205+
onClick={() => void onReject(link.id)}
206+
className="flex-1 px-3 py-2 text-sm font-mono uppercase tracking-wider border border-[var(--foreground)] hover:bg-[#CC0000] hover:text-white hover:border-[#CC0000] transition-colors disabled:opacity-50"
207+
>
208+
拒绝
209+
</button>
210+
</div>
211+
</li>
212+
))}
213+
</ul>
214+
)}
215+
</div>
216+
</main>
217+
);
218+
}

0 commit comments

Comments
 (0)