feat: 新增帖子最后回复展示、文章目录与三栏布局,优化点赞按钮与导航栏

这是一次包含多个功能升级的提交:
1. 后端新增批量查询帖子最后回复的逻辑,在前端帖子列表项中展示最后回复信息
2. 重构点赞按钮,新增文章样式变体并统一样式逻辑
3. 重写工作台导航栏,精简默认展示的菜单选项
4. 新增文章目录组件,为帖子详情页添加文章目录导航
5. 重构帖子详情页,实现三栏响应式布局,新增板块卡片、作者信息栏与同板块热议推荐
6. 重构帖子列表行组件,优化排版与信息展示逻辑
7. 重构帖子管理操作组件,使用下拉菜单整合所有管理功能
8. 新增全局通用的滚动容器与点赞按钮样式
This commit is contained in:
2026-09-13 04:06:56 +08:00
parent bee06fa962
commit 52124f2682
10 changed files with 926 additions and 226 deletions

View File

@@ -2,15 +2,34 @@ import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import ReactMarkdown from "react-markdown";
import { fetchPostDetail, fetchComments } from "@/lib/api";
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 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 Avatar from "@/components/Avatar";
import { IconComment, IconEye } from "@/components/Icons";
import { ChevronRight } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
@@ -34,11 +53,285 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
}
}
/**
* 从 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 }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const { post } = await fetchPostDetail(id, cookie);
const { comments } = await fetchComments(id);
// 评论 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
const [commentsData, profile, relatedData] = await Promise.all([
fetchComments(id),
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",
@@ -56,84 +349,162 @@ export default async function PostDetailPage({ params }: PageProps) {
},
};
// 目录项与 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 (
<article className="max-w-3xl mx-auto">
<>
<ReadingProgress />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<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>
{/* 三栏阅读布局: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="panel p-6 sm:p-9">
<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-5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<div className="flex items-center gap-4 flex-wrap">
<Link
href={`/u/${post.user.id}`}
className="flex items-center gap-2.5 group"
>
<Avatar name={post.user.nickname || post.user.username} size={40} />
<span className="text-sm font-semibold group-hover:text-[var(--accent)] transition-colors" style={{ color: "var(--ink)" }}>
{post.user.nickname}
</span>
{/* 中栏:正文主体 */}
<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>
<span className="meta">{new Date(post.created_at).toLocaleString("zh-CN")}</span>
<span className="inline-flex items-center gap-1.5 meta">
<IconEye size={14} /> {post.view_count}
</span>
<span className="inline-flex items-center gap-1.5 meta">
<IconComment size={14} /> {post.comment_count}
</span>
</div>
<div className="flex items-center gap-2">
<LikeButton postId={id} liked={post.liked} likeCount={post.like_count} />
<PostActions postId={id} authorId={post.user.id} pinned={post.pinned} recommended={post.recommended} />
</div>
</div>
</header>
</nav>
{post.tags && (
<div className="flex flex-wrap gap-2 mb-7">
{[
...new Set(
post.tags
.split(",")
.map((t) => t.trim())
.filter(Boolean)
),
].map((t, i) => (
<span key={`${t}-${i}`} className="chip">
# {t}
</span>
))}
</div>
)}
<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="prose-content">
<ReactMarkdown>{post.content}</ReactMarkdown>
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
<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} />
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-6">
<RelatedPosts posts={related} boardId={post.board_id} uid="related-inline" />
</div>
</article>
</div>
</div>
<CommentSection postId={id} comments={comments} />
</article>
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
<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>
</>
);
}