|
| 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