fix: 空评论列表 500,并完善撤回审计与角色说明
空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user