feat: 实现完整的板块级RBAC内容审核系统

- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
This commit is contained in:
2026-09-15 04:47:34 +08:00
parent 6da4309453
commit a7b6421840
44 changed files with 3149 additions and 585 deletions

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import ReactMarkdown from "react-markdown";
import {
@@ -31,6 +32,8 @@ 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 { isStaff } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
interface PageProps {
@@ -42,7 +45,8 @@ interface PageProps {
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
try {
const { post } = await fetchPostDetail(id);
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
return {
title: post.title,
description: post.content.slice(0, 160),
@@ -157,14 +161,7 @@ function AuthorRailCard({ profile }: { profile: UserProfile }) {
>
{u.nickname}
</Link>
{u.role === "admin" && (
<span
className="chip mt-1.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
管理员
</span>
)}
{u.role !== "user" && <RoleBadge role={u.role} boardIds={u.board_ids} />}
</div>
</div>
<p className="meta mt-3">{formatYearMonth(u.created_at)} 加入</p>
@@ -295,13 +292,8 @@ function AuthorCardStacked({ profile }: { profile: UserProfile }) {
>
{profile.user.nickname}
</Link>
{profile.user.role === "admin" && (
<span
className="chip"
style={{ background: "var(--accent-soft)", color: "var(--accent)", borderColor: "transparent" }}
>
管理员
</span>
{profile.user.role !== "user" && (
<RoleBadge role={profile.user.role} boardIds={profile.user.board_ids} />
)}
</div>
<p className="meta mt-1">
@@ -322,11 +314,17 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
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);
// 待审/被拒帖子对无权访问者由后端返回 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),
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),
@@ -340,6 +338,10 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const canSeeStatus = !!me.user && (me.user.id === post.user.id || isStaff(me.user.role));
const jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
@@ -409,6 +411,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<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>}
{!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"
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"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
未通过审核
</span>
)}
<Link href={`/board/${post.board.id}`} className="chip">
{post.board.name}
</Link>
@@ -435,7 +453,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</Link>
</div>
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
<PostActions postId={id} authorId={post.user.id} pinned={post.pinned} recommended={post.recommended} />
<PostActions postId={id} authorId={post.user.id} boardId={post.board_id} pinned={post.pinned} recommended={post.recommended} />
</div>
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);
@@ -496,15 +514,32 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</div>
)}
<CommentSection
postId={id}
comments={comments}
total={commentsData.total}
totalComments={commentsData.total_comments}
page={commentsData.page}
size={commentsData.size}
user={me.user}
/>
{/* 非公开帖不开放评论(后端同样拒绝),给出状态提示 */}
{isPublished ? (
<CommentSection
postId={id}
boardId={post.board_id}
comments={comments}
total={commentsData.total}
totalComments={commentsData.total_comments}
page={commentsData.page}
size={commentsData.size}
user={me.user}
/>
) : (
<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">