完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 14:30:08 +08:00
parent 1f2e4b2a33
commit c0462a3500
37 changed files with 1340 additions and 472 deletions

View File

@@ -28,7 +28,7 @@ import {
type CommentNode,
type User,
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import CommentsUnsupported from "@/components/CommentsUnsupported";
@@ -334,19 +334,25 @@ export default function CommentSection({
const canSoftDelete = (comment: CommentNode) =>
!comment.deleted &&
!!user &&
(user.id === comment.user?.id || canModerateBoard(user, boardId));
(user.id === comment.user?.id ||
canStaffSoftDelete(user, boardId, comment.user?.role));
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
const canViewHistory = !!user && canModerateBoard(user, boardId);
const canPurge = (comment: CommentNode) =>
!!user && canModerateBoard(user, boardId);
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除),板管受作者角色保护 */
const canRestore = (comment: CommentNode) => {
if (!comment.deleted || !user) return false;
if (canModerateBoard(user, boardId)) return true;
if (
canModerateBoard(user, boardId) &&
canStaffSoftDelete(user, boardId, comment.user?.role)
) {
return true;
}
return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted;
};

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { POST_DELETE_TYPES } from "@/lib/api";
import StaffDeleteFields from "@/components/StaffDeleteFields";
export type DeleteCommentPayload = {
delete_type: string;
@@ -12,7 +12,7 @@ export type DeleteCommentPayload = {
/**
* 评论软删弹窗:
* - 评论者自删:必填理由(仅管理可见)
* - 管理删:必选类型;仅「其他」须填理由
* - 管理删:与删帖共用类型表单;仅「其他」须填理由
*/
export default function DeleteCommentModal({
open,
@@ -89,55 +89,31 @@ export default function DeleteCommentModal({
}
>
{isStaff ? (
<>
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
role="radiogroup"
aria-label="删除类型"
>
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => {
setDeleteType(t.value);
setErr("");
if (t.value !== "other") setReason("");
}}
className="text-left rounded-lg px-3 py-2.5 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
}}
>
{t.label}
</button>
);
})}
</div>
<p className="meta text-[11.5px] mb-4 leading-relaxed">
选「其他」时需补充理由;其余类型直接按标签展示给读者。
</p>
</>
) : null}
{reasonRequired ? (
<StaffDeleteFields
deleteType={deleteType}
onTypeChange={(value) => {
setDeleteType(value);
setErr("");
if (value !== "other") setReason("");
}}
reason={reason}
onReasonChange={(value) => {
setReason(value);
setErr("");
}}
reasonId="cmt-delete-reason"
reasonPlaceholder="向读者说明删除原因(将展示在占位中)"
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
reasonHint="将公开显示在评论占位旁。"
/>
) : (
<>
<label
className="block mb-1.5 text-[12.5px] font-medium"
style={{ color: "var(--ink)" }}
htmlFor="cmt-delete-reason"
>
{isStaff ? "补充理由" : "删除理由"}{" "}
<span style={{ color: "var(--danger)" }}>*</span>
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id="cmt-delete-reason"
@@ -148,19 +124,13 @@ export default function DeleteCommentModal({
setReason(e.target.value);
setErr("");
}}
placeholder={
isStaff
? "向读者说明删除原因(将展示在占位中)"
: "例如:发错了 / 不想保留(仅管理员可见)"
}
placeholder="例如:发错了 / 不想保留(仅管理员可见)"
maxLength={200}
autoFocus={!isStaff}
autoFocus
/>
<p className="meta text-[11.5px] mt-1.5">
{isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"}
</p>
<p className="meta text-[11.5px] mt-1.5">普通读者看不到这条理由。</p>
</>
) : null}
)}
{err ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>

View File

@@ -21,7 +21,7 @@ import {
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { isStaff } from "@/lib/roles";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
@@ -396,7 +396,7 @@ export default function Header({
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user && isStaff(user.role) && (
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
<MobileLink href="/admin">
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
</MobileLink>
@@ -560,7 +560,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
{isStaff(user.role) && (
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
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 { canStaffSoftDelete, canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -13,6 +13,7 @@ import StaffDeletePostModal from "@/components/StaffDeletePostModal";
interface PostActionsProps {
postId: string;
authorId: number;
authorRole?: string | null;
boardId: number;
pinned: number;
recommended: boolean;
@@ -25,6 +26,7 @@ interface PostActionsProps {
export default function PostActions({
postId,
authorId,
authorRole,
boardId,
pinned,
recommended,
@@ -64,12 +66,15 @@ export default function PostActions({
};
}, [open]);
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
const canManage =
!!user &&
(user.id === authorId || canStaffSoftDelete(user, boardId, authorRole));
const isAdmin = !!user && isAdminOrAbove(user.role);
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId);
// staff 删他人帖:走类型+理由;作者自删:简单确认
const staffDeletingOther =
!!user && user.id !== authorId && canModerateBoard(user, boardId);
!!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole);
const runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
setDeleting(true);

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
@@ -19,6 +19,7 @@ export interface RoleAccountTarget {
role: string;
board_ids?: number[];
banned: boolean;
can_manage_messages?: boolean;
avatar?: string;
email?: string;
points?: number;
@@ -75,6 +76,7 @@ export default function RoleAccountModal({
const [role, setRole] = useState<string>(ROLES.USER);
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [manageMessages, setManageMessages] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
@@ -82,6 +84,7 @@ export default function RoleAccountModal({
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
setBoardIds(target.board_ids ?? []);
setBanned(!!target.banned);
setManageMessages(!!target.can_manage_messages);
setErr("");
}
}, [target]);
@@ -97,6 +100,11 @@ export default function RoleAccountModal({
JSON.stringify([...boardIds].sort((a, b) => a - b));
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
const banChanged = banned !== !!target.banned;
const messagesChanged =
canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages;
const willBan = banChanged && banned;
const willUnban = banChanged && !banned;
@@ -105,7 +113,7 @@ export default function RoleAccountModal({
setErr("请至少选择一个授权板块");
return;
}
if (!roleChanged && !banChanged) {
if (!roleChanged && !banChanged && !messagesChanged) {
onClose();
return;
}
@@ -114,6 +122,7 @@ export default function RoleAccountModal({
let nextRole = target.role;
let nextBoards = target.board_ids ?? [];
let nextBanned = !!target.banned;
let nextMessages = !!target.can_manage_messages;
if (roleChanged) {
const res = await apiAdminSetUserRole(
target.id,
@@ -127,19 +136,26 @@ export default function RoleAccountModal({
const res = await apiAdminSetUserBan(target.id, banned);
nextBanned = !!res.user.banned;
}
if (messagesChanged) {
const res = await apiAdminSetUserMessages(target.id, manageMessages);
nextMessages = !!res.user.can_manage_messages;
}
const parts: string[] = [];
if (roleChanged) parts.push("角色已更新");
if (willBan) parts.push("已封禁");
if (willUnban) parts.push("已解封");
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
toast(parts.join(" · ") || "已保存", "ok");
onSaved({
...target,
role: nextRole,
board_ids: nextBoards,
banned: nextBanned,
can_manage_messages: nextMessages,
});
onClose();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
setErr(e instanceof Error ? e.message : "保存失败");
} finally {
setActing(false);
}
@@ -279,6 +295,49 @@ export default function RoleAccountModal({
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
站点群消息管理
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。
</span>
</span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
</span>
</button>
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
账号状态

View File

@@ -0,0 +1,82 @@
"use client";
import { POST_DELETE_TYPES } from "@/lib/api";
/** 管理软删共用:类型单选 + 仅「其他」时的补充理由 */
export default function StaffDeleteFields({
deleteType,
onTypeChange,
reason,
onReasonChange,
reasonId,
reasonPlaceholder,
typeHint,
reasonHint,
}: {
deleteType: string;
onTypeChange: (value: string) => void;
reason: string;
onReasonChange: (value: string) => void;
reasonId: string;
reasonPlaceholder: string;
typeHint: string;
reasonHint: string;
}) {
const reasonRequired = deleteType === "other";
return (
<>
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
role="radiogroup"
aria-label="删除类型"
>
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onTypeChange(t.value)}
className="text-left rounded-lg px-3 py-2.5 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
}}
>
{t.label}
</button>
);
})}
</div>
<p className="meta text-[11.5px] mb-4 leading-relaxed">{typeHint}</p>
{reasonRequired ? (
<>
<label
className="block mb-1.5 text-[12.5px] font-medium"
style={{ color: "var(--ink)" }}
htmlFor={reasonId}
>
补充理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id={reasonId}
className="field"
rows={3}
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder={reasonPlaceholder}
maxLength={200}
/>
<p className="meta text-[11.5px] mt-1.5">{reasonHint}</p>
</>
) : null}
</>
);
}

View File

@@ -2,10 +2,10 @@
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { POST_DELETE_TYPES } from "@/lib/api";
import StaffDeleteFields from "@/components/StaffDeleteFields";
/**
* Staff 软删帖:必填删除类型 + 理由。
* Staff 软删帖:必选删除类型;仅「其他」须填理由。
* 作者自删仍用 ConfirmDialog,不走此组件。
*/
export default function StaffDeletePostModal({
@@ -36,17 +36,21 @@ export default function StaffDeletePostModal({
if (!open) return null;
const reasonRequired = deleteType === "other";
const submit = () => {
const r = reason.trim();
if (!r) {
setErr("请填写删除理由");
return;
if (reasonRequired) {
if (!r) {
setErr("请填写删除理由");
return;
}
if ([...r].length > 200) {
setErr("删除理由不能超过 200 字");
return;
}
}
if ([...r].length > 200) {
setErr("删除理由不能超过 200 字");
return;
}
onConfirm({ delete_type: deleteType, delete_reason: r });
onConfirm({ delete_type: deleteType, delete_reason: reasonRequired ? r : "" });
};
return (
@@ -55,8 +59,8 @@ export default function StaffDeletePostModal({
title="删除帖子"
description={
title
? `将删除「${title}」。列表不再展示;原链接会显示删除类型与理由。`
: "列表不再展示;原链接会显示删除类型与理由。"
? `将删除「${title}」。列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。`
: "列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。"
}
onClose={onClose}
busy={busy}
@@ -74,51 +78,28 @@ export default function StaffDeletePostModal({
</>
}
>
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
删除类型
</p>
<div className="flex flex-col gap-1.5 mb-4" role="radiogroup" aria-label="删除类型">
{POST_DELETE_TYPES.map((t) => {
const active = deleteType === t.value;
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => setDeleteType(t.value)}
className="text-left rounded-lg px-3 py-2 text-[13px] transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink)",
}}
>
{t.label}
</button>
);
})}
</div>
<label className="block mb-1.5 text-[12.5px]" style={{ color: "var(--ink)" }} htmlFor="delete-reason">
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id="delete-reason"
className="field"
rows={3}
value={reason}
onChange={(e) => {
setReason(e.target.value);
<StaffDeleteFields
deleteType={deleteType}
onTypeChange={(value) => {
setDeleteType(value);
setErr("");
if (value !== "other") setReason("");
}}
reason={reason}
onReasonChange={(value) => {
setReason(value);
setErr("");
}}
placeholder="将展示在帖子专用页与作者通知中"
maxLength={200}
reasonId="post-delete-reason"
reasonPlaceholder="向读者说明删除原因(将展示在帖子页)"
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
reasonHint="将公开显示在帖子专用页与作者通知中。"
/>
{err && (
{err ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
) : null}
</Modal>
);
}

View File

@@ -27,6 +27,7 @@ import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
import {
canModerateAny,
canAccessAdminMessages,
isAdminOrAbove,
isStaff,
isSuperOrOwner,
@@ -76,7 +77,7 @@ const NAV_GROUPS: NavGroup[] = [
href: "/admin/messages",
label: "消息管理",
icon: MessageCircle,
visible: (u) => isStaff(u.role),
visible: (u) => canAccessAdminMessages(u),
},
{
href: "/admin/announcements",