feat: 新增管理员用户管理功能与账号封禁体系

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

View File

@@ -0,0 +1,872 @@
"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>
);
}