fix: 空评论列表 500,并完善撤回审计与角色说明

空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 00:23:55 +08:00
parent 42c395a864
commit 8c5de13899
29 changed files with 835 additions and 405 deletions

View File

@@ -32,7 +32,7 @@ import {
type Board,
type LoginLog,
} from "@/lib/api";
import { isOwner, ROLES } from "@/lib/roles";
import { isOwner, ROLES, ROLE_META } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { realtime } from "@/lib/realtime";
@@ -60,7 +60,6 @@ interface RoleOption {
value: string;
title: string;
desc: string;
icon: React.ReactNode;
token: string;
}
@@ -131,9 +130,7 @@ export default function UsersAdmin({
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
// 封禁二次确认弹窗;null 关闭
const [banTarget, setBanTarget] = useState<AdminUser | null>(null);
// 角色授权弹窗 / 登录历史弹窗
// 角色与账号弹窗 / 登录历史弹窗
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
@@ -412,42 +409,33 @@ export default function UsersAdmin({
</Link>
<button
type="button"
title="设置管理角色"
aria-label={`设置 ${u.username} 的管理角色`}
title="角色与账号"
aria-label={`设置 ${u.username} 的角色与账号状态`}
disabled={busy}
onClick={() => setRoleTarget(u)}
className={ghostBtn}
style={u.role === ROLES.USER ? { color: "var(--ink-3)" } : { color: "var(--accent)" }}
style={
u.banned
? { color: "var(--danger)" }
: u.role === ROLES.USER
? { color: "var(--ink-3)" }
: { color: "var(--accent)" }
}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.background = u.banned ? "var(--danger-soft)" : "var(--accent-soft)";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.role === ROLES.USER ? "var(--ink-3)" : "var(--accent)";
e.currentTarget.style.color = u.banned
? "var(--danger)"
: u.role === ROLES.USER
? "var(--ink-3)"
: "var(--accent)";
}}
>
<ShieldCheck size={15} />
</button>
<button
type="button"
title={u.banned ? "解封" : "封禁"}
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
disabled={busy}
onClick={() => setBanTarget(u)}
className={ghostBtn}
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--danger-soft)";
e.currentTarget.style.color = "var(--danger)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
}}
>
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
</button>
</div>
);
};
@@ -475,7 +463,7 @@ export default function UsersAdmin({
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
检索全部成员,配置管理角色与板块授权;站长账号受保护,操作即时生效
检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护
</p>
</div>
@@ -738,24 +726,16 @@ export default function UsersAdmin({
disabled={actingId === u.id}
onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm"
style={u.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
<ShieldCheck size={13} />
角色权限
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setBanTarget(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
? { color: "var(--danger)", borderColor: "var(--danger)" }
: u.role !== ROLES.USER
? { color: "var(--accent)", borderColor: "var(--accent)" }
: undefined
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
<ShieldCheck size={13} />
角色与账号
</button>
</>
)}
@@ -811,33 +791,12 @@ export default function UsersAdmin({
setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)}
/>
<BanConfirmDialog
target={banTarget}
busy={actingId === banTarget?.id}
onCancel={() => setBanTarget(null)}
onConfirm={async () => {
const u = banTarget;
if (!u) return;
setActingId(u.id);
try {
await apiAdminSetUserBan(u.id, !u.banned);
toast(u.banned ? "已解封" : "已封禁", "ok");
setBanTarget(null);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActingId(null);
}
}}
/>
<LoginLogsDialog target={logsTarget} onClose={() => setLogsTarget(null)} />
</div>
);
}
// ===== 角色授权弹窗 =====
// ===== 角色与账号弹窗(角色授权 + 封禁状态) =====
function RoleAssignDialog({
target,
@@ -856,47 +815,35 @@ function RoleAssignDialog({
onSaved: () => void;
setActing: (v: boolean) => void;
}) {
// 可授予角色(按等级从高到低);超管选项仅站长可见
const options: RoleOption[] = [
{
value: ROLES.SUPER_ADMIN,
title: "超级管理员",
desc: "可操作全站后台:用户权限、公告、外观设置、内容审核;站长账号除外",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.ADMIN,
title: "管理员",
desc: "可管理公告,审核全站帖子与评论,可置顶/加精",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.BOARD_ADMIN,
title: "板块管理员",
desc: "仅可审核被授权板块的帖子与评论",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.USER,
title: "普通用户",
desc: "无后台权限;发帖与评论需通过审核",
icon: <UsersIcon size={18} />,
token: "--ink-3",
},
].filter((o) => canGrantSuper || o.value !== ROLES.SUPER_ADMIN);
const grantKeys = [
ROLES.SUPER_ADMIN,
ROLES.ADMIN,
ROLES.BOARD_ADMIN,
ROLES.USER,
] as const;
const options: RoleOption[] = grantKeys
.filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN)
.map((k) => {
const meta = ROLE_META[k];
return {
value: k,
title: meta.label,
desc: meta.grantDesc || "",
token: meta.colorToken,
};
});
const [role, setRole] = useState<string>(ROLES.USER);
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [err, setErr] = useState("");
// 每次打开按目标当前角色/授权初始化
useEffect(() => {
if (target) {
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
setBoardIds(target.board_ids ?? []);
setBanned(!!target.banned);
setErr("");
}
}, [target]);
@@ -916,15 +863,36 @@ function RoleAssignDialog({
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
};
const sameBoards =
JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) ===
JSON.stringify([...boardIds].sort((a, b) => a - b));
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
const banChanged = banned !== !!target.banned;
const willBan = banChanged && banned;
const willUnban = banChanged && !banned;
const submit = async () => {
if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) {
setErr("请至少选择一个授权板块");
return;
}
if (!roleChanged && !banChanged) {
onClose();
return;
}
setActing(true);
try {
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
toast("角色权限已更新", "ok");
if (roleChanged) {
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
}
if (banChanged) {
await apiAdminSetUserBan(target.id, banned);
}
const parts: string[] = [];
if (roleChanged) parts.push("角色已更新");
if (willBan) parts.push("已封禁");
if (willUnban) parts.push("已解封");
toast(parts.join(" · ") || "已保存", "ok");
onSaved();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
@@ -933,6 +901,8 @@ function RoleAssignDialog({
}
};
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
@@ -940,17 +910,17 @@ function RoleAssignDialog({
onClick={() => !acting && onClose()}
>
<div
className="j13-toast-in panel w-full max-w-[460px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
className="j13-toast-in panel w-full max-w-[500px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="设置管理角色"
aria-label="角色与账号"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
设置管理角色
角色与账号
</h3>
<p className="meta mt-1 text-[12.5px] truncate">
@{target.username}({target.nickname})
@@ -968,7 +938,14 @@ function RoleAssignDialog({
</button>
</div>
<div className="mt-4 flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
<p className="mt-3 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
角色决定后台入口与前台管理能力;封禁立即强制下线。登录历史、站长内容档案仍走列表其他入口。
</p>
<p className="mt-4 mb-2 text-[12.5px] font-bold" style={{ color: "var(--ink)" }}>
管理角色
</p>
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
{options.map((o) => {
const active = role === o.value;
return (
@@ -981,7 +958,7 @@ function RoleAssignDialog({
setRole(o.value);
setErr("");
}}
className="text-left rounded-xl p-3 flex items-start gap-3 transition-colors"
className="text-left rounded-xl p-3.5 flex items-start gap-3 transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
@@ -995,13 +972,13 @@ function RoleAssignDialog({
}}
aria-hidden="true"
>
{o.icon}
{o.value === ROLES.USER ? <UsersIcon size={18} /> : <ShieldCheck size={18} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
{o.title}
</span>
<span className="block mt-0.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
<span className="block mt-1 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{o.desc}
</span>
</span>
@@ -1017,7 +994,6 @@ function RoleAssignDialog({
})}
</div>
{/* 板块授权(仅板块管理员) */}
{role === ROLES.BOARD_ADMIN && (
<div className="mt-4">
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
@@ -1060,111 +1036,89 @@ function RoleAssignDialog({
})}
</div>
)}
{err && <p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>{err}</p>}
{err && (
<p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
账号状态
</p>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
<button
type="button"
role="radio"
aria-checked={!banned}
onClick={() => setBanned(false)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
style={{
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-bold"
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
>
<RotateCcw size={14} /> 正常
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可登录与互动
</span>
</button>
<button
type="button"
role="radio"
aria-checked={banned}
onClick={() => setBanned(true)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
style={{
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-bold"
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
>
<Ban size={14} /> 封禁
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
强制下线,禁止登录
</span>
</button>
</div>
{willBan && (
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
取消
</button>
<button type="button" className="btn" onClick={submit} disabled={acting}>
{acting && <Loader2 size={15} className="animate-spin" />}
保存角色
</button>
</div>
</div>
</div>
);
}
// ===== 封禁确认弹窗 =====
function BanConfirmDialog({
target,
busy,
onCancel,
onConfirm,
}: {
target: AdminUser | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
useEffect(() => {
if (!target) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [target, busy, onCancel]);
if (!target) return null;
const banning = !target.banned;
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !busy && onCancel()}
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{
background: banning ? "var(--danger-soft)" : "var(--ok-soft)",
color: banning ? "var(--danger)" : "var(--ok)",
}}
aria-hidden="true"
<button
type="button"
className={willBan ? "btn btn-danger" : "btn"}
onClick={submit}
disabled={acting}
>
{banning ? <Ban size={23} /> : <RotateCcw size={21} />}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{banning ? "封禁账号" : "解除封禁"}
</h3>
<p className="meta mt-1.5 text-[13px] leading-relaxed">
{banning
? `封禁后 @${target.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`
: `解除后 @${target.username} 可以重新登录,并恢复发帖、回复等全部功能。`}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button type="button" className="btn btn-line" onClick={onCancel} disabled={busy} autoFocus>
取消
{acting && <Loader2 size={15} className="animate-spin" />}
{saveLabel}
</button>
{banning ? (
<button type="button" className="btn btn-danger" onClick={onConfirm} disabled={busy}>
{busy && <Loader2 size={15} className="animate-spin" />}
确认封禁
</button>
) : (
<button
type="button"
className="btn"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
解除封禁
</button>
)}
</div>
</div>
</div>
);
}
// ===== 登录历史弹窗 =====
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {