Files
jiang13-bbs/frontend/app/post/[id]/page.tsx

847 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, { type TocItem } from "@/components/PostToc";
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";
interface PageProps {
params: Promise<{ id: string }>;
// page 同时用于评论分页(?page=N),帖子正文本身不分页
searchParams: Promise<{ page?: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
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: "帖子不存在" };
}
}
/**
* 从 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 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 (
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
<p className="j13-module-title mb-3" id="board-card-title">
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
</p>
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
<span
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
>
{board.name.slice(0, 1)}
</span>
<span
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{board.name}
</span>
</Link>
{board.description && (
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{board.description}
</p>
)}
<Link
href={`/?board=${board.id}`}
className="btn btn-line btn-sm w-full justify-center mt-3.5"
>
查看该板讨论 <ArrowRight size={13} />
</Link>
</section>
);
}
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 (
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
<p className="j13-module-title mb-3" id="author-card-title">
<User size={15} style={{ color: "var(--accent)" }} /> 作者
</p>
<div className="flex items-center gap-3">
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
</Link>
<div className="min-w-0">
<Link
href={`/u/${u.id}`}
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<p className="meta mt-0.5 truncate">@{u.username}</p>
{u.role !== "user" && (
<div className="mt-1">
<RoleBadge role={u.role} boardIds={u.board_ids} />
</div>
)}
</div>
</div>
{/* 有签名则展示;无则留空,不放占位文案 */}
{signature ? (
<p
className="mt-3 text-[12.5px] leading-relaxed line-clamp-3"
style={{ color: "var(--ink-2)" }}
title={signature}
>
{signature}
</p>
) : null}
<div
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
style={{ borderColor: "var(--line)" }}
>
{stats.map((s, i) => (
<div
key={s.label}
className="px-1.5 py-2.5 text-center"
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
>
<p
className="text-[17px] font-semibold leading-none tabular-nums"
style={{ color: "var(--ink)" }}
>
{s.value}
</p>
<p className="mt-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
{s.label}
</p>
</div>
))}
</div>
{streak > 0 && (
<p
className="mt-2 flex items-center justify-center gap-1 text-[11.5px] font-medium"
style={{ color: "var(--gold)" }}
>
<Flame size={11.5} /> 连签 {streak} 天
</p>
)}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
访问主页
</Link>
</section>
);
}
function AuthorPosts({ posts }: { posts: PostListItem[] }) {
if (posts.length === 0) return null;
return (
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
</p>
<ul className="j13-rail-scroll">
{posts.map((p) => (
<li key={p.id}>
<Link
href={`/post/${p.id}`}
data-tip={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<span
className="block text-[13px] leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{p.title}
</span>
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
</Link>
</li>
))}
</ul>
</section>
);
}
/* ============================================================
右栏模块(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 (
<section className="j13-module shrink-0" aria-labelledby={titleId}>
<p className="j13-module-title mb-1.5" id={titleId}>
<Flame size={15} style={{ color: "var(--clay)" }} /> 同板块热议
</p>
<ol>
{posts.map((p, i) => (
<li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="j13-rank-title line-clamp-2">
{p.title}
</span>
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
{allowComments ? (
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
) : null}
<span className="inline-flex items-center gap-1">
<Heart size={11.5} /> {p.like_count}
</span>
</span>
</span>
</Link>
</li>
))}
</ol>
<Link
href={`/?board=${boardId}`}
className="j13-raillink justify-center mt-1.5 text-[12.5px]"
style={{ color: "var(--accent)" }}
>
查看全部 <ChevronRight size={13} />
</Link>
</section>
);
}
/* 移动端 / 窄屏作者卡(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 (
<div className="panel p-5 sm:p-6">
<p
className="text-[11.5px] font-medium tracking-[0.08em] uppercase mb-3.5"
style={{ color: "var(--ink-3)" }}
>
关于作者
</p>
<div className="flex items-start gap-4 flex-wrap">
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={52} />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/u/${u.id}`}
className="font-semibold text-[15px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
{u.role !== "user" && <RoleBadge role={u.role} boardIds={u.board_ids} />}
</div>
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
<span>@{u.username}</span>
<span aria-hidden>·</span>
<span>发帖 {post_count}</span>
{allowComments ? (
<>
<span aria-hidden>·</span>
<span>评论 {comment_count}</span>
</>
) : null}
<span aria-hidden>·</span>
<span>积分 {points}</span>
{streak > 0 && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-0.5 font-medium" style={{ color: "var(--gold)" }}>
<Flame size={11} /> 连签 {streak} 天
</span>
</>
)}
</p>
{signature ? (
<p className="mt-2 text-[13px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{signature}
</p>
) : null}
</div>
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm shrink-0 self-center">
访问主页
</Link>
</div>
</div>
);
}
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<ReturnType<typeof fetchPostDetail>>["post"];
try {
({ post } = await fetchPostDetail(id, cookie));
} catch {
notFound();
}
// 软删专用页:列表已不可见,原链接展示删除说明
if (post.tombstone) {
const typeLabel = postDeleteTypeLabel(post.delete_type);
return (
<div className="max-w-lg mx-auto py-16 px-4 text-center">
<p
className="inline-flex items-center rounded-full px-3 py-1 text-[12px] font-semibold mb-4"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
帖子已删除
</p>
<h1 className="text-[20px] sm:text-[22px] font-extrabold mb-3" style={{ color: "var(--ink)" }}>
{post.title || "(无标题)"}
</h1>
{post.staff_deleted ? (
<div
className="panel text-left p-5 sm:p-6 mt-2"
style={{ borderColor: "var(--danger-soft)" }}
>
<p className="text-[14px] font-medium mb-3" style={{ color: "var(--ink)" }}>
此帖已被管理员删除
</p>
{typeLabel && (
<p className="text-[13px] mb-2" style={{ color: "var(--ink-2)" }}>
<span style={{ color: "var(--ink-3)" }}>类型:</span>
{typeLabel}
</p>
)}
{post.delete_reason && (
<p className="text-[13px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
<span style={{ color: "var(--ink-3)" }}>理由:</span>
{post.delete_reason}
</p>
)}
</div>
) : (
<p className="meta text-[14px] mt-2">作者已删除此帖</p>
)}
<div className="mt-8 flex items-center justify-center gap-3">
<Link href="/" className="btn btn-line btn-sm">
返回首页
</Link>
{post.board?.id ? (
<Link href={`/?board=${post.board_id}`} className="btn btn-primary btn-sm">
浏览板块
</Link>
) : null}
</div>
</div>
);
}
// 站点设置已在 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 readingMinutes = Math.max(
1,
Math.ceil((publicBody.length > 0 ? publicBody.length : post.title.length) / 400)
);
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 标题按渲染顺序一一对应(extractToc 顺序 == 文档顺序)
let headingIndex = 0;
const headingRenderer = (Tag: "h2" | "h3") =>
function Heading({ children }: { children?: React.ReactNode }) {
const item = toc[headingIndex++];
return (
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
{children}
</Tag>
);
};
const tags = post.tags
? [...new Set(post.tags.split(",").map((t) => t.trim()).filter(Boolean))]
: [];
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
<MobilePostBar
postId={id}
liked={post.liked}
likeCount={post.like_count}
allowComments={allowComments}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
{/* 左栏:板块 / 作者 / 作者其他帖 */}
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
<aside
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
aria-label="帖子导航"
>
<BoardCard board={post.board} />
{profile && <AuthorRailCard profile={profile} allowComments={allowComments} />}
{profile && <AuthorPosts posts={authorPosts} />}
</aside>
{/* 中栏:正文主体 */}
<div className="min-w-0">
<article className="w-full">
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<Link href={`/?board=${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
{post.board.name}
</Link>
</nav>
<div className="panel p-6 sm:p-8">
<header className="mb-7">
<div className="flex items-center gap-2 mb-4 flex-wrap">
<Link href={`/?board=${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0 mt-2 sm:mt-3">推荐</span>}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
{postType !== "discussion" && postType !== "question" && (
<span className="badge-type shrink-0 mt-2 sm:mt-3">{postTypeLabel(postType)}</span>
)}
{typeStatus && typeStatusCls && (
<span className={`${typeStatusCls} shrink-0 mt-2 sm:mt-3`}>{typeStatus}</span>
)}
{post.content_access && post.content_access !== "public" && (
<span className="badge-access shrink-0 mt-2 sm:mt-3">
{contentAccessLabel(post.content_access)}
</span>
)}
{!isPublished && canSeeStatus && post.status === "pending" && (
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-medium shrink-0 mt-2 sm:mt-3"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
等待审核
</span>
)}
{!isPublished && canSeeStatus && post.status === "rejected" && (
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-medium shrink-0 mt-2 sm:mt-3"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
未通过审核
</span>
)}
</div>
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
<div
className="flex items-center justify-between gap-4 flex-wrap pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<div className="flex items-center gap-3 min-w-0">
<Link href={`/u/${post.user.id}`} className="shrink-0" aria-label={post.user.nickname}>
<Avatar name={post.user.nickname || post.user.username} src={post.user.avatar || undefined} size={40} />
</Link>
<Link
href={`/u/${post.user.id}`}
className="min-w-0 truncate text-[15px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{post.user.nickname}
</Link>
</div>
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
<PostActions
postId={id}
authorId={post.user.id}
boardId={post.board_id}
pinned={post.pinned}
recommended={post.recommended}
status={post.status}
title={post.title}
/>
</div>
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);
原右栏“帖子数据”卡已移除,点赞数由点赞按钮承载,此处不重复 */}
<div className="mt-3.5 flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-4 meta">
<span className="inline-flex items-center gap-1.5" aria-label={`浏览 ${post.view_count} 次`}>
<IconEye size={14} /> {post.view_count} 浏览
</span>
{allowComments ? (
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
<IconComment size={14} /> {post.comment_count} 评论
</span>
) : null}
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${readingMinutes} 分钟`}>
<Clock size={14} /> 约 {readingMinutes} 分钟读完
</span>
</div>
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
发布于 {formatDateTime(post.created_at)}
{edited && <> · 更新于 {formatDateTime(post.updated_at)}</>}
</p>
</div>
</header>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-7">
{tags.map((t, i) => (
<span key={`${t}-${i}`} className="chip">
# {t}
</span>
))}
</div>
)}
<MarkdownBody
content={post.content}
postId={post.id}
loggedIn={!!me.user}
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
/>
<PostAttachments
postId={post.id}
items={post.attachments || []}
loggedIn={!!me.user}
/>
{/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */}
<PostInteractPanels
postId={post.id}
poll={post.poll}
bounty={post.bounty}
lottery={post.lottery}
loggedIn={!!me.user}
/>
{allowComments && acceptedAnswer ? (
<AcceptedAnswerCard
answer={acceptedAnswer}
variant={acceptedAnswerVariant}
solvedAt={
post.question?.solved_at ||
post.bounty?.accepted_answer?.created_at ||
acceptedAnswer.created_at
}
/>
) : questionState ? (
<QuestionStatusCard postId={post.id} state={questionState} />
) : null}
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
<div
className="mt-9 pt-7 flex justify-center"
style={{ borderTop: "1px solid var(--line)" }}
>
<LikeButton
postId={id}
liked={post.liked}
likeCount={post.like_count}
variant="article"
/>
</div>
</div>
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
<div className="lg:hidden mt-6">
<AuthorCardStacked profile={profile} allowComments={allowComments} />
</div>
)}
{/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */}
{isPublished ? (
<CommentSection
postId={id}
boardId={post.board_id}
authorId={post.user.id}
comments={comments}
total={commentsData.total}
totalComments={commentsData.total_comments}
page={commentsData.page}
size={commentsData.size}
user={me.user}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||
post.bounty?.accepted_comment_id ||
0
}
winnerCommentIds={
post.lottery?.drawn
? (post.lottery.winners || [])
.map((w) => w.comment_id || 0)
.filter((cid) => cid > 0)
: []
}
/>
) : allowComments ? (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}
</p>
<p className="meta mt-1.5 text-[12.5px]">
{me.user?.id === post.user.id
? post.status === "pending"
? "你的帖子正在等待管理员审核,请留意通知"
: "如有疑问可联系管理团队"
: "审核通过后评论区将自动开放"}
</p>
</section>
) : null}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-6">
<RelatedPosts
posts={related}
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
/>
</div>
</article>
</div>
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
<aside
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
aria-label="阅读辅助"
>
{toc.length > 0 && <PostToc items={toc} />}
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
</aside>
</div>
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
<div className="lg:hidden h-16" aria-hidden />
</>
);
}