import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { Clock, Heart, MessageCircle, Flame, Layers, User, PenLine, ArrowRight, ChevronRight, } from "lucide-react"; import { fetchPostDetail, fetchComments, fetchPosts, fetchUserProfile, postDeleteTypeLabel, type UserProfile, type PostListItem, type Board, type CommentsResponse, } from "@/lib/api"; import { postViewerCookieHeader } from "@/lib/cookies"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import CommentSection from "@/components/CommentSection"; import PostActions from "@/components/PostActions"; import LikeButton from "@/components/LikeButton"; import PostToc from "@/components/PostToc"; import { SiteDocBreadcrumb } from "@/components/site-doc"; import MobilePostBar from "@/components/MobilePostBar"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; import MarkdownBody from "@/components/MarkdownBody"; import { publicMarkdownForSeo } from "@/lib/hideBlocks"; import PostAttachments from "@/components/PostAttachments"; import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels"; import AcceptedAnswerCard from "@/components/AcceptedAnswerCard"; import { canModerateBoard } from "@/lib/roles"; import { IconComment, IconEye } from "@/components/Icons"; import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta"; import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc"; interface PageProps { params: Promise<{ id: string }>; // page 同时用于评论分页(?page=N),帖子正文本身不分页 searchParams: Promise<{ page?: string }>; } export async function generateMetadata({ params }: PageProps): Promise { const { id } = await params; try { // 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在" const { post } = await fetchPostDetail(id, postViewerCookieHeader(await cookies())); if (post.tombstone) { return { title: post.title || "帖子已删除", description: "该帖子已被删除", robots: { index: false, follow: false }, }; } const publicBody = publicMarkdownForSeo(post.content || ""); const desc = publicBody ? publicBody.slice(0, 160) : post.access_hint || "部分内容需满足条件后可见"; return { title: post.title, description: desc, openGraph: { title: post.title, description: desc, type: "article", }, }; } catch { return { title: "帖子不存在" }; } } function formatYMD(dateStr: string) { const d = new Date(dateStr); return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String( d.getDate() ).padStart(2, "0")}`; } function formatDateTime(dateStr: string) { return new Date(dateStr).toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } /* ============================================================ 左栏模块(仅 xl 三栏时展示) ============================================================ */ function BoardCard({ board }: { board: Board }) { const colorIdx = Math.max(0, board.color_index ?? 0) % 8; return (

所属板块

{board.name.slice(0, 1)} {board.name} {board.description && (

{board.description}

)} 查看该板讨论
); } function AuthorRailCard({ profile, allowComments, }: { profile: UserProfile; allowComments: boolean; }) { const u = profile.user; const signature = (u.signature || "").trim(); const { post_count, comment_count, points, streak } = profile.stats; const stats = [ { label: "发帖", value: post_count }, ...(allowComments ? [{ label: "评论", value: comment_count }] : []), { label: "积分", value: points }, ]; return (

作者

{u.nickname}

@{u.username}

{u.role !== "user" && (
)}
{/* 有签名则展示;无则留空,不放占位文案 */} {signature ? (

{signature}

) : null}
{stats.map((s, i) => (
0 ? { borderLeft: "1px solid var(--line)" } : undefined} >

{s.value}

{s.label}

))}
{streak > 0 && (

连签 {streak} 天

)} 访问主页
); } function AuthorPosts({ posts }: { posts: PostListItem[] }) { if (posts.length === 0) return null; return (

TA 的其他帖子

); } /* ============================================================ 右栏模块(lg 起展示;移动端下沉到评论之后) ============================================================ */ function RelatedPosts({ posts, boardId, uid = "related", allowComments, }: { posts: PostListItem[]; boardId: number; // 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本 uid?: string; allowComments: boolean; }) { if (posts.length === 0) return null; const titleId = `${uid}-title`; return (

同板块热议

    {posts.map((p, i) => (
  1. {i + 1} {p.title} {allowComments ? ( {p.comment_count} ) : null} {p.like_count}
  2. ))}
查看全部
); } /* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */ function AuthorCardStacked({ profile, allowComments, }: { profile: UserProfile; allowComments: boolean; }) { const u = profile.user; const signature = (u.signature || "").trim(); const { post_count, comment_count, points, streak } = profile.stats; return (

关于作者

{u.nickname} {u.role !== "user" && }

@{u.username} · 发帖 {post_count} {allowComments ? ( <> · 评论 {comment_count} ) : null} · 积分 {points} {streak > 0 && ( <> · 连签 {streak} 天 )}

{signature ? (

{signature}

) : null}
访问主页
); } export default async function PostDetailPage({ params, searchParams }: PageProps) { const { id } = await params; const sp = await searchParams; const commentPage = Math.max(1, parseInt(sp.page || "1", 10) || 1); const cookie = postViewerCookieHeader(await cookies()); // 待审/被拒帖子对无权访问者由后端返回 404,这里落到全局 not-found 页而非 500 let post: Awaited>["post"]; try { ({ post } = await fetchPostDetail(id, cookie)); } catch { notFound(); } // 软删专用页:列表已不可见,原链接展示删除说明 if (post.tombstone) { const typeLabel = postDeleteTypeLabel(post.delete_type); return (

帖子已删除

{post.title || "(无标题)"}

{post.staff_deleted ? (

此帖已被管理员删除

{typeLabel && (

类型: {typeLabel}

)} {post.delete_reason && (

理由: {post.delete_reason}

)}
) : (

作者已删除此帖

)}
返回首页 {post.board?.id ? ( 浏览板块 ) : null}
); } // 站点设置已在 layout 缓存;关评时不拉评论树,避免首屏 HTML 泄露历史评论 const allowComments = (await getPublicSettingsCached()).allow_comments !== false; const emptyComments: CommentsResponse = { comments: [], total: 0, total_comments: 0, page: 1, size: 20, }; const [commentsData, me, profile, relatedData] = await Promise.all([ allowComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments), getMeCached(cookie || undefined), fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null), fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null), ]); const comments = commentsData.comments; const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5); const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5); const publicBody = publicMarkdownForSeo(post.content || ""); const toc = extractToc(publicBody); const headingToc = markdownHeadingToc(toc); const readingMinutes = estimateReadingMinutes(publicBody, post.title.length); const edited = new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000; // 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态 const isPublished = post.status === "published"; const postType = normalizePostType(post.post_type); const typeStatus = typeStatusLabel(postType, post.type_status); const typeStatusCls = typeStatus ? typeStatusClass(postType, post.type_status) : ""; const canManageTyped = !!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id)); const canSeeStatus = canManageTyped; const questionSolved = !!post.question?.solved; const acceptMode = postType === "question" && !questionSolved && canManageTyped ? ("question" as const) : post.bounty?.can_accept ? ("bounty" as const) : null; const acceptedAnswer = post.question?.accepted_answer || post.bounty?.accepted_answer || null; const acceptedAnswerVariant = postType === "bounty" && post.bounty?.accepted_answer ? ("bounty" as const) : ("question" as const); const questionState = postType === "question" ? post.question ?? { solved: false, accepted_comment_id: 0, accepted_floor: 0, can_accept: canManageTyped, can_solve: canManageTyped, can_reopen: false, } : null; const jsonLd = { "@context": "https://schema.org", "@type": "DiscussionForumPosting", headline: post.title, // 仅公开片段进 JSON-LD,避免 SEO 泄露隐藏块原文 articleBody: publicBody || undefined, datePublished: post.created_at, dateModified: post.updated_at, author: { "@type": "Person", name: post.user.nickname }, ...(allowComments ? { commentCount: post.comment_count } : {}), interactionStatistic: { "@type": "InteractionCounter", interactionType: { "@type": "ReadAction" }, userInteractionCount: post.view_count, }, ...(allowComments && acceptedAnswer && !acceptedAnswer.deleted && !!publicBody && { acceptedAnswer: { "@type": "Comment", text: acceptedAnswer.content, dateCreated: acceptedAnswer.created_at, author: acceptedAnswer.user ? { "@type": "Person", name: acceptedAnswer.user.nickname || acceptedAnswer.user.username, } : undefined, url: `#comment-${acceptedAnswer.id}`, }, }), }; // 仅正文 Markdown 标题与 headingRenderer 按序对应(时间线日锚点另有 tl-* id) let headingIndex = 0; const headingRenderer = (Tag: "h2" | "h3") => function Heading({ children }: { children?: React.ReactNode }) { const item = headingToc[headingIndex++]; return ( {children} ); }; const tags = post.tags ? [...new Set(post.tags.split(",").map((t) => t.trim()).filter(Boolean))] : []; return ( <>