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"; import { requestCanonical } from "@/lib/canonicalMeta"; import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl"; import { discussionJsonLd, questionJsonLd, type JsonLdVisibleComment } from "@/lib/jsonLd"; import JsonLd from "@/components/JsonLd"; interface PageProps { params: Promise<{ id: string }>; // page 同时用于评论分页(?page=N),帖子正文本身不分页 searchParams: Promise<{ page?: string }>; } export async function generateMetadata({ params, searchParams }: PageProps): Promise { const { id } = await params; const sp = await searchParams; 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 || "部分内容需满足条件后可见"; const page = canonicalPage(sp.page); return { title: post.title, description: desc, openGraph: { title: post.title, description: desc, type: "article", }, ...(await requestCanonical(`/post/${post.id}`, page ? { page } : undefined)), }; } 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 缓存;关评或「登录可见」且游客时不拉评论树,避免首屏泄露 const publicSettings = await getPublicSettingsCached(); const allowComments = publicSettings.allow_comments !== false; const commentsRequireLogin = publicSettings.comments_require_login === true; const emptyComments: CommentsResponse = { comments: [], total: 0, total_comments: 0, page: 1, size: 20, }; const meFirst = await getMeCached(cookie || undefined); const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user); const [commentsData, profile, relatedData] = await Promise.all([ canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments), fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null), fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null), ]); const me = meFirst; 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 entityUrl = absoluteCanonical(`/post/${post.id}`); const pageUrl = absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) || entityUrl; const authorName = post.user.nickname || post.user.username; const visibleFloors: JsonLdVisibleComment[] = canFetchComments ? comments .filter((c) => !c.deleted && (c.content || "").trim()) .map((c) => ({ id: c.id, content: c.content, created_at: c.created_at, author: c.user ? { id: c.user.id, name: c.user.nickname || c.user.username } : undefined, })) : []; const acceptedForLd: JsonLdVisibleComment | null = canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim() ? { id: acceptedAnswer.id, content: acceptedAnswer.content, created_at: acceptedAnswer.created_at, author: acceptedAnswer.user ? { id: acceptedAnswer.user.id, name: acceptedAnswer.user.nickname || acceptedAnswer.user.username, } : undefined, } : null; // 问答帖才是「一问多答」;悬赏/投票仍是讨论帖,不套 QAPage,也不把采纳标成 Question.acceptedAnswer const jsonLd = postType === "question" ? questionJsonLd({ title: post.title, text: publicBody || undefined, createdAt: post.created_at, updatedAt: edited ? post.updated_at : undefined, author: { id: post.user.id, name: authorName }, postId: post.id, pageUrl, accepted: acceptedForLd, answers: visibleFloors, }) : discussionJsonLd({ title: post.title, text: publicBody || undefined, createdAt: post.created_at, updatedAt: edited ? post.updated_at : undefined, author: { id: post.user.id, name: authorName }, postId: post.id, pageUrl, commentCount: canFetchComments ? post.comment_count : undefined, viewCount: post.view_count, comments: visibleFloors, }); // 仅正文 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 ( <> {/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */} {/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
{/* 左栏:板块 / 作者 / 作者其他帖 */} {/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程 等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */} {/* 中栏:正文主体 */}
{/* 仅置顶/推荐在标题左侧 */} {post.pinned > 0 && 置顶} {post.recommended && 推荐}

{post.title}

{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */} {postType !== "discussion" && postType !== "question" && ( {postTypeLabel(postType)} )} {typeStatus && typeStatusCls && ( {typeStatus} )} {post.content_access && post.content_access !== "public" && ( {contentAccessLabel(post.content_access)} )} {!isPublished && canSeeStatus && post.status === "pending" && ( 等待审核 )} {!isPublished && canSeeStatus && post.status === "rejected" && ( 未通过审核 )}
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
{post.user.nickname}
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行); 原右栏“帖子数据”卡已移除,点赞数由点赞按钮承载,此处不重复 */}
{post.view_count} 浏览 {allowComments ? ( {post.comment_count} 评论 ) : null} 约 {readingMinutes} 分钟读完

发布于 {edited && ( <> {" "} · 更新于 )}

{tags.length > 0 && (
{tags.map((t, i) => ( # {t} ))}
)} {/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */} {allowComments && acceptedAnswer ? ( ) : questionState ? ( ) : null} {/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */} {profile && (
)} {/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */} {isPublished ? ( w.comment_id || 0) .filter((cid) => cid > 0) : [] } /> ) : allowComments ? (

{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}

{me.user?.id === post.user.id ? post.status === "pending" ? "你的帖子正在等待管理员审核,请留意通知" : "如有疑问可联系管理团队" : "审核通过后评论区将自动开放"}

) : null} {/* 窄屏相关讨论(lg 起由右栏承载) */}
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
); }