Files
TallyNote/web-next/src/pages/admins/AdminsPage.tsx
T
2026-09-04 14:24:54 +08:00

116 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Copy, KeyRound, Plus, RotateCcw, ShieldCheck, UserRound, UserRoundCheck, UserRoundX } from "lucide-react";
import { Button, Dialog, Drawer, Form, Space, Table, Tooltip } from "tdesign-react";
import { api } from "../../services/api";
import AccessibleInput from "../../components/AccessibleInput";
import { apiFieldErrors } from "../../services/formErrors";
import type { Admin } from "../../types/auth";
import { dateText } from "../expenses/date";
import { AsyncState, BusyIcon, ErrorBanner, Page, StatusTag } from "../common";
import type { Notify } from "../expenses/types";
import { useUnsavedActions, useUnsavedChanges } from "../../contexts/UnsavedChanges";
import { useDrawerFocus } from "../../hooks/useDrawerFocus";
export default function AdminsPage({ currentAdmin, timezone = "Asia/Shanghai", notify }: { currentAdmin: Admin; timezone?: string; notify?: Notify }) {
const [items, setItems] = useState<Admin[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ username: "", displayName: "" });
const [formError, setFormError] = useState("");
const [formFields, setFormFields] = useState<{ username?: string; displayName?: string }>({});
const [action, setAction] = useState<{ kind: "toggle" | "reset"; admin: Admin } | null>(null);
const [secret, setSecret] = useState("");
const loadSequence = useRef(0);
const createInFlight = useRef(false);
const { requestDiscard } = useUnsavedActions();
useDrawerFocus(showCreate, ".tn-admin-drawer");
useUnsavedChanges(showCreate && Boolean(form.username || form.displayName));
const load = async () => {
const sequence = ++loadSequence.current;
setLoading(true); setError("");
try { const result = await api<{ items: Admin[] }>("/api/admins"); if (sequence === loadSequence.current) setItems(result.items); }
catch (e) { if (sequence === loadSequence.current) setError((e as Error).message); }
finally { if (sequence === loadSequence.current) setLoading(false); }
};
useEffect(() => { void load(); }, []);
const create = async (event?: React.FormEvent) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (busy || createInFlight.current) return;
setFormError("");
setFormFields({});
const username = form.username.normalize("NFKC").trim();
const displayName = form.displayName.trim();
const nextFields: typeof formFields = {};
if ([...username].length < 3) nextFields.username = "用户名至少需要 3 个字符";
if (!displayName) nextFields.displayName = "请输入显示名";
if (Object.keys(nextFields).length) { setFormFields(nextFields); focusAdminField(nextFields); return; }
createInFlight.current = true;
setBusy(true);
try { const result = await api<{ temporaryPassword: string }>("/api/admins", { method: "POST", body: JSON.stringify({ username, displayName }) }); setShowCreate(false); setForm({ username: "", displayName: "" }); setSecret(result.temporaryPassword); notify?.("管理员已创建", "success"); await load(); }
catch (caught) {
const mapped = apiFieldErrors(caught);
if (Object.keys(mapped).length) { const fields = { username: mapped.username, displayName: mapped.displayName }; setFormFields(fields); focusAdminField(fields); }
else setFormError((caught as Error).message);
}
finally { createInFlight.current = false; setBusy(false); }
};
const toggle = async () => {
if (!action) return; setBusy(true);
try { const next = action.admin.status === "active" ? "disabled" : "active"; await api(`/api/admins/${action.admin.id}/status`, { method: "PUT", body: JSON.stringify({ status: next, version: action.admin.version }) }); setAction(null); notify?.(next === "active" ? "管理员已启用" : "管理员已停用", "success"); await load(); }
catch (e) { setError((e as Error).message); setAction(null); }
finally { setBusy(false); }
};
const reset = async () => {
if (!action) return; setBusy(true);
try { const result = await api<{ temporaryPassword: string }>(`/api/admins/${action.admin.id}/reset-password`, { method: "POST", body: JSON.stringify({ version: action.admin.version }) }); setAction(null); setSecret(result.temporaryPassword); notify?.("密码已重置,现有会话已失效", "success"); await load(); }
catch (e) { setError((e as Error).message); setAction(null); }
finally { setBusy(false); }
};
const columns = [
{ colKey: "username", title: "用户名", cell: ({ row }: any) => <span className="tn-code">{row.username}</span> },
{ colKey: "displayName", title: "显示名" },
{ colKey: "status", title: "状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
{ colKey: "lastLoginAt", title: "最近登录", cell: ({ row }: any) => row.lastLoginAt ? dateText(row.lastLoginAt, timezone) : "从未登录" },
{ colKey: "version", title: "版本", cell: ({ row }: any) => <span className="tn-code">v{row.version}</span> },
{ colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => <Space className="tn-action-group"><Tooltip content={row.id === currentAdmin.id ? "当前账号请在个人菜单中修改密码" : "重置并生成临时登录密码"}><Button variant="outline" onClick={() => setAction({ kind: "reset", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={<KeyRound size={15} />}>重置密码</Button></Tooltip><Button variant="outline" theme={row.status === "active" ? "danger" : "primary"} onClick={() => setAction({ kind: "toggle", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={row.status === "active" ? <UserRoundX size={15} /> : <UserRoundCheck size={15} />}>{row.status === "active" ? "停用" : "启用"}</Button></Space> },
];
return <Page title="管理员" subtitle="管理员协同维护团队账单与报销凭据,停用后将立即限制该账号访问并注销其登录会话。" actions={<Space><Button variant="outline" onClick={() => void load()} disabled={loading} icon={<RotateCcw size={15} />}>刷新</Button><Button theme="primary" onClick={() => { setForm({ username: "", displayName: "" }); setFormError(""); setFormFields({}); setShowCreate(true); }} icon={<Plus size={16} />}>新增管理员</Button></Space>}>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><UserRound size={30} /><p>暂无管理员</p></div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap" role="region" aria-label="管理员列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div></AsyncState>
<Drawer className="tn-content-drawer tn-admin-drawer" visible={showCreate} destroyOnClose placement="right" size="440px" header="新增管理员" onClose={() => { if (!busy) requestDiscard(() => setShowCreate(false)); }} footer={<Space><Button variant="outline" onClick={() => requestDiscard(() => setShowCreate(false))} disabled={busy}>取消</Button><Button theme="primary" type="button" onClick={() => void create()} disabled={busy} icon={busy ? <BusyIcon /> : <ShieldCheck size={15} />}>创建并生成临时密码</Button></Space>}>
<Form id="admin-create-form" layout="vertical" onSubmit={() => { void create(); }}><Form.FormItem label="用户名" help={formFields.username || "至少 3 个字符"} status={formFields.username ? "error" : undefined} rules={[{ required: true, min: 3, max: 64, message: "用户名至少需要 3 个字符" }]}><AccessibleInput disabled={busy} inputAriaLabel="用户名" inputAriaInvalid={Boolean(formFields.username)} value={form.username} onChange={value => { setForm({ ...form, username: value }); setFormFields(current => ({ ...current, username: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={64} autocomplete="off" /></Form.FormItem><Form.FormItem label="显示名" help={formFields.displayName} status={formFields.displayName ? "error" : undefined} rules={[{ required: true, max: 80, message: "请输入显示名" }]}><AccessibleInput disabled={busy} inputAriaLabel="显示名" inputAriaInvalid={Boolean(formFields.displayName)} value={form.displayName} onChange={value => { setForm({ ...form, displayName: value }); setFormFields(current => ({ ...current, displayName: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={80} /></Form.FormItem>{formError && <div className="tn-inline-error" role="alert">{formError}</div>}</Form>
</Drawer>
<Dialog width="540px" visible={Boolean(action)} header={action?.kind === "reset" ? "重置管理员密码?" : action?.admin.status === "active" ? "停用管理员?" : "启用管理员?"} confirmBtn={{ content: action?.kind === "reset" ? "重置密码" : action?.admin.status === "active" ? "停用" : "启用", theme: action?.kind === "toggle" && action.admin.status === "active" ? "danger" : "primary", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}>
{action?.kind === "reset" ? <>将生成一次性临时密码,同时让管理员“{action.admin.displayName}”已登录的会话安全退出。</> : action?.admin.status === "active" ? "停用后该管理员将无法访问系统,已登录的会话会立即注销。" : "启用后该管理员可恢复系统访问并正常登录。"}
</Dialog>
<Dialog width="540px" visible={Boolean(secret)} header="临时密码已生成" confirmBtn="关闭" cancelBtn={null} onClose={() => setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}><div className="tn-secret"><code>{secret}</code><Button variant="outline" icon={<Copy size={15} />} onClick={() => { void copySecret(secret); }}>复制</Button></div><p className="tn-dialog-note">请妥善保管并将临时密码交付给管理员,该密码在首次登录时会被强制更新。</p></Dialog>
</Page>;
async function copySecret(value: string) {
try {
if (!navigator.clipboard) throw new Error("clipboard unavailable");
await navigator.clipboard.writeText(value);
notify?.("临时密码已复制", "info");
} catch {
notify?.("复制失败,请手动复制临时密码", "error");
}
}
}
function focusAdminField(fields: { username?: string; displayName?: string }) {
const selector = fields.username ? "[aria-invalid='true'], [aria-invalid='true'] input" : fields.displayName ? "[aria-invalid='true'], [aria-invalid='true'] input" : "";
if (!selector) return;
const focus = () => {
const node = document.querySelector<HTMLElement>(selector);
const target = node?.matches("input") ? node : node?.querySelector<HTMLElement>("input");
target?.focus();
};
requestAnimationFrame(focus);
window.setTimeout(focus, 60);
}