完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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)" }} /> 管理面板
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)" }}>
|
||||
账号状态
|
||||
|
||||
82
frontend/components/StaffDeleteFields.tsx
Normal file
82
frontend/components/StaffDeleteFields.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user