import type { Metadata } from "next"; import Link from "next/link"; import { cookies } from "next/headers"; import ReactMarkdown from "react-markdown"; import { Clock, Heart, MessageCircle, Flame, Layers, User, PenLine, ArrowRight, ChevronRight, } from "lucide-react"; import { fetchPostDetail, fetchComments, fetchPosts, fetchUserProfile, type UserProfile, type PostListItem, type Board, } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import CommentSection from "@/components/CommentSection"; import PostActions from "@/components/PostActions"; import LikeButton from "@/components/LikeButton"; import ReadingProgress from "@/components/ReadingProgress"; import PostToc, { type TocItem } from "@/components/PostToc"; import Avatar from "@/components/Avatar"; import { IconComment, IconEye } from "@/components/Icons"; interface PageProps { params: Promise<{ id: string }>; } export async function generateMetadata({ params }: PageProps): Promise { const { id } = await params; try { const { post } = await fetchPostDetail(id); return { title: post.title, description: post.content.slice(0, 160), openGraph: { title: post.title, description: post.content.slice(0, 160), type: "article", }, }; } catch { return { title: "帖子不存在" }; } } /** * 从 Markdown 提取二三级标题生成目录项,id 按出现顺序编号; * 代码围栏内的 # 不计入 */ function extractToc(content: string): TocItem[] { const items: TocItem[] = []; let inCode = false; for (const raw of content.split(/\r?\n/)) { const line = raw.trim(); if (line.startsWith("```")) { inCode = !inCode; continue; } if (inCode) continue; const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line); if (m) items.push({ level: m[1].length as 2 | 3, text: m[2], id: `h-${items.length}` }); } return items; } function formatYearMonth(dateStr: string) { const d = new Date(dateStr); return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`; } 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 }: { profile: UserProfile }) { const u = profile.user; return (

作者

{u.nickname} {u.role === "admin" && ( 管理员 )}

{formatYearMonth(u.created_at)} 加入

{profile.stats.post_count} 发帖
{profile.stats.comment_count} 评论
访问主页
); } function AuthorPosts({ posts }: { posts: PostListItem[] }) { if (posts.length === 0) return null; return (

TA 的其他帖子

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

同板块热议

    {posts.map((p, i) => (
  1. {i + 1} {p.title} {p.comment_count} {p.like_count}
  2. ))}
查看全部
); } /* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */ function AuthorCardStacked({ profile }: { profile: UserProfile }) { return (

关于作者

{profile.user.nickname} {profile.user.role === "admin" && ( 管理员 )}

{formatYearMonth(profile.user.created_at)} 加入 · 发帖 {profile.stats.post_count} · 评论{" "} {profile.stats.comment_count}

访问主页
); } export default async function PostDetailPage({ params }: PageProps) { const { id } = await params; const cookie = authCookieHeader(await cookies()); const { post } = await fetchPostDetail(id, cookie); // 评论 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文 const [commentsData, profile, relatedData] = await Promise.all([ fetchComments(id), fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null), fetchPosts(1, 8, post.board_id, "hot", "", 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 toc = extractToc(post.content); const readingMinutes = Math.max(1, Math.ceil(post.content.length / 400)); const edited = new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000; const jsonLd = { "@context": "https://schema.org", "@type": "DiscussionForumPosting", headline: post.title, articleBody: post.content, datePublished: post.created_at, dateModified: post.updated_at, author: { "@type": "Person", name: post.user.nickname }, commentCount: post.comment_count, interactionStatistic: { "@type": "InteractionCounter", interactionType: { "@type": "ReadAction" }, userInteractionCount: post.view_count, }, }; // 目录项与 Markdown 标题按渲染顺序一一对应(extractToc 顺序 == 文档顺序) let headingIndex = 0; const headingRenderer = (Tag: "h2" | "h3") => function Heading({ children }: { children?: React.ReactNode }) { const item = toc[headingIndex++]; return ( {children} ); }; const tags = post.tags ? [...new Set(post.tags.split(",").map((t) => t.trim()).filter(Boolean))] : []; return ( <>