diff --git a/app/admin/community/layout.tsx b/app/admin/community/layout.tsx new file mode 100644 index 0000000..90a2a51 --- /dev/null +++ b/app/admin/community/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +/** + * /admin/community/* 子树的 layout。 + * + * 根 /admin/layout.tsx 已经挂了 Header / Footer,这层仅透传。 + * 保留文件是让 Next 路由分段能命中,必要时在这里插入 community 专属的 Tab / sidebar。 + */ +export default function AdminCommunityLayout({ + children, +}: { + children: ReactNode; +}) { + return <>{children}; +} diff --git a/app/admin/community/lib.ts b/app/admin/community/lib.ts new file mode 100644 index 0000000..463a0dc --- /dev/null +++ b/app/admin/community/lib.ts @@ -0,0 +1,68 @@ +"use client"; + +/** + * Admin 侧 Community 的 API client(纯 client)。 + * + * 参照 /admin/events/lib.ts 的做法: + * - 所有请求带 satoken header(从 localStorage 读) + * - 响应统一解包后端 ApiResponse + * + * 对应后端:/api/admin/community/* (走 @SaCheckRole("admin")) + */ + +import type { SharedLinkView } from "@/app/feed/types"; + +interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +function token(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("satoken"); +} + +async function request(url: string, init: RequestInit = {}): Promise { + const t = token(); + const res = await fetch(url, { + ...init, + headers: { + "content-type": "application/json", + accept: "application/json", + ...(t ? { satoken: t } : {}), + ...(init.headers ?? {}), + }, + }); + const json = (await res.json()) as ApiResponse; + if (!res.ok || !json.success) { + throw new Error(json.message ?? `请求失败 ${res.status}`); + } + if (json.data === undefined) { + throw new Error("后端返回 success 但没有 data"); + } + return json.data; +} + +/** 拉取管理员待审列表(PENDING_MANUAL + FLAGGED) */ +export function listPendingLinks(): Promise { + return request("/api/admin/community/pending"); +} + +/** 通过一条链接,状态置 APPROVED */ +export function approveLink(id: number): Promise { + return request(`/api/admin/community/${id}/approve`, { + method: "POST", + }); +} + +/** 拒绝一条链接,状态置 REJECTED */ +export function rejectLink( + id: number, + reason?: string, +): Promise { + return request(`/api/admin/community/${id}/reject`, { + method: "POST", + body: JSON.stringify({ reason: reason ?? null }), + }); +} diff --git a/app/admin/community/page.tsx b/app/admin/community/page.tsx new file mode 100644 index 0000000..3eb8290 --- /dev/null +++ b/app/admin/community/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +/** + * /admin/community — 管理员审核社区分享链接。 + * + * 权限:包在 里。 + * 数据:GET /api/admin/community/pending 拉 PENDING_MANUAL + FLAGGED 两种状态。 + * 交互:每条两个动作——通过(→ APPROVED)/ 拒绝(→ REJECTED)。 + * + * 为什么不用复杂表格:v1 预计审核频率很低(每周一次扫), + * 简单的卡片列表加两按钮足矣;后续量大了再做分页 + 批量操作。 + */ + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { AdminGuard } from "@/app/admin/events/AdminGuard"; +import type { SharedLinkView } from "@/app/feed/types"; +import { sanitizeExternalUrl } from "@/lib/url-safety"; +import { approveLink, listPendingLinks, rejectLink } from "./lib"; + +export default function AdminCommunityPage() { + return ( + + + + ); +} + +// FLAGGED 的原因标签(来自后端 AI 判定的 flags JSON) +function renderFlagBadges(link: SharedLinkView) { + // flags 目前前端 DTO 里没直接暴露,这里预留位——M7 后端返回 flags 后再补 + if (link.status !== "FLAGGED") return null; + return ( + + AI 判定需要复核 + + ); +} + +function AdminCommunityInner() { + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // 记录正在处理的 link id,避免一条链接按两次 + const [workingId, setWorkingId] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + setLinks(await listPendingLinks()); + } catch (e) { + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const onApprove = async (id: number) => { + setWorkingId(id); + try { + await approveLink(id); + // 审核后直接从列表中移除(通过的不再出现在待审) + setLinks((xs) => xs.filter((x) => x.id !== id)); + } catch (e) { + alert(e instanceof Error ? e.message : "通过失败"); + } finally { + setWorkingId(null); + } + }; + + const onReject = async (id: number) => { + const reason = prompt("拒绝原因(可选,留空直接拒绝):") ?? undefined; + setWorkingId(id); + try { + await rejectLink(id, reason || undefined); + setLinks((xs) => xs.filter((x) => x.id !== id)); + } catch (e) { + alert(e instanceof Error ? e.message : "拒绝失败"); + } finally { + setWorkingId(null); + } + }; + + return ( +
+
+
+
+ Admin · Community +
+

+ 社区分享审核 +

+

+ 这里列出所有 PENDING_MANUAL(非白名单域名)和 FLAGGED(AI 判定风险) + 的链接。审核频率预期很低(每周一次),按需处理即可。 +

+
+ + {loading &&

加载中...

} + + {error && ( +
+ 加载失败:{error} + +
+ )} + + {!loading && !error && links.length === 0 && ( +
+ 当前没有需要审核的链接。 +
+ )} + + {!loading && links.length > 0 && ( +
    + {links.map((link) => ( +
  • + {/* 左:OG 封面缩略图(没抓到就占位) */} +
    + {link.ogCover ? ( + {link.ogTitle + ) : ( + + {link.host[0]?.toUpperCase() ?? "?"} + + )} +
    + + {/* 中:元信息 */} +
    +
    + + {link.status === "FLAGGED" ? "AI 标记" : "非白名单"} + + {renderFlagBadges(link)} + + {link.host} + +
    + {(() => { + // defense-in-depth:后端 UrlNormalizer 已拒非 http/https, + // 前端仍用 sanitizeExternalUrl 兜底过滤 javascript:/data: 协议。 + const safe = sanitizeExternalUrl(link.url); + const title = link.ogTitle ?? link.url; + return safe ? ( + + {title} + + ) : ( + + {title} ⚠ + + ); + })()} + {link.ogDescription && ( +

    + {link.ogDescription} +

    + )} + {link.recommendation && ( +

    + 推荐:{link.recommendation} +

    + )} +

    + 提交人 #{link.submitterId} ·{" "} + {new Date(link.createdAt).toLocaleString()} +

    +
    + + {/* 右:操作按钮 */} +
    + + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/app/components/Contribute.tsx b/app/components/Contribute.tsx index 4e46352..41c9119 100644 --- a/app/components/Contribute.tsx +++ b/app/components/Contribute.tsx @@ -162,7 +162,8 @@ export function Contribute() { } }} > -
+ {/* mt 由外层容器控制;本组件只负责按钮 + 徽章的相对定位 */} +
diff --git a/app/components/ShareLink.tsx b/app/components/ShareLink.tsx new file mode 100644 index 0000000..b28917d --- /dev/null +++ b/app/components/ShareLink.tsx @@ -0,0 +1,58 @@ +"use client"; + +/** + * Hero 区的"分享链接"按钮。 + * + * 视觉与样式**完全复制** Contribute 主 CTA,和它并排形成双 CTA: + * - Contribute → 正式投稿 Fumadocs 知识库(走 GitHub PR) + * - ShareLink → 随手分享公众号/知乎等文章到社区墙(/feed) + * + * 两者语义平级,视觉也平级——这是用户拍板的设计(之前尝试的次级文字链 UI 不够突出)。 + * 按钮点击跳 /feed(先看一眼再决定是否提交),右上角徽章保留与 Contribute 对称的图标位。 + */ + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Link2, Plus } from "lucide-react"; + +export function ShareLink() { + const t = useTranslations("shareLink"); + + return ( +
+ {/* 主按钮跳 /feed(社区分享墙),样式与 Contribute 主按钮完全同构 */} + + + + {/* 右上角徽章:跳 /feed/submit 直接开提交表单,对应 Contribute 的指南 "?" 徽章 */} + + + {t("submitAriaLabel")} + +
+ ); +} diff --git a/app/feed/components/CategoryTabs.tsx b/app/feed/components/CategoryTabs.tsx new file mode 100644 index 0000000..34f4209 --- /dev/null +++ b/app/feed/components/CategoryTabs.tsx @@ -0,0 +1,66 @@ +"use client"; + +/** + * 分类 tab 导航组件。 + * 通过 URL searchParams(?category=)来控制当前选中项, + * 保持 SSR 可读且可书签化,避免纯 client state 无法分享链接。 + */ + +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { type CategorySlug, CATEGORY_SLUGS } from "@/app/feed/types"; +import { cn } from "@/lib/utils"; + +export function CategoryTabs() { + const t = useTranslations("feed.category"); + const router = useRouter(); + const searchParams = useSearchParams(); + + // 当前选中的分类 slug,空字符串代表"全部" + const current = (searchParams.get("category") ?? "") as CategorySlug | ""; + + /** + * 点击分类时更新 URL query,不需要 push history stack—— + * 用 replace 避免用户反复点分类时返回键卡死。 + */ + function handleSelect(slug: CategorySlug | "") { + const params = new URLSearchParams(searchParams.toString()); + if (slug) { + params.set("category", slug); + } else { + params.delete("category"); + } + router.replace(`/feed?${params.toString()}`); + } + + const allTabs: Array<{ slug: CategorySlug | ""; label: string }> = [ + { slug: "", label: t("all") }, + ...CATEGORY_SLUGS.map((slug) => ({ + slug, + label: t(slug), + })), + ]; + + return ( + // 横向滚动容器,移动端展示全部分类时不截断 +
+ {allTabs.map(({ slug, label }) => ( + + ))} +
+ ); +} diff --git a/app/feed/components/FeedAuthWrapper.tsx b/app/feed/components/FeedAuthWrapper.tsx new file mode 100644 index 0000000..f27187b --- /dev/null +++ b/app/feed/components/FeedAuthWrapper.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * FeedAuthWrapper —— client 组件桥接器。 + * + * /feed/page.tsx 是 SSR server component,无法感知 localStorage 登录态; + * 本组件在 client 端读取 useAuth() 后,把 isLoggedIn 传给 LinkCard, + * 使举报按钮可以区分已登录 / 未登录行为。 + * + * 接收 server 端已预计算好的 links 和 categoryLabel 函数, + * 只负责登录态桥接,不做额外数据请求。 + */ + +import { useAuth } from "@/lib/use-auth"; +import { LinkCard } from "@/app/feed/components/LinkCard"; +import type { SharedLinkView, CategorySlug } from "@/app/feed/types"; + +interface FeedAuthWrapperProps { + links: SharedLinkView[]; + /** 由 server 端传入的分类标签计算函数(已含 i18n 翻译) */ + getCategoryLabel: (slug: CategorySlug | null) => string; +} + +export function FeedAuthWrapper({ + links, + getCategoryLabel, +}: FeedAuthWrapperProps) { + const { status } = useAuth(); + // loading 阶段默认视为未登录,避免 UI 闪烁 + const isLoggedIn = status === "authenticated"; + + return ( + // 响应式 grid:桌面 3 列 / 平板 2 列 / 手机 1 列 +
    + {links.map((link) => ( + + ))} +
+ ); +} diff --git a/app/feed/components/LinkCard.tsx b/app/feed/components/LinkCard.tsx new file mode 100644 index 0000000..72749ee --- /dev/null +++ b/app/feed/components/LinkCard.tsx @@ -0,0 +1,116 @@ +/** + * 社区分享链接卡片。 + * - 整卡可点击,跳转到原文(target="_blank") + * - OG 封面:有则显示,没有则渲染 host 首字母占位块 + * - 举报按钮由 ReportButton 组件负责,阻止冒泡不触发整卡跳转 + * - 服务端渲染(纯展示,无 client state),ReportButton 是 client 组件 + */ + +import { useTranslations } from "next-intl"; +import type { SharedLinkView } from "@/app/feed/types"; +import { ReportButton } from "@/app/feed/components/ReportButton"; +import { Badge } from "@/components/ui/badge"; + +interface LinkCardProps { + link: SharedLinkView; + /** 分类显示名(由父组件从 i18n 翻译后传入,避免在纯 server 组件里调 useTranslations) */ + categoryLabel: string; + /** 当前用户是否已登录(影响举报按钮行为) */ + isLoggedIn: boolean; +} + +/** 从 host 字符串提取首字母大写,作为封面占位符 */ +function getHostInitial(host: string): string { + const cleaned = host.replace(/^www\./, ""); + return (cleaned[0] ?? "?").toUpperCase(); +} + +export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) { + const t = useTranslations("feed.card"); + + return ( +
  • + {/* 整卡可点击区域,跳到原文 */} + + {/* OG 封面 / 占位块 */} + {link.ogCover && !link.ogFetchFailed ? ( + // next/image 全站 unoptimized:true,用 img 即可(与 events 页一致) + // eslint-disable-next-line @next/next/no-img-element + {link.ogTitle + ) : ( + // 无封面:显示 host 首字母占位 +
    + + {getHostInitial(link.host)} + + + {link.host} + + {link.ogFetchFailed && ( + // OG 抓取失败时给用户一个弱提示 + + {t("ogFallback")} + + )} +
    + )} + + {/* 卡片内容区 */} +
    + {/* 标题 */} +

    + {link.ogTitle ?? link.url} +

    + + {/* OG 描述 / 用户推荐语 */} + {(link.recommendation || link.ogDescription) && ( +

    + {/* 用户推荐语优先展示,没有则展示 OG description */} + {link.recommendation ?? link.ogDescription} +

    + )} + + {/* 分类 badge + 失效标记 */} +
    + {link.category && ( + + {categoryLabel} + + )} + {link.status === "ARCHIVED" && ( + + {t("archivedBadge")} + + )} +
    + + {/* 提交人 + host 来源 */} +
    + {link.host} +
    +
    +
    + + {/* 举报区:与整卡点击分离(ReportButton 内部阻止冒泡) */} +
    + +
    +
  • + ); +} diff --git a/app/feed/components/ReportButton.tsx b/app/feed/components/ReportButton.tsx new file mode 100644 index 0000000..e493f84 --- /dev/null +++ b/app/feed/components/ReportButton.tsx @@ -0,0 +1,128 @@ +"use client"; + +/** + * 举报按钮 + 举报 Dialog 组件。 + * - 未登录:点击时 toast 提示需要登录 + * - 已登录:弹出 Dialog,填写可选原因后提交 POST /api/community/links/{id}/report + * 参照 Contribute.tsx 的 Dialog 模式实现。 + */ + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Flag } from "lucide-react"; + +interface ReportButtonProps { + /** 被举报的链接 ID */ + linkId: number; + /** 当前用户是否已登录(由父组件/页面传入,避免在每张卡片都重新请求 session) */ + isLoggedIn: boolean; +} + +export function ReportButton({ linkId, isLoggedIn }: ReportButtonProps) { + const t = useTranslations("feed.report"); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [done, setDone] = useState(false); // 举报成功后隐藏按钮 + + /** + * 未登录时点击举报,弹浏览器原生 alert(轻量,避免引入额外 toast provider 依赖)。 + * 后续如果需要引导跳登录页,可改成 router.push。 + */ + function handleUnauth(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); // 阻止冒泡,不触发整卡链接跳转 + alert(t("loginRequired")); + } + + async function handleSubmit() { + setSubmitting(true); + try { + const res = await fetch(`/api/community/links/${linkId}/report`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: reason.trim() || undefined }), + }); + if (res.ok) { + setDone(true); + setOpen(false); + // 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast + alert(t("successToast")); + } + } finally { + setSubmitting(false); + } + } + + // 已举报成功,不再显示按钮 + if (done) return null; + + // 未登录:直接用普通按钮,点击触发提示 + if (!isLoggedIn) { + return ( + + ); + } + + return ( + + + + + + e.stopPropagation()} + > + + {t("title")} + + +
    + +