Files
jiang13-bbs/frontend/app/admin/users/UsersAdmin.tsx
freefire 6da4309453 feat: 新增管理员用户管理功能与账号封禁体系
此提交完成了全站管理员后台核心功能开发:
1.  新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封
2.  重构认证链路,统一账号封禁错误码与处理逻辑
3.  新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态
4.  重构管理员后台路由与布局,统一权限校验逻辑
5.  优化通知轮询逻辑,页面切回前台时立即刷新未读计数
6.  合并原公告、外观管理页到统一后台面板
2026-09-15 02:52:26 +08:00

873 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
Ban,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
RotateCcw,
Search,
ShieldCheck,
ShieldOff,
Users as UsersIcon,
UserPlus,
X,
} from "lucide-react";
import {
apiAdminListUsers,
apiAdminSetUserBan,
apiAdminSetUserRole,
type AdminUser,
type AdminUserSummary,
type AdminUsersResponse,
} from "@/lib/api";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
export type UserFilter = "all" | "admin" | "banned";
const PAGE_SIZE = 20;
const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "admin", label: "管理员" },
{ key: "banned", label: "已封禁" },
];
function filterToParams(filter: UserFilter): { role?: string; status?: string } {
if (filter === "admin") return { role: "admin" };
if (filter === "banned") return { status: "banned" };
return {};
}
// 待二次确认的危险操作种类
export type PendingKind = "promote" | "demote" | "ban" | "unban";
export interface PendingAction {
kind: PendingKind;
user: AdminUser;
}
// 确认弹窗的文案与视觉配置(说明需拼入用户名,故 desc 为函数)
const CONFIRM_CONFIG: Record<
PendingKind,
{
title: string;
desc: (u: AdminUser) => string;
confirmText: string;
tone: "accent" | "danger" | "ok";
icon: React.ReactNode;
}
> = {
promote: {
title: "设为管理员",
desc: (u) =>
`@${u.username} 将获得管理面板访问权限,可管理公告、外观设置与用户账号。`,
confirmText: "设为管理员",
tone: "accent",
icon: <ShieldCheck size={23} />,
},
demote: {
title: "取消管理员",
desc: (u) =>
`将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`,
confirmText: "确认降级",
tone: "danger",
icon: <ShieldOff size={23} />,
},
ban: {
title: "封禁账号",
desc: (u) =>
`封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`,
confirmText: "确认封禁",
tone: "danger",
icon: <Ban size={23} />,
},
unban: {
title: "解除封禁",
desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`,
confirmText: "解除封禁",
tone: "ok",
icon: <RotateCcw size={21} />,
},
};
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 flex items-center gap-3">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 leading-tight">
<p className="text-[11.5px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<p className="text-[19px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{value}
{sub && (
<span className="ml-1.5 text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
{sub}
</span>
)}
</p>
</div>
</div>
);
}
// 角色/状态徽章
function Badge({
color,
soft,
icon,
children,
}: {
color: string;
soft: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold leading-[1.6] whitespace-nowrap"
style={{ background: soft, color }}
>
{icon}
{children}
</span>
);
}
export default function UsersAdmin({
initial,
initError,
initialQuery,
currentUserId,
}: {
initial: AdminUsersResponse | null;
initError: string;
initialQuery: { q: string; filter: UserFilter; page: number };
currentUserId: number;
}) {
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>(
initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
);
const [total, setTotal] = useState(initial?.total ?? 0);
const [page, setPage] = useState(initialQuery.page);
const [filter, setFilter] = useState<UserFilter>(initialQuery.filter);
const [keyword, setKeyword] = useState(initialQuery.q);
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
// 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭
const [pending, setPending] = useState<PendingAction | null>(null);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
const seq = ++seqRef.current;
setLoading(true);
try {
const res = await apiAdminListUsers({
page: opts.page,
size: PAGE_SIZE,
q: opts.q || undefined,
...filterToParams(opts.filter),
});
if (seq !== seqRef.current) return;
setUsers(res.users);
setSummary(res.summary);
setTotal(res.total);
setError("");
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "获取用户列表失败");
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, []);
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const q = keyword.trim();
if (q !== appliedQ) {
setAppliedQ(q);
setPage(1);
load({ page: 1, filter, q });
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
useEffect(() => {
if (urlFirstRun.current) {
urlFirstRun.current = false;
return;
}
const p = new URLSearchParams();
if (appliedQ) p.set("q", appliedQ);
if (filter !== "all") p.set("filter", filter);
if (page > 1) p.set("page", String(page));
const qs = p.toString();
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = keyword.trim();
if (q === appliedQ) return;
setAppliedQ(q);
setPage(1);
load({ page: 1, filter, q });
};
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
setAppliedQ("");
setPage(1);
load({ page: 1, filter, q: "" });
}
};
const changeFilter = (f: UserFilter) => {
if (f === filter) return;
setFilter(f);
setPage(1);
load({ page: 1, filter: f, q: appliedQ });
};
const goPage = (p: number) => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const next = Math.min(Math.max(1, p), totalPages);
if (next === page) return;
setPage(next);
load({ page: next, filter, q: appliedQ });
};
// 危险操作不再使用浏览器原生 confirm,统一走站点风格的二次确认弹窗
const requestRoleChange = (u: AdminUser) => {
setPending({ kind: u.role === "admin" ? "demote" : "promote", user: u });
};
const requestBanChange = (u: AdminUser) => {
setPending({ kind: u.banned ? "unban" : "ban", user: u });
};
// 确认弹窗中点击"确认"后执行;成功关闭弹窗并重拉列表,
// 保证筛选结果集与汇总计数一致;失败保留弹窗,错误走 toast
const confirmPending = async () => {
const action = pending;
if (!action) return;
const { kind, user: u } = action;
setActingId(u.id);
try {
if (kind === "promote" || kind === "demote") {
await apiAdminSetUserRole(u.id, kind === "promote" ? "admin" : "user");
toast(kind === "promote" ? "已设为管理员" : "已取消管理员", "ok");
} else {
await apiAdminSetUserBan(u.id, kind === "ban");
toast(kind === "ban" ? "已封禁" : "已解封", "ok");
}
setPending(null);
await load({ page, filter, q: appliedQ });
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActingId(null);
}
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "admin" ? summary.admins : summary.banned;
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
// 行内操作按钮(圆形幽灵按钮,32px)
const ghostBtn =
"w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
const renderActions = (u: AdminUser, isSelf: boolean) => {
if (isSelf) {
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
);
}
const busy = actingId === u.id;
return (
<div className="flex items-center gap-1 justify-end">
<Link
href={`/u/${u.id}`}
title="查看个人主页"
aria-label={`查看 ${u.nickname} 的个人主页`}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
<ExternalLink size={15} />
</Link>
<button
type="button"
title={u.role === "admin" ? "取消管理员" : "设为管理员"}
aria-label={u.role === "admin" ? `取消 ${u.username} 的管理员` : `将 ${u.username} 设为管理员`}
disabled={busy}
onClick={() => requestRoleChange(u)}
className={ghostBtn}
style={u.role === "admin" ? { color: "var(--accent)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.color = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.role === "admin" ? "var(--accent)" : "var(--ink-3)";
}}
>
{busy ? <Loader2 size={15} className="animate-spin" /> : u.role === "admin" ? <ShieldOff size={15} /> : <ShieldCheck size={15} />}
</button>
<button
type="button"
title={u.banned ? "解封" : "封禁"}
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
disabled={busy}
onClick={() => requestBanChange(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>
);
};
const renderBadges = (u: AdminUser) => (
<div className="flex items-center gap-1.5 flex-wrap">
{u.role === "admin" ? (
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
管理员
</Badge>
) : (
<Badge color="var(--ink-3)" soft="var(--panel-2)" icon={<UsersIcon size={11} />}>
普通用户
</Badge>
)}
{u.banned && (
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
已封禁
</Badge>
)}
</div>
);
// 桌面端列模板(与表头一致)
const rowGrid =
"hidden md:grid md:grid-cols-[minmax(0,1.7fr)_88px_92px_104px_100px_108px_150px] gap-3 items-center px-4 sm:px-5 py-3.5";
return (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UsersIcon size={18} />
</span>
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">检索全部成员,管理管理员角色与封禁状态;操作即时生效</p>
</div>
{/* 汇总 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<MiniStat
label="注册用户"
value={String(summary.total)}
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
icon={<UsersIcon size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<MiniStat
label="管理员"
value={String(summary.admins)}
icon={<ShieldCheck size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<MiniStat
label="已封禁"
value={String(summary.banned)}
icon={<Ban size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
/>
<MiniStat
label="当前筛选"
value={String(total)}
sub="人"
icon={<UserPlus size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
</div>
{/* 搜索 + 筛选 */}
<div className="panel p-3 sm:p-4 mb-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<form onSubmit={submitSearch} className="relative flex-1" role="search">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索用户名、昵称或邮箱…"
aria-label="搜索用户"
className="field pl-10 pr-9 py-2.5"
/>
{keyword && (
<button
type="button"
onClick={clearSearch}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
)}
</form>
<div className="flex items-center gap-1.5 flex-wrap" role="group" aria-label="用户筛选">
{FILTER_TABS.map((t) => {
const active = filter === t.key;
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => changeFilter(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: active ? "var(--panel)" : "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{tabCount(t.key)}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* 用户列表 */}
<div className="panel overflow-hidden">
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
{appliedQ ? (
<>
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
</>
) : filter === "admin" ? (
"管理员"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
</span>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
共 {total} 人
</span>
</div>
{/* 桌面表头 */}
{users.length > 0 && (
<div
className={`${rowGrid} text-[11.5px] font-semibold uppercase tracking-wide`}
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span>成员</span>
<span>角色</span>
<span>状态</span>
<span>内容</span>
<span>注册</span>
<span>最近活跃</span>
<span className="text-right">操作</span>
</div>
)}
<div aria-busy={loading} style={{ opacity: loading ? 0.55 : 1, transition: "opacity .15s" }}>
{error ? (
<p className="alert-error m-4">{error}</p>
) : loading && users.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : users.length === 0 ? (
<div className="text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<UsersIcon size={22} />
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
{hasFilter ? "没有匹配的成员" : "暂无成员"}
</p>
<p className="meta mt-1.5 text-[12.5px]">
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
</p>
</div>
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
{/* 桌面行 */}
<div className={rowGrid}>
<div className="flex items-center gap-3 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<div>
{u.role === "admin" ? (
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
管理员
</Badge>
) : (
<span className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
普通用户
</span>
)}
</div>
<div>
{u.banned ? (
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
已封禁
</Badge>
) : (
<span className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold" style={{ color: "var(--ok)" }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--ok)" }} aria-hidden="true" />
正常
</span>
)}
</div>
<p className="text-[12.5px] font-semibold tabular-nums" style={{ color: "var(--ink-2)" }}>
帖 {u.post_count}
<span className="mx-1" style={{ color: "var(--line-2)" }}>·</span>
评 {u.comment_count}
</p>
<p className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</p>
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}>
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}
</p>
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
</div>
{/* 移动卡片 */}
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
{renderBadges(u)}
{isSelf && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] font-bold leading-[1.55]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
)}
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<p className="mt-2.5 text-[11.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "}
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}
</p>
{!isSelf && (
<div className="flex items-center gap-2 mt-3">
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
<button
type="button"
disabled={actingId === u.id}
onClick={() => requestRoleChange(u)}
className="btn btn-line btn-sm"
style={u.role === "admin" ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
{actingId === u.id ? (
<Loader2 size={13} className="animate-spin" />
) : u.role === "admin" ? (
<ShieldOff size={13} />
) : (
<ShieldCheck size={13} />
)}
{u.role === "admin" ? "取消管理员" : "设为管理员"}
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => requestBanChange(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
</button>
</div>
)}
</div>
</div>
);
})
)}
</div>
{/* 分页 */}
{total > 0 && (
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between gap-2"
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页 · 共 {total} 人
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goPage(page - 1)}
disabled={page <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => goPage(page + 1)}
disabled={page >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
{/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */}
<ActionConfirmDialog
action={pending}
busy={actingId === pending?.user.id}
onCancel={() => setPending(null)}
onConfirm={confirmPending}
/>
</div>
);
}
// 危险操作确认弹窗:遮罩 + 居中卡片;ESC/点遮罩等同取消,执行中锁定所有关闭途径
function ActionConfirmDialog({
action,
busy,
onCancel,
onConfirm,
}: {
action: PendingAction | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
useEffect(() => {
if (!action) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [action, busy, onCancel]);
if (!action) return null;
const cfg = CONFIRM_CONFIG[action.kind];
const iconColor =
cfg.tone === "danger" ? "var(--danger)" : cfg.tone === "ok" ? "var(--ok)" : "var(--accent)";
const iconSoft =
cfg.tone === "danger"
? "var(--danger-soft)"
: cfg.tone === "ok"
? "var(--ok-soft)"
: "var(--accent-soft)";
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"
aria-labelledby="user-action-title"
aria-describedby="user-action-desc"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{ background: iconSoft, color: iconColor }}
aria-hidden="true"
>
{cfg.icon}
</span>
<div className="min-w-0 flex-1">
<h3
id="user-action-title"
className="text-[16px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
{cfg.title}
</h3>
<p id="user-action-desc" className="meta mt-1.5 text-[13px] leading-relaxed">
{cfg.desc(action.user)}
</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
>
取消
</button>
{cfg.tone === "danger" ? (
<button
type="button"
className="btn btn-danger"
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
{cfg.confirmText}
</button>
) : (
<button
type="button"
className="btn"
style={
cfg.tone === "ok"
? { background: "var(--ok-soft)", color: "var(--ok)" }
: undefined
}
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
{cfg.confirmText}
</button>
)}
</div>
</div>
</div>
);
}