新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
729 lines
30 KiB
TypeScript
729 lines
30 KiB
TypeScript
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 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, 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
|
||
.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 (
|
||
<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">约 {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">
|
||
{/* 仅置顶/推荐在标题左侧 */}
|
||
{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={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={`阅读约需 ${readingMinutes} 分钟`}>
|
||
<Clock size={14} /> 约 {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>
|
||
</>
|
||
);
|
||
}
|