Files
jiang13-bbs/frontend/app/post/[id]/page.tsx
freefire 996a870015 feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化:
1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示
2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制
3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频
4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化
5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
2026-09-13 21:38:01 +08:00

531 lines
20 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 { 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 { 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 { IconComment, IconEye } from "@/components/Icons";
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);
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 (
<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} 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 === "admin" && (
<span
className="chip mt-1.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
管理员
</span>
)}
</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}`}
title={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)]"
>
<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" title={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} 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 === "admin" && (
<span
className="chip"
style={{ background: "var(--accent-soft)", color: "var(--accent)", borderColor: "transparent" }}
>
管理员
</span>
)}
</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());
const { post } = await fetchPostDetail(id, cookie);
// 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
const [commentsData, me, profile, relatedData] = await Promise.all([
fetchComments(id, commentPage),
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 = 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 (
<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} />
{/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
<div className="grid grid-cols-1 items-start lg:grid-cols-[minmax(0,1fr)_260px] xl:grid-cols-[240px_minmax(0,1fr)_280px] gap-x-6 xl:gap-x-7">
{/* 左栏:板块 / 作者 / 作者其他帖 */}
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
<aside
className="hidden xl:flex w-[240px] 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 max-w-[760px] mx-auto">
<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">
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
{post.recommended && <span className="badge-rec">推荐</span>}
<Link href={`/board/${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<h1 className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight mb-6" style={{ color: "var(--ink)" }}>
{post.title}
</h1>
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
<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} 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} 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>
)}
<div className="prose-content">
<ReactMarkdown components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}>
{post.content}
</ReactMarkdown>
</div>
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
<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>
{/* 窄屏作者卡(xl 起由左栏承载,避免重复展示) */}
{profile && (
<div className="xl:hidden mt-6">
<AuthorCardStacked profile={profile} />
</div>
)}
<CommentSection
postId={id}
comments={comments}
total={commentsData.total}
totalComments={commentsData.total_comments}
page={commentsData.page}
size={commentsData.size}
user={me.user}
/>
{/* 窄屏相关讨论(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-[260px] xl:w-[280px] 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 />
</>
);
}