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

752 lines
31 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,
ChevronRight,
Pin,
Star,
} from "lucide-react";
import {
fetchPostDetail,
fetchComments,
fetchPosts,
fetchUserProfile,
fetchSidebar,
postDeleteTypeLabel,
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 MobileRailDrawers from "@/components/MobileRailDrawers";
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, formatReadingDuration, 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<Metadata> {
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 (
<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={postPath(urlStyle, 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>
);
}
/* ============================================================
右栏模块(lg 起展示;移动端收进右抽屉)
============================================================ */
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 缓存;关评或「登录可见」且游客时不拉评论树,避免首屏泄露
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<number, string> = {};
const boardColors: Record<number, number> = {};
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]";
// 移动端抽屉内容(lg 以下):左抽屉 = 左栏侧栏块,右抽屉 = 右栏侧栏块(含文章目录)+ 同板块热议;
// 桌面常驻 aside 与抽屉共用渲染函数,uid 区分同屏两份 DOM 的 aria-labelledby
const leftRailNode = hasLeftRail ? (
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
) : null;
const hasTocBlock = sidebarLists.right.some((r) => r.key === "post_toc");
const renderRightRail = (uid: string) => (
<>
{hasRightRail ? (
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
) : null}
{related.length > 0 ? (
<RelatedPosts
posts={related}
boardId={post.board_id}
uid={uid}
allowComments={allowComments}
urlStyle={urlStyle}
/>
) : null}
</>
);
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
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
.map((c, i): JsonLdVisibleComment | null =>
!c.deleted && (c.content || "").trim()
? {
id: c.id,
floor: (commentPage - 1) * 20 + i + 1,
is_root: true,
content: c.content,
created_at: c.created_at,
author: c.user
? { id: c.user.id, name: c.user.nickname || c.user.username }
: undefined,
}
: null
)
.filter((x): x is JsonLdVisibleComment => x !== null)
: [];
const acceptedForLd: JsonLdVisibleComment | null =
canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
? {
id: acceptedAnswer.id,
floor: acceptedAnswer.floor,
is_root: acceptedAnswer.is_root,
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 (
<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 (
<>
<JsonLd data={jsonLd} />
{/* 移动端:吸顶工具栏 + 左右抽屉(导航 / 目录),lg 起隐藏;两侧皆无内容时组件自身不渲染 */}
<MobileRailDrawers
center={
<>
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
{post.title}
</p>
<p className="meta mt-0.5 text-[10.5px] leading-tight">约 {formatReadingDuration(readingMinutes)} 读完</p>
</>
}
left={leftRailNode ? { label: "导航", content: leftRailNode } : undefined}
right={
hasRightRail || related.length > 0
? {
label: hasTocBlock ? "目录" : "热议",
content: renderRightRail("related-drawer"),
}
: undefined
}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
<div
className={`grid grid-cols-1 items-start gap-x-6 xl:gap-x-7 ${railGridClass}`}
>
{/* 左栏:按配置渲染(缺省 = 板块 / 作者 / 作者其他帖) */}
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
{hasLeftRail && (
<aside
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="帖子导航"
>
{leftRailNode}
</aside>
)}
{/* 中栏:正文主体 */}
<div className="min-w-0">
<article className="w-full">
<div className="panel p-4 sm:p-6 lg:p-8 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<header className="mb-5 sm:mb-7">
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 置顶/推荐与标题包在同一不可折行的 flex item 内:外层 flex-wrap 收集行时只看
已在行内元素的收缩能力,shrink-0 徽章会让超长 h1 整体掉到第二行;内层 min-w-0
使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
<div className="flex items-start gap-2 min-w-0">
{post.pinned > 0 && (
<span className="badge-pin shrink-0 mt-2 sm:mt-3" title="置顶" aria-label="置顶">
<Pin size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐">
<Star size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
</div>
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
{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={userPath(urlStyle, 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={userPath(urlStyle, 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>
{/* 等级 + 管理员颁布徽章 + 站点角色徽章收拢为一组(详情接口已 Preload,随 post.user 返回) */}
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<LevelBadge level={post.user.level} className="shrink-0" />
{(post.user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} className="shrink-0" />
))}
{post.user.role !== "user" && (
<RoleBadge
role={post.user.role}
// board_ids 仅 /api/profile 返回;profile 拉取失败回落纯角色徽章
boardIds={profile?.user.board_ids}
boardNames={boardNames}
boardColors={boardColors}
className="shrink-0"
/>
)}
</div>
</div>
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
<PostActions
postId={id}
authorId={post.user.id}
authorRole={post.user.role}
boardId={post.board_id}
pinned={post.pinned}
recommended={post.recommended}
status={post.status}
createdAt={post.created_at}
locked={post.locked}
postEditLockHours={publicSettings.post_edit_lock_hours}
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={`阅读约需 ${formatReadingDuration(readingMinutes)}`}>
<Clock size={14} /> 约 {formatReadingDuration(readingMinutes)} 读完
</span>
</div>
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
发布于 <time dateTime={post.created_at}>{formatDateTime(post.created_at)}</time>
{edited && (
<>
{" "}
· 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time>
{me.user && (
<>
{" "}·{" "}
<PostEditedMark
postId={post.id}
currentTitle={post.title}
currentContent={post.content || ""}
/>
</>
)}
</>
)}
</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-6 pt-5 sm:mt-9 sm:pt-7 flex justify-center gap-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<LikeButton
postId={id}
liked={post.liked}
likeCount={post.like_count}
variant="article"
/>
<FavoriteButton postId={id} favorited={post.favorited === true} variant="article" />
</div>
</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}
postLocked={post.locked}
commentEditLockHours={publicSettings.comment_edit_lock_hours}
necroReply={post.necro_reply}
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-4 sm:p-6 lg:p-8 mt-4 text-center max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
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}
</article>
</div>
{/* 右栏:按配置渲染(缺省 = 文章目录)+ 固定的同板块热议 */}
{hasRightRail && (
<aside
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="阅读辅助"
>
{renderRightRail("related")}
</aside>
)}
</div>
</>
);
}