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, ChevronRight, } from "lucide-react"; import { fetchPostDetail, fetchComments, fetchPosts, fetchUserProfile, fetchSidebar, postDeleteTypeLabel, type UserProfile, type PostListItem, type CommentsResponse, type SidebarPublicPayload, } from "@/lib/api"; import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta"; import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer"; import { postViewerCookieHeader } from "@/lib/cookies"; import { getMeCached, getPublicSettingsCached, getBoardsCached } from "@/lib/serverData"; import CommentSection from "@/components/CommentSection"; import PostActions from "@/components/PostActions"; import LikeButton from "@/components/LikeButton"; import FavoriteButton from "@/components/FavoriteButton"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; import LevelBadge from "@/components/LevelBadge"; import BadgeChip from "@/components/BadgeChip"; import { boardColor } from "@/components/BoardIcon"; import MarkdownBody from "@/components/MarkdownBody"; import { publicMarkdownForSeo } from "@/lib/hideBlocks"; import PostAttachments from "@/components/PostAttachments"; import PostEditedMark from "@/components/PostEditedMark"; 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 { postPath, userPath, type UrlStyle } from "@/lib/urlStyle"; 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); const urlStyle = (await getPublicSettingsCached()).url_style; return { title: post.title, description: desc, openGraph: { title: post.title, description: desc, type: "article", }, ...(await requestCanonical(postPath(urlStyle, post.id), page ? { page } : undefined)), }; } catch { return { title: "帖子不存在" }; } } 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", }); } /* ============================================================ 右栏模块(lg 起展示;移动端下沉到评论之后) ============================================================ */ function RelatedPosts({ posts, boardId, uid = "related", allowComments, urlStyle, }: { posts: PostListItem[]; boardId: number; // 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本 uid?: string; allowComments: boolean; urlStyle: UrlStyle; }) { 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, boardNames, boardColors, urlStyle, }: { profile: UserProfile; allowComments: boolean; /** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */ boardNames?: Record; boardColors?: Record; urlStyle: UrlStyle; }) { const u = profile.user; const signature = (u.signature || "").trim(); const { post_count, comment_count, points, streak } = profile.stats; return (

关于作者

{u.nickname} {(u.badges ?? []).slice(0, 2).map((ub) => ( ))} {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 urlStyle = publicSettings.url_style; 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, sidebarData, boardsData] = 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), // 侧栏布局配置失败不阻塞页面,回落默认布局 fetchSidebar(cookie).catch(() => null), // 板块名/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 getBoardsCached().catch(() => null), ]); // 板块 id → 名称 / 配色下标(与板块列表、头像底色同一套 av-N 色板) const boardNames: Record = {}; const boardColors: Record = {}; for (const b of boardsData?.boards ?? []) { boardNames[b.id] = b.name; boardColors[b.id] = boardColor(b); } const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] }; 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 = post.edited ?? false; // 侧栏布局:post 范围左右两列(toc 需在下方已就绪) const sidebarLists = sidebarScopeLists(sidebar.config, "post"); const hasLeftRail = sidebarLists.left.length > 0; const hasRightRail = sidebarLists.right.length > 0; const sidebarProps: SidebarBlockProps = { allowComments, postBoard: post.board, authorProfile: profile ?? undefined, authorPosts, toc, boardNames, boardColors, }; // 三档网格退化:双栏 / 仅左 / 仅右 / 单栏 const railGridClass = !hasLeftRail && !hasRightRail ? "" : hasLeftRail && hasRightRail ? "lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px]" : hasLeftRail ? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]" : "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]"; // 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态 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(postPath(urlStyle, post.id)); const pageUrl = absoluteCanonical(postPath(urlStyle, 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, urlStyle, 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, urlStyle, 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 缩成内容高而失去行程 */} {hasLeftRail && ( )} {/* 中栏:正文主体 */}
{/* 仅置顶/推荐在标题左侧 */} {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} {/* 等级 + 管理员颁布徽章 + 站点角色徽章收拢为一组(详情接口已 Preload,随 post.user 返回) */}
{(post.user.badges ?? []).slice(0, 2).map((ub) => ( ))} {post.user.role !== "user" && ( )}
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行); 原右栏“帖子数据”卡已移除,点赞数由点赞按钮承载,此处不重复 */}
{post.view_count} 浏览 {allowComments ? ( {post.comment_count} 评论 ) : null} 约 {readingMinutes} 分钟读完

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

{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 起由右栏承载) */}
{/* 右栏:按配置渲染(缺省 = 文章目录)+ 固定的同板块热议 */} {hasRightRail && ( )}
); }