Files
jiang13-bbs/frontend/app/post/[id]/page.tsx
freefire b9ba8cb8c9 feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:17:07 +08:00

645 lines
26 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,
type UserProfile,
type PostListItem,
type Board,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
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 MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import MarkdownBody from "@/components/MarkdownBody";
import ContentLockGate from "@/components/ContentLockGate";
import PostAttachments from "@/components/PostAttachments";
import PostInteractPanels from "@/components/PostInteractPanels";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } 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, authCookieHeader(await cookies()));
const desc = post.content_locked
? post.access_hint || "部分内容需满足条件后可见"
: post.content.slice(0, 160);
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 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 (
<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-bold 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 }: { profile: UserProfile }) {
const u = profile.user;
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-bold text-[14.5px] truncate 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>
</div>
<p className="meta mt-3">{formatYearMonth(u.created_at)} 加入</p>
<div className="grid grid-cols-2 gap-2 mt-2.5">
<div className="j13-stat">
<span className="j13-stat-num">{profile.stats.post_count}</span>
<span className="j13-stat-lab">发帖</span>
</div>
<div className="j13-stat">
<span className="j13-stat-num">{profile.stats.comment_count}</span>
<span className="j13-stat-lab">评论</span>
</div>
</div>
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3.5">
访问主页
</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] font-semibold 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",
}: {
posts: PostListItem[];
boardId: number;
// 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本
uid?: string;
}) {
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="block text-[13px] font-semibold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{p.title}
</span>
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
<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] font-semibold"
style={{ color: "var(--accent)" }}
>
查看全部 <ChevronRight size={13} />
</Link>
</section>
);
}
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
function AuthorCardStacked({ profile }: { profile: UserProfile }) {
return (
<div className="panel p-5 sm:p-6">
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-3.5"
style={{ color: "var(--ink-3)" }}
>
关于作者
</p>
<div className="flex items-center gap-4 flex-wrap">
<Link href={`/u/${profile.user.id}`} className="shrink-0" aria-label={profile.user.nickname}>
<Avatar name={profile.user.nickname || profile.user.username} src={profile.user.avatar || undefined} size={52} />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/u/${profile.user.id}`}
className="font-bold text-[15px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{profile.user.nickname}
</Link>
{profile.user.role !== "user" && (
<RoleBadge role={profile.user.role} boardIds={profile.user.board_ids} />
)}
</div>
<p className="meta mt-1">
{formatYearMonth(profile.user.created_at)} 加入 · 发帖 {profile.stats.post_count} · 评论{" "}
{profile.stats.comment_count}
</p>
</div>
<Link href={`/u/${profile.user.id}`} className="btn btn-line btn-sm shrink-0">
访问主页
</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 = authCookieHeader(await cookies());
// 待审/被拒帖子对无权访问者由后端返回 404,这里落到全局 not-found 页而非 500
let post: Awaited<ReturnType<typeof fetchPostDetail>>["post"];
try {
({ post } = await fetchPostDetail(id, cookie));
} catch {
notFound();
}
// 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
const [commentsData, me, profile, relatedData] = await Promise.all([
fetchComments(id, commentPage, cookie),
getMeCached(cookie || undefined),
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 = post.content_locked ? [] : extractToc(post.content);
const readingMinutes = Math.max(
1,
Math.ceil((post.content_locked ? post.title.length : post.content.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 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 jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: post.title,
// 锁定正文不进 JSON-LD,避免 SEO 泄露付费/回复可见内容
articleBody: post.content_locked ? undefined : 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 (
<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 (
<>
<ReadingProgress />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
<MobilePostBar postId={id} liked={post.liked} likeCount={post.like_count} />
{/* 三栏阅读布局:宽度与首页工作台一致——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} />}
{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 && (
<span className="badge-access 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-bold 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-bold 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-bold 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} />
</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>
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
<IconComment size={14} /> {post.comment_count} 评论
</span>
<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>
)}
<PostInteractPanels
postId={post.id}
question={
postType === "question"
? post.question ?? {
solved: false,
accepted_comment_id: 0,
can_accept: canManageTyped,
can_solve: canManageTyped,
can_reopen: false,
}
: post.question
}
poll={post.poll}
bounty={post.bounty}
lottery={post.lottery}
loggedIn={!!me.user}
/>
{post.content_locked ? (
<ContentLockGate
postId={post.id}
hint={post.access_hint}
access={post.content_access}
points={post.access_points}
loggedIn={!!me.user}
/>
) : (
<MarkdownBody
content={post.content}
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
/>
)}
{!post.content_locked && (
<PostAttachments postId={post.id} items={post.attachments || []} />
)}
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
<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} />
</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
}
/>
) : (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
<p className="text-[14px] font-bold" 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>
)}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-6">
<RelatedPosts posts={related} boardId={post.board_id} uid="related-inline" />
</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} />
</aside>
</div>
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
<div className="lg:hidden h-16" aria-hidden />
</>
);
}