feat: 就地通过评审,并为已过审用户提供默认可关的免审发布

帖/评管理入口可直接通过;站点默认信任已有 published 内容的用户后续直发。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:37:57 +08:00
parent b0170a99fd
commit f3145b24ff
14 changed files with 330 additions and 40 deletions

View File

@@ -58,7 +58,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
if (!normalized || saving) return;
setSaving(true);
try {
const res = await apiUpdateSiteSettings(submitHex);
const res = await apiUpdateSiteSettings({ accent: submitHex });
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
applyAccentTheme(next || null);

View File

@@ -20,6 +20,7 @@ import {
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
apiUpdateSiteSettings,
type PendingCommentItem,
type PendingCounts,
type Post,
@@ -47,10 +48,15 @@ export default function ModerationAdmin({
initialPosts,
initialPostsTotal,
initError,
canConfigureSettings,
initialTrustReviewedPublish,
}: {
initialPosts: Post[];
initialPostsTotal: number;
initError: string;
/** 超管/站长可改站点免审策略(后端 PermSettings) */
canConfigureSettings: boolean;
initialTrustReviewedPublish: boolean;
}) {
const [tab, setTab] = useState<Tab>("posts");
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
@@ -73,12 +79,30 @@ export default function ModerationAdmin({
const [confirmKey, setConfirmKey] = useState<string | null>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [trustReviewedPublish, setTrustReviewedPublish] = useState(initialTrustReviewedPublish);
const [savingTrust, setSavingTrust] = useState(false);
const refreshCounts = useCallback(() => {
apiAdminPendingCounts()
.then(setCounts)
.catch(() => {});
}, []);
const toggleTrustReviewedPublish = async () => {
if (!canConfigureSettings || savingTrust) return;
const next = !trustReviewedPublish;
setSavingTrust(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: next });
setTrustReviewedPublish(res.trust_reviewed_publish);
toast(res.trust_reviewed_publish ? "已开启:过审用户后续免审" : "已关闭:所有普通用户均需审核", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingTrust(false);
}
};
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
@@ -215,6 +239,40 @@ export default function ModerationAdmin({
</p>
</div>
{canConfigureSettings && (
<section
className="panel p-4 sm:p-5 mb-5 flex items-start justify-between gap-4 flex-wrap"
aria-label="审核策略"
>
<div className="min-w-0 flex-1">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
已过审用户后续免审
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed">
开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
</p>
</div>
<button
type="button"
role="switch"
aria-checked={trustReviewedPublish}
disabled={savingTrust}
onClick={() => void toggleTrustReviewedPublish()}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{
background: trustReviewedPublish ? "var(--accent)" : "var(--line-2)",
}}
data-tip={trustReviewedPublish ? "点击关闭" : "点击开启"}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: trustReviewedPublish ? "translateX(1.25rem)" : "translateX(0)" }}
/>
<span className="sr-only">{trustReviewedPublish ? "已开启" : "已关闭"}</span>
</button>
</section>
)}
{/* 队列切换 */}
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
{(

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny } from "@/lib/roles";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ModerationAdmin from "./ModerationAdmin";
@@ -33,11 +33,16 @@ export default async function ModerationPage() {
initError = e instanceof Error ? e.message : "获取待审核内容失败";
}
const canConfigureSettings = isSuperOrOwner(me.user.role);
const settings = await getPublicSettingsCached();
return (
<ModerationAdmin
initialPosts={initialPosts}
initialPostsTotal={initialTotal}
initError={initError}
canConfigureSettings={canConfigureSettings}
initialTrustReviewedPublish={settings.trust_reviewed_publish !== false}
/>
);
}

View File

@@ -494,7 +494,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</Link>
</div>
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
<PostActions postId={id} authorId={post.user.id} boardId={post.board_id} pinned={post.pinned} recommended={post.recommended} />
<PostActions
postId={id}
authorId={post.user.id}
boardId={post.board_id}
pinned={post.pinned}
recommended={post.recommended}
status={post.status}
/>
</div>
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);

View File

@@ -3,9 +3,17 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, type CommentNode, type User } from "@/lib/api";
import {
apiCreateComment,
apiDeleteComment,
apiAcceptBounty,
apiAcceptQuestion,
apiAdminApproveComment,
type CommentNode,
type User,
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "./Avatar";
@@ -59,6 +67,7 @@ export default function CommentSection({
const [deletingId, setDeletingId] = useState<number | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [acceptingId, setAcceptingId] = useState<number | null>(null);
const [approvingId, setApprovingId] = useState<number | null>(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -163,11 +172,27 @@ export default function CommentSection({
}
};
const handleApprove = async (commentId: number) => {
setApprovingId(commentId);
try {
await apiAdminApproveComment(commentId);
toast("已通过评审", "ok");
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "通过评审失败");
} finally {
setApprovingId(null);
}
};
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
const canDelete = (comment: CommentNode) =>
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
const canApprove = (comment: CommentNode) =>
!!user && comment.status === "pending" && canModerateBoard(user, boardId);
const canAccept = (comment: CommentNode) =>
!!acceptMode &&
!!user &&
@@ -302,6 +327,18 @@ export default function CommentSection({
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
{canApprove(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
disabled={approvingId === c.id}
onClick={() => void handleApprove(c.id)}
data-tip="通过评审,公开此评论"
>
<BadgeCheck size={13} />
{approvingId === c.id ? "通过中…" : "通过评审"}
</button>
)}
</div>
{canDelete(c) && (
<button
@@ -394,6 +431,18 @@ export default function CommentSection({
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
{canApprove(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
disabled={approvingId === c.id}
onClick={() => void handleApprove(c.id)}
data-tip="通过评审,公开此评论"
>
<BadgeCheck size={13} />
{approvingId === c.id ? "通过中…" : "通过评审"}
</button>
)}
</div>
{canDelete(c) && (
<button

View File

@@ -3,8 +3,8 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
@@ -15,9 +15,18 @@ interface PostActionsProps {
boardId: number;
pinned: number;
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
}
export default function PostActions({ postId, authorId, boardId, pinned, recommended }: PostActionsProps) {
export default function PostActions({
postId,
authorId,
boardId,
pinned,
recommended,
status,
}: PostActionsProps) {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [open, setOpen] = useState(false);
@@ -54,6 +63,7 @@ export default function PostActions({ postId, authorId, boardId, pinned, recomme
// 作者本人或有该板块审核权的管理团队可管理;置顶/加精仅管理员及以上
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
const isAdmin = !!user && isAdminOrAbove(user.role);
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
const handleDelete = async () => {
setDeleting(true);
@@ -73,6 +83,20 @@ export default function PostActions({ postId, authorId, boardId, pinned, recomme
}
};
const handleApprove = async () => {
setActing(true);
setOpen(false);
try {
await apiAdminApprovePost(Number(postId));
toast("已通过评审", "ok");
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "通过评审失败");
} finally {
setActing(false);
}
};
const handlePin = async () => {
setActing(true);
setOpen(false);
@@ -137,6 +161,21 @@ export default function PostActions({ postId, authorId, boardId, pinned, recomme
boxShadow: "var(--shadow-lg)",
}}
>
{canApprove && (
<>
<button
type="button"
role="menuitem"
onClick={handleApprove}
className={itemCls}
style={{ color: "var(--accent)" }}
>
<BadgeCheck size={14} className="shrink-0" />
<span className="flex-1 text-left">通过评审</span>
</button>
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
{isAdmin && (
<>
<button

View File

@@ -528,19 +528,25 @@ export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
return res.json();
}
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认)
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认);
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true)
export interface PublicSettings {
accent: string;
trust_reviewed_publish: boolean;
}
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
if (!res.ok) return { accent: "" };
return (await res.json()) as PublicSettings;
if (!res.ok) return { accent: "", trust_reviewed_publish: true };
const data = (await res.json()) as Partial<PublicSettings>;
return {
accent: data.accent ?? "",
trust_reviewed_publish: data.trust_reviewed_publish !== false,
};
} catch {
return { accent: "" };
return { accent: "", trust_reviewed_publish: true };
}
}
@@ -1417,19 +1423,31 @@ export async function apiGetSettings(): Promise<PublicSettings> {
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
if (!res.ok) throw new Error("获取站点设置失败");
return res.json();
const data = (await res.json()) as Partial<PublicSettings>;
return {
accent: data.accent ?? "",
trust_reviewed_publish: data.trust_reviewed_publish !== false,
};
}
// 更新站点主题色;accent 传空串恢复默认
export async function apiUpdateSiteSettings(accent: string): Promise<PublicSettings> {
export type UpdateSiteSettingsBody = {
accent?: string;
trust_reviewed_publish?: boolean;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promise<PublicSettings> {
const res = await fetchWithRefresh("/api/admin/settings", {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ accent }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存设置失败");
return { accent: data.accent ?? "" };
return {
accent: data.accent ?? "",
trust_reviewed_publish: data.trust_reviewed_publish !== false,
};
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====

View File

@@ -40,6 +40,7 @@ export interface RtPresenceData {
export interface RtSettingsData {
accent: string;
trust_reviewed_publish?: boolean;
}
// 群聊新消息:data 为落库的 ChatMessage(含 sender)