release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+115
View File
@@ -0,0 +1,115 @@
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 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 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);
}
+1
View File
@@ -0,0 +1 @@
export { default as AdminsPage } from "./AdminsPage";
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react";
import { Archive, RotateCcw, Search } from "lucide-react";
import { Button, Select, Space, Table, Tag } from "tdesign-react";
import AccessibleInput from "../../components/AccessibleInput";
import { useSearchParams } from "react-router-dom";
import { api } from "../../services/api";
import { dateText } from "../expenses/date";
import { AsyncState, ErrorBanner, Page } from "../common";
type AuditItem = { id: number; occurredAt: number; requestId: string; actorUsername?: string | null; action: string; targetType: string; targetId?: string | null; outcome?: string };
function outcomeLabel(outcome?: string): string {
if (!outcome || outcome === "success") return "成功";
if (outcome === "denied") return "已拒绝";
if (outcome === "failure") return "失败";
return outcome;
}
const ACTION_LABELS: Record<string, string> = {
deny: "拒绝请求",
"auth.login": "登录",
"auth.logout": "退出登录",
"auth.login_failed": "登录失败",
"expense.created": "创建账目",
"expense.updated": "更新账目",
"expense.status_changed": "切换报销状态",
"expense.trashed": "移入回收站",
"expense.restored": "恢复账目",
"expense.purged": "永久删除账目",
"expense.attachments_added": "添加附件",
"attachment.added": "添加附件",
"attachment.deleted": "删除附件",
"attachment.previewed": "预览附件",
"attachment.downloaded": "下载附件",
"export.created": "创建导出任务",
"export.downloaded": "下载导出文件",
"admin.created": "创建管理员",
"admin.updated": "更新管理员",
"admin.status_changed": "切换管理员状态",
"admin.password_changed": "修改管理员密码",
"admin.password_reset": "重置管理员密码",
"expense.attachment_deleted": "删除附件",
"expense.attachment_read": "读取附件",
"expense.purge": "永久删除账目",
"update.checked": "检查更新",
"update.apply_requested": "提交更新",
};
const TARGET_LABELS: Record<string, string> = {
expense: "账目",
admin: "管理员",
export: "导出任务",
session: "会话",
update: "更新任务",
system: "系统检查",
};
const ALLOWED_TARGETS = new Set(["", "expense", "admin", "export", "session", "update", "system"]);
export default function AuditPage({ timezone = "Asia/Shanghai" }: { timezone?: string }) {
const [searchParams, setSearchParams] = useSearchParams();
const [items, setItems] = useState<AuditItem[]>([]);
const rawAction = searchParams.get("action") || "";
const action = rawAction.trim().slice(0, 100);
const targetParam = searchParams.get("targetType") || "";
const targetType = ALLOWED_TARGETS.has(targetParam) ? targetParam : "";
const [actionDraft, setActionDraft] = useState(action);
const [targetDraft, setTargetDraft] = useState(targetType);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [hasMore, setHasMore] = useState(false);
const requestSequence = useRef(0);
const offsetRef = useRef(0);
const appendInFlight = useRef(false);
const pageSize = 100;
const load = async (append = false) => {
if (append && appendInFlight.current) return;
if (append) appendInFlight.current = true;
const sequence = ++requestSequence.current;
if (append) setLoadingMore(true); else { setLoading(true); setLoadingMore(false); }
setError("");
try {
const nextOffset = append ? offsetRef.current : 0;
const params = new URLSearchParams({ limit: String(pageSize), offset: String(nextOffset) });
if (action.trim()) params.set("action", action.trim());
if (targetType) params.set("targetType", targetType);
const result = await api<{ items: AuditItem[] }>(`/api/audit?${params}`);
if (sequence !== requestSequence.current) return;
setItems(current => {
if (!append) return result.items;
const existingIds = new Set(current.map(item => item.id));
return [...current, ...result.items.filter(item => !existingIds.has(item.id))];
});
offsetRef.current = nextOffset + result.items.length;
setHasMore(result.items.length === pageSize);
} catch (e) {
if (sequence === requestSequence.current) setError((e as Error).message);
}
finally {
if (append) appendInFlight.current = false;
if (sequence === requestSequence.current) { if (append) setLoadingMore(false); else setLoading(false); }
}
};
useEffect(() => {
const params = new URLSearchParams(searchParams);
let changed = false;
if (rawAction !== action) {
if (action) params.set("action", action); else params.delete("action");
changed = true;
}
if (targetParam !== targetType) {
params.delete("targetType");
changed = true;
}
if (changed) setSearchParams(params, { replace: true });
}, [action, rawAction, searchParams, setSearchParams, targetParam, targetType]);
useEffect(() => { setActionDraft(action); setTargetDraft(targetType); }, [action, targetType]);
useEffect(() => { void load(); }, [action, targetType]);
const applyFilters = () => {
const params = new URLSearchParams(searchParams);
const nextAction = actionDraft.trim().slice(0, 100);
if (nextAction) params.set("action", nextAction); else params.delete("action");
if (targetDraft) params.set("targetType", targetDraft); else params.delete("targetType");
if (nextAction === action && targetDraft === targetType) void load();
else setSearchParams(params);
};
const columns = [
{ colKey: "occurredAt", title: "时间", cell: ({ row }: any) => dateText(row.occurredAt, timezone) },
{ colKey: "actorUsername", title: "操作者", cell: ({ row }: any) => row.actorUsername || "系统" },
{ colKey: "action", title: "动作", cell: ({ row }: any) => <span className="tn-code" title={row.action}>{ACTION_LABELS[row.action] || row.action}</span> },
{ colKey: "target", title: "目标", cell: ({ row }: any) => <span className="tn-code" title={row.targetId ? `${row.targetType} / ${row.targetId}` : row.targetType}>{TARGET_LABELS[row.targetType] || row.targetType}{row.targetId ? ` / ${row.targetId.slice(0, 8)}` : ""}</span> },
{ colKey: "outcome", title: "结果", cell: ({ row }: any) => <Tag theme={row.outcome === "success" || !row.outcome ? "success" : row.outcome === "denied" ? "warning" : "danger"}>{outcomeLabel(row.outcome)}</Tag> },
];
return <Page title="审计日志" subtitle="记录登录、账目、附件、导出、管理员和更新操作。日志只读,永久删除也不会清除它。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="动作,例如 expense.created" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Archive size={30} /><p>{action || targetType ? "没有符合当前筛选条件的审计记录" : "暂无审计记录"}</p>{(action || targetType) && <Button variant="outline" onClick={() => setSearchParams(new URLSearchParams())}>清除筛选</Button>}</div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap tn-audit-table" role="region" aria-label="审计日志列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>{hasMore && <div className="tn-table-more"><Button variant="outline" onClick={() => void load(true)} disabled={loadingMore} icon={<RotateCcw size={15} />}>{loadingMore ? "加载中…" : "加载更早记录"}</Button></div>}</AsyncState>
</Page>;
}
+1
View File
@@ -0,0 +1 @@
export { default as AuditPage } from "./AuditPage";
@@ -0,0 +1,2 @@
export { default } from "./ChangePasswordPage";
export * from "./ChangePasswordPage";
@@ -0,0 +1,133 @@
import { useMemo, useState } from "react";
import { ArrowLeft, Check, KeyRound, ShieldCheck } from "lucide-react";
import { Alert, Button, Form } from "tdesign-react";
import { changePasswordThunk, clearSessionError, useAppDispatch, useAppSelector } from "../../store";
import type { Admin } from "../../types/auth";
import { StarterPasswordInput } from "./StarterAuth";
import { Page } from "../common";
import { useUnsavedChanges } from "../../contexts/UnsavedChanges";
export interface ChangePasswordPageProps {
admin?: Admin | null;
onSuccess?: (admin: Admin) => void;
onCancel?: () => void;
returnLabel?: string;
firstLogin?: boolean;
}
export default function ChangePasswordPage({ admin, onSuccess, onCancel, returnLabel = "返回当前页面", firstLogin = false }: ChangePasswordPageProps) {
const dispatch = useAppDispatch();
const sessionAdmin = useAppSelector((state) => state.session.admin);
const { status, error } = useAppSelector((state) => state.session);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmation?: string }>({});
const busy = status === "loading";
const dirty = Boolean(currentPassword || newPassword || confirmation);
useUnsavedChanges(dirty);
const displayAdmin = admin ?? sessionAdmin;
const isFirstLogin = firstLogin || Boolean(displayAdmin?.mustChangePassword);
const displayName = displayAdmin?.displayName || displayAdmin?.username || "当前管理员";
const requirements = useMemo(() => [
{ label: "至少 12 个字符", valid: newPassword.length >= 12 },
{ label: "两次输入保持一致", valid: Boolean(confirmation) && newPassword === confirmation },
], [confirmation, newPassword]);
const submit = async () => {
setFormError(null);
setFieldErrors({});
if (newPassword.length < 12) {
setFieldErrors({ newPassword: "新密码至少需要 12 位" });
focusPasswordField("新密码");
return;
}
if (newPassword !== confirmation) {
setFieldErrors({ confirmation: "两次输入的新密码不一致" });
focusPasswordField("确认新密码");
return;
}
try {
const updated = await dispatch(changePasswordThunk({ currentPassword, newPassword })).unwrap();
// Clear the local draft before notifying the parent. This keeps the
// unsaved-changes guard correct even when a host keeps this page mounted.
setCurrentPassword("");
setNewPassword("");
setConfirmation("");
setFieldErrors({});
onSuccess?.(updated);
} catch {
// The rejected thunk exposes its server message through session.error.
}
};
const form = <Form
className="tn-password-form"
labelAlign="top"
labelWidth={0}
onSubmit={(context) => { if (context.validateResult === true) void submit(); }}
>
<Form.FormItem label="当前密码" name="currentPassword" rules={[{ required: true, message: "请输入当前密码", type: "error" }]}>
<StarterPasswordInput ariaLabel="当前密码" value={currentPassword} onChange={value => { setCurrentPassword(value); setFormError(null); dispatch(clearSessionError()); }} autocomplete="current-password" disabled={busy} placeholder="请输入当前密码" />
</Form.FormItem>
<Form.FormItem label="新密码" name="newPassword" status={fieldErrors.newPassword ? "error" : undefined} help={fieldErrors.newPassword} rules={[{ required: true, min: 12, message: "新密码至少需要 12 位", type: "error" }]}>
<StarterPasswordInput ariaLabel="新密码" ariaInvalid={Boolean(fieldErrors.newPassword)} ariaDescribedby={fieldErrors.newPassword ? "password-new-error" : undefined} value={newPassword} onChange={value => { setNewPassword(value); setFieldErrors(current => ({ ...current, newPassword: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请输入至少 12 位的新密码" />
{fieldErrors.newPassword && <span id="password-new-error" className="tn-sr-only">{fieldErrors.newPassword}</span>}
</Form.FormItem>
<Form.FormItem label="确认新密码" name="confirmation" status={fieldErrors.confirmation ? "error" : undefined} help={fieldErrors.confirmation} rules={[{ required: true, message: "请再次输入新密码", type: "error" }]}>
<StarterPasswordInput ariaLabel="确认新密码" ariaInvalid={Boolean(fieldErrors.confirmation)} ariaDescribedby={fieldErrors.confirmation ? "password-confirmation-error" : undefined} value={confirmation} onChange={value => { setConfirmation(value); setFieldErrors(current => ({ ...current, confirmation: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请再次输入新密码" />
{fieldErrors.confirmation && <span id="password-confirmation-error" className="tn-sr-only">{fieldErrors.confirmation}</span>}
</Form.FormItem>
<Form.FormItem className="tn-password-submit">
<div className="tn-password-actions">
<Button className="tn-password-submit-button" theme="primary" type="submit" loading={busy} disabled={busy}>更新密码</Button>
</div>
</Form.FormItem>
</Form>;
const panel = <section className={`tn-password-card${isFirstLogin ? " tn-password-card-first" : ""}`} aria-labelledby="password-card-title">
<header className="tn-password-card-head">
<div className="tn-password-card-icon" aria-hidden="true"><KeyRound size={20} /></div>
<div>
<h2 id="password-card-title">{isFirstLogin ? "设置登录密码" : "修改登录密码"}</h2>
<p>{isFirstLogin ? "首次登录需要先设置一个新的登录密码。" : `当前账号:${displayName}`}</p>
</div>
</header>
{(formError || error) && <div role="alert" aria-live="assertive"><Alert className="tn-password-alert" theme="error" message={formError || error || "密码更新失败"} /></div>}
<div className="tn-password-card-body">
<div>{form}</div>
<aside className="tn-password-guidance" aria-label="密码要求">
<div className="tn-password-guidance-title"><ShieldCheck size={18} />密码要求</div>
<p>请使用不易猜测且未在其他服务重复使用的密码。</p>
<ul>
{requirements.map((item) => <li key={item.label} className={item.valid ? "is-valid" : ""}><Check size={14} />{item.label}</li>)}
</ul>
</aside>
</div>
</section>;
if (!isFirstLogin) {
return <Page title="修改密码" subtitle="更新当前管理员的登录凭据。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
}
return <main className="tn-login-page" data-page="change-password">
<section className="tn-login-container tn-password-container">
<div className="tn-login-heading">
<h1 id="password-title" className="tn-login-title">首次登录保护</h1>
<p className="tn-login-subtitle">管理员 {displayName} 需要先设置新密码。</p>
</div>
{panel}
</section>
</main>;
}
function focusPasswordField(label: string) {
const focus = () => {
const nodes = Array.from(document.querySelectorAll<HTMLElement>("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea"));
const target = nodes.find(node => node.closest(".t-form__item")?.textContent?.includes(label));
target?.focus();
};
requestAnimationFrame(focus);
window.setTimeout(focus, 60);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./LoginPage";
export * from "./LoginPage";
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import { Alert, Button, Checkbox, Form } from "tdesign-react";
import { useAppDispatch, useAppSelector, clearSessionError, loginThunk } from "../../store";
import type { Admin } from "../../types/auth";
import { LoginUserIcon, StarterPasswordInput } from "./StarterAuth";
import AccessibleInput from "../../components/AccessibleInput";
const REMEMBERED_USERNAME_KEY = "tallynote.login.username";
export interface LoginPageProps {
notice?: string;
onSuccess?: (admin: Admin) => void;
}
function getRememberedUsername(): string {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(REMEMBERED_USERNAME_KEY) ?? "";
} catch {
return "";
}
}
export default function LoginPage({ notice, onSuccess }: LoginPageProps) {
const dispatch = useAppDispatch();
const { status, error } = useAppSelector((state) => state.session);
const [username, setUsername] = useState(getRememberedUsername);
const [password, setPassword] = useState("");
const [rememberAccount, setRememberAccount] = useState(Boolean(username));
const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({});
const busy = status === "loading";
const updateUsername = (value: string) => {
setUsername(value);
setFieldErrors(current => ({ ...current, username: undefined }));
if (error) dispatch(clearSessionError());
};
const updatePassword = (value: string) => {
setPassword(value);
setFieldErrors(current => ({ ...current, password: undefined }));
if (error) dispatch(clearSessionError());
};
const submit = async () => {
const normalizedUsername = username.trim();
const nextErrors: { username?: string; password?: string } = {};
if (!normalizedUsername) nextErrors.username = "请输入用户名";
if (!password) nextErrors.password = "请输入密码";
if (Object.keys(nextErrors).length) {
setFieldErrors(nextErrors);
const focusInvalid = () => document.querySelector<HTMLElement>("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea")?.focus();
requestAnimationFrame(focusInvalid);
window.setTimeout(focusInvalid, 60);
return;
}
setFieldErrors({});
try {
if (rememberAccount) {
window.localStorage.setItem(REMEMBERED_USERNAME_KEY, normalizedUsername);
} else {
window.localStorage.removeItem(REMEMBERED_USERNAME_KEY);
}
} catch {
// Private browsing or a locked-down browser may reject localStorage.
}
try {
const admin = await dispatch(loginThunk({ username: normalizedUsername, password })).unwrap();
onSuccess?.(admin);
} catch {
// The rejected thunk exposes its server message through session.error.
}
};
return (
<main className="tn-login-page" data-page="login">
<section className="tn-login-container" aria-labelledby="login-title">
<div className="tn-login-heading">
<h1 id="login-title" className="tn-login-title">登录到 <span className="tn-login-title-brand">TallyNote</span></h1>
</div>
<Form
className="tn-login-form"
labelWidth={0}
onSubmit={(context) => { context.e?.preventDefault(); void submit(); }}
>
{notice && <div role="status" aria-live="polite"><Alert className="tn-login-alert" theme="info" message={notice} /></div>}
{error && <div role="alert" aria-live="assertive"><Alert className="tn-login-alert" theme="error" message={error} /></div>}
<Form.FormItem label="用户名" requiredMark={false} name="username" status={fieldErrors.username ? "error" : undefined} help={fieldErrors.username}>
<AccessibleInput
inputAriaLabel="用户名"
inputAriaInvalid={Boolean(fieldErrors.username)}
inputAriaDescribedby={fieldErrors.username ? "login-username-error" : undefined}
size="large"
value={username}
onChange={(value) => updateUsername(String(value))}
clearable
disabled={busy}
autocomplete="username"
autofocus
prefixIcon={<LoginUserIcon />}
placeholder="请输入用户名"
/>
{fieldErrors.username && <span id="login-username-error" className="tn-sr-only">{fieldErrors.username}</span>}
</Form.FormItem>
<Form.FormItem label="密码" requiredMark={false} name="password" status={fieldErrors.password ? "error" : undefined} help={fieldErrors.password}>
<StarterPasswordInput
ariaLabel="密码"
ariaInvalid={Boolean(fieldErrors.password)}
ariaDescribedby={fieldErrors.password ? "login-password-error" : undefined}
value={password}
onChange={updatePassword}
autocomplete="current-password"
disabled={busy}
placeholder="请输入登录密码"
/>
{fieldErrors.password && <span id="login-password-error" className="tn-sr-only">{fieldErrors.password}</span>}
</Form.FormItem>
<div className="tn-login-options"><Checkbox checked={rememberAccount} onChange={setRememberAccount} disabled={busy}>记住账号</Checkbox></div>
<Form.FormItem className="tn-login-submit">
<Button block size="large" theme="primary" type="submit" loading={busy} disabled={busy}>登录</Button>
</Form.FormItem>
</Form>
</section>
</main>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from "react";
import AccessibleInput from "../../components/AccessibleInput";
import { Eye, EyeOff, LockKeyhole, UserRound, X } from "lucide-react";
export interface StarterPasswordInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
ariaLabel: string;
autocomplete?: string;
disabled?: boolean;
ariaInvalid?: boolean;
ariaDescribedby?: string;
}
/** Starter-style password field with clear and show/hide affordances. */
export function StarterPasswordInput({
value,
onChange,
placeholder,
ariaLabel,
autocomplete,
disabled = false,
ariaInvalid,
ariaDescribedby,
}: StarterPasswordInputProps) {
const [visible, setVisible] = useState(false);
const toggle = () => setVisible((current) => !current);
return (
<AccessibleInput
inputAriaLabel={ariaLabel}
inputAriaInvalid={ariaInvalid}
inputAriaDescribedby={ariaDescribedby}
size="large"
value={value}
onChange={(next) => onChange(String(next))}
type={visible ? "text" : "password"}
disabled={disabled}
autocomplete={autocomplete}
prefixIcon={<LockKeyhole className="tn-login-input-icon" size={18} />}
// A defined (empty) suffix prevents TDesign's password icon from being
// added in addition to the Starter-style control below.
suffixIcon={<span className="tn-login-suffix-placeholder" aria-hidden="true" />}
suffix={<span className="tn-login-suffix-actions">
{value && <button type="button" className="tn-login-clear" aria-label="清空密码" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange("")}><X size={15} /></button>}
<button type="button" className="tn-login-password-toggle" aria-label={visible ? "隐藏密码" : "显示密码"} aria-pressed={visible} onMouseDown={(event) => event.preventDefault()} onClick={toggle}>
{visible ? <Eye size={17} /> : <EyeOff size={17} />}
</button>
</span>}
placeholder={placeholder}
/>
);
}
export function LoginUserIcon() {
return <UserRound className="tn-login-prefix-icon" aria-hidden="true" size={18} />;
}
+4
View File
@@ -0,0 +1,4 @@
export { default as LoginPage } from "./LoginPage";
export { default as ChangePasswordPage } from "./ChangePasswordPage";
export type { LoginPageProps } from "./LoginPage";
export type { ChangePasswordPageProps } from "./ChangePasswordPage";
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { AlertCircle, Loader2 } from "lucide-react";
import { Alert, Button, Card, Loading, Space, Tag } from "tdesign-react";
export function Page({ eyebrow, title, subtitle, actions, children, className = "" }: {
eyebrow?: string;
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return <section className={`tn-page ${className}`}>
<div className="tn-page-head">
<div>{eyebrow && <div className="tn-eyebrow">{eyebrow}</div>}<h1 className="tn-page-title">{title}</h1>{subtitle && <p className="tn-page-subtitle">{subtitle}</p>}</div>
{actions && <div className="tn-page-actions">{actions}</div>}
</div>
{children}
</section>;
}
export function AsyncState({ loading, error, empty, onRetry, children }: {
loading: boolean;
error?: string;
empty?: ReactNode;
onRetry?: () => void;
children: ReactNode;
}) {
if (loading && empty) return <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div>;
if (error && empty) return <div className="tn-empty" role="alert"><AlertCircle size={28} /><p>{error}</p>{onRetry && <Button variant="outline" onClick={onRetry}>重试</Button>}</div>;
return <>
{loading && <div className="tn-inline-loading" role="status"><Loader2 size={15} className="tn-spin" aria-hidden="true" />正在更新…</div>}
{error && <ErrorBanner message={error} onRetry={onRetry} />}
{empty || children}
</>;
}
export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {
return <div role="alert"><Alert theme="error" className="tn-alert" icon={<AlertCircle size={16} />} message={<Space size="small"><span>{message}</span>{onRetry && <Button variant="text" onClick={onRetry}>重试</Button>}</Space>} /></div>;
}
export function BusyIcon() { return <Loader2 size={16} className="tn-spin" aria-hidden="true" />; }
export function StatusTag({ status }: { status: "unreimbursed" | "reimbursed" | "active" | "disabled" }) {
const labels = { unreimbursed: "未报销", reimbursed: "已报销", active: "有效", disabled: "已停用" } as const;
const themes = { unreimbursed: "warning", reimbursed: "success", active: "success", disabled: "default" } as const;
return <Tag theme={themes[status]} variant="light-outline">{labels[status]}</Tag>;
}
export function Surface({ children, className = "" }: { children: ReactNode; className?: string }) {
return <Card bordered className={`tn-flat-section ${className}`}>{children}</Card>;
}
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
import { Alert, Button, Card, DatePicker, Empty, Loading, Space, Statistic, Table, Tag, Tooltip } from "tdesign-react";
import ReactEChartsCore from "echarts-for-react/lib/core";
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import { useSearchParams } from "react-router-dom";
import { api } from "../../services/api";
import { dateText, money, monthNow } from "../expenses/date";
import type { Expense } from "../expenses/types";
import type { RouteId } from "../../router";
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
type Props = { timezone?: string; onNavigate?: (id: RouteId, search?: string) => void };
type ExpenseResult = { items: Expense[]; summary: { count: number; amountCents: number } };
function prefersReducedMotion(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
}
export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const rawMonthParam = searchParams.get("month");
const defaultMonth = monthNow(timezone);
const month = rawMonthParam && /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonthParam) ? rawMonthParam : defaultMonth;
const [unreimbursed, setUnreimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
const [reimbursed, setReimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [loadedMonth, setLoadedMonth] = useState<string | null>(null);
const requestSequence = useRef(0);
const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReducedMotion(media.matches);
sync();
if (typeof media.addEventListener === "function") {
media.addEventListener("change", sync);
return () => media.removeEventListener("change", sync);
}
media.addListener?.(sync);
return () => media.removeListener?.(sync);
}, []);
useEffect(() => {
if (!rawMonthParam || rawMonthParam === month) return;
const params = new URLSearchParams(searchParams);
params.set("month", month);
setSearchParams(params, { replace: true });
}, [month, rawMonthParam, searchParams, setSearchParams]);
const setDashboardMonth = (nextMonth: string) => {
const params = new URLSearchParams(searchParams);
params.set("month", nextMonth);
setSearchParams(params);
};
const load = async () => {
const sequence = ++requestSequence.current;
setLoading(true);
setError("");
try {
const [pending, done] = await Promise.all([
api<ExpenseResult>(`/api/expenses?month=${month}&status=unreimbursed&query=&missingInvoice=false`),
api<ExpenseResult>(`/api/expenses?month=${month}&status=reimbursed&query=&missingInvoice=false`),
]);
if (sequence !== requestSequence.current) return;
setUnreimbursed(pending);
setReimbursed(done);
setLoadedMonth(month);
} catch (caught) {
if (sequence === requestSequence.current) setError((caught as Error).message);
} finally {
if (sequence === requestSequence.current) setLoading(false);
}
};
useEffect(() => { void load(); }, [month]);
const allItems = useMemo(() => [...unreimbursed.items, ...reimbursed.items].sort((a, b) => b.paidAt - a.paidAt), [unreimbursed.items, reimbursed.items]);
const totalCents = unreimbursed.summary.amountCents + reimbursed.summary.amountCents;
const totalCount = unreimbursed.summary.count + reimbursed.summary.count;
const missingInvoiceCount = allItems.filter((item) => item.invoiceCount === 0).length;
const recentItems = allItems.slice(0, 8);
const reimbursedPercent = totalCount ? Math.round((reimbursed.summary.count / totalCount) * 100) : 0;
const hasCurrentSnapshot = loadedMonth === month;
const expensesSearch = `?month=${encodeURIComponent(month)}`;
const chartDateFormatter = useMemo(() => new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }), [timezone]);
const chartOption = useMemo(() => {
const grouped = new Map<string, { label: string; unreimbursed: number; reimbursed: number }>();
for (const item of allItems) {
const parts = Object.fromEntries(chartDateFormatter.formatToParts(new Date(item.paidAt)).map((part) => [part.type, part.value]));
const key = `${parts.year}-${parts.month}-${parts.day}`;
const label = `${parts.month}/${parts.day}`;
const current = grouped.get(key) ?? { label, unreimbursed: 0, reimbursed: 0 };
current[item.status] += item.amountCents / 100;
grouped.set(key, current);
}
const values = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
return {
animation: !reducedMotion,
animationDuration: reducedMotion ? 0 : 380,
color: ["#175cd3", "#2f7d5c"],
tooltip: { trigger: "axis", valueFormatter: (value: number) => `¥${value.toFixed(2)}` },
legend: { top: 0, right: 0, textStyle: { color: "#667085" }, data: ["未报销", "已报销"] },
grid: { left: 48, right: 20, top: 38, bottom: 30 },
xAxis: { type: "category", data: values.map((value) => value.label), boundaryGap: false, axisLine: { lineStyle: { color: "#dce3ec" } }, axisLabel: { color: "#667085" } },
yAxis: { type: "value", axisLabel: { color: "#667085", formatter: (value: number) => `¥${value}` }, splitLine: { lineStyle: { color: "#edf1f5" } } },
series: [
{ name: "未报销", type: "line", smooth: true, symbol: "circle", symbolSize: 6, data: values.map((value) => value.unreimbursed), lineStyle: { width: 2 }, areaStyle: { color: "rgba(23,92,211,.08)" } },
{ name: "已报销", type: "line", smooth: true, symbol: "circle", symbolSize: 6, data: values.map((value) => value.reimbursed), lineStyle: { width: 2 }, areaStyle: { color: "rgba(47,125,92,.06)" } },
],
};
}, [allItems, chartDateFormatter, reducedMotion]);
const shiftMonth = (delta: number) => {
const [rawYear, rawMonth] = month.split("-").map(Number);
const year = rawYear ?? new Date().getFullYear();
const monthNumber = rawMonth ?? 1;
const next = new Date(year, monthNumber - 1 + delta, 1);
setDashboardMonth(`${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`);
};
const columns = [
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <strong className="tn-amount">{money(row.amountCents)}</strong> },
{ colKey: "note", title: "备注", cell: ({ row }: any) => row.note || <span className="tn-muted">无备注</span>, ellipsis: true },
{ colKey: "status", title: "状态", cell: ({ row }: any) => <Tag theme={row.status === "reimbursed" ? "success" : "warning"}>{row.status === "reimbursed" ? "已报销" : "未报销"}</Tag> },
];
return <section className="tn-page tn-dashboard-page">
<div className="tn-page-head">
<div><h1 className="tn-page-title">仪表盘</h1><p className="tn-page-subtitle">快速查看本月采购报销进度和待处理账目。</p></div>
<div className="tn-page-actions"><div className="tn-dashboard-actions"><div className="tn-dashboard-month-control"><Tooltip content="上个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={16} />} /></Tooltip><DatePicker className="tn-dashboard-month" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) setDashboardMonth(next); }} inputProps={{ "aria-label": "仪表盘月份" } as any} /><Tooltip content="下个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={16} />} /></Tooltip></div><div className="tn-dashboard-secondary-actions"><Button className="tn-dashboard-refresh" variant="outline" onClick={() => void load()} disabled={loading} icon={<RefreshCw size={15} />}>刷新</Button><Button className="tn-dashboard-view" theme="primary" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看账目</Button></div></div></div>
</div>
{error && hasCurrentSnapshot && <Alert theme="error" icon={<AlertCircle size={16} />} message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />}
{loading ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载仪表盘…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
<div className="tn-dashboard-stats">
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-total"><Statistic title="本月总额" value={totalCents / 100} prefix="¥" decimalPlaces={2} /></Card>
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-count"><Statistic title="账目笔数" value={totalCount} suffix="笔" /></Card>
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-pending"><Statistic title="未报销" value={unreimbursed.summary.amountCents / 100} prefix="¥" decimalPlaces={2} /></Card>
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-missing"><Statistic title="缺发票" value={missingInvoiceCount} suffix="笔" /></Card>
</div>
<div className="tn-dashboard-grid">
<Card bordered className="tn-dashboard-chart" title={<DashboardCardTitle title="支付趋势" subtitle="按支付日期汇总本月全部账目" />}>
{allItems.length ? <div role="img" aria-label={`${month} 支付趋势:共 ${totalCount} 笔,未报销 ${unreimbursed.summary.count} 笔,已报销 ${reimbursed.summary.count} 笔。`}><ReactEChartsCore echarts={echarts} option={chartOption} style={{ height: 286, width: "100%" }} notMerge lazyUpdate /></div> : <div className="tn-dashboard-chart-empty"><span>本月暂无支付数据</span><small>新增账目后,这里会显示每日支付趋势。</small></div>}
</Card>
<Card bordered className="tn-dashboard-progress" title={<DashboardCardTitle title="报销进度" subtitle="本月账目状态分布" />}><div className="tn-progress-ring" style={{ background: `conic-gradient(var(--tn-blue-700) ${reimbursedPercent}%, #e7edf3 0)` }} role="img" aria-label={`已报销 ${reimbursedPercent}%`}><div><strong>{reimbursedPercent}%</strong><span>已报销</span></div></div><div className="tn-dashboard-progress-legend"><span><i className="is-blue" />未报销 <strong>{unreimbursed.summary.count} 笔</strong></span><span><i className="is-green" />已报销 <strong>{reimbursed.summary.count} 笔</strong></span></div></Card>
</div>
<Card bordered className="tn-dashboard-recent" title={<DashboardCardTitle title="最近账目" subtitle="按支付时间倒序排列" />} actions={<Button variant="text" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看全部</Button>}>
{recentItems.length ? <div className="tn-table-wrap tn-dashboard-table" role="region" aria-label="最近账目列表,可横向滚动查看更多列"><Table rowKey="id" data={recentItems} columns={columns as any} hover stripe /> </div> : <Empty description="本月暂无账目" />}
</Card>
</>}
</section>;
}
function DashboardCardTitle({ title, subtitle }: { title: string; subtitle: string }) {
return <div className="tn-dashboard-card-title"><span>{title}</span><small>{subtitle}</small></div>;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as DashboardPage } from "./DashboardPage";
export { default } from "./DashboardPage";
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AlertCircle, ArrowDownToLine, FileText, Image as ImageIcon, Loader2, Search, Settings, Trash2, X } from "lucide-react";
import { Button, Dialog, Drawer, Loading, Space, Tag, Textarea, Tooltip } from "tdesign-react";
import { api, ApiError } from "../../services/api";
import { dateText, formatBytes, money } from "./date";
import type { Attachment, Expense, Notify, TimelineEvent } from "./types";
import { useDrawerFocus } from "../../hooks/useDrawerFocus";
type Props = { expense: Expense; timezone?: string; onClose: () => void; onUpdated: () => void; onRequestEdit: (expense: Expense) => void; notify?: Notify };
const TIMELINE_LABELS: Record<string, string> = {
"expense.created": "创建账目",
"expense.updated": "更新账目",
"expense.status_changed": "切换报销状态",
"expense.trashed": "移入回收站",
"expense.restored": "从回收站恢复",
"attachment.added": "添加附件",
"attachment.deleted": "删除附件",
};
export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onClose, onUpdated, onRequestEdit, notify }: Props) {
const [detail, setDetail] = useState(expense); const [timeline, setTimeline] = useState<TimelineEvent[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [action, setAction] = useState<"status" | "trash" | null>(null); const [removeTarget, setRemoveTarget] = useState<Attachment | null>(null); const [removeReason, setRemoveReason] = useState(""); const [preview, setPreview] = useState<Attachment | null>(null); const [removeError, setRemoveError] = useState("");
const requestSequence = useRef(0);
useDrawerFocus(true, ".tn-detail-drawer");
const load = useCallback(async () => {
const currentSequence = ++requestSequence.current;
const currentExpenseId = expense.id;
setLoading(true);
setError("");
try {
const result = await api<{ expense: Expense; timeline: TimelineEvent[] }>(`/api/expenses/${currentExpenseId}`);
if (currentSequence !== requestSequence.current || result.expense.id !== currentExpenseId) return;
setDetail(result.expense);
setTimeline(result.timeline || []);
} catch (caught) {
if (currentSequence === requestSequence.current) setError((caught as Error).message);
} finally {
if (currentSequence === requestSequence.current) setLoading(false);
}
}, [expense.id]);
useEffect(() => {
setDetail(expense);
setTimeline([]);
setRemoveTarget(null);
setPreview(null);
void load();
return () => { requestSequence.current += 1; };
}, [expense.id, load]);
const recoverConflict = (caught: unknown, setMessage: (message: string) => void) => {
if (!(caught instanceof ApiError) || caught.status !== 409) return false;
const current = (caught.details as { current?: Expense } | undefined)?.current;
if (!current) return false;
setDetail(current);
setMessage("这笔账目刚被其他管理员修改,已加载最新版本,请确认后重试。");
return true;
};
const updateStatus = async () => { setBusy(true); try { const next = detail.status === "reimbursed" ? "unreimbursed" : "reimbursed"; const result = await api<{ expense: Expense }>(`/api/expenses/${detail.id}/status`, { method: "POST", body: JSON.stringify({ status: next, version: detail.version }) }); setDetail(result.expense); setAction(null); notify?.(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
const trash = async () => { setBusy(true); try { await api(`/api/expenses/${detail.id}`, { method: "DELETE", body: JSON.stringify({ version: detail.version }) }); notify?.("账目已移入回收站", "success"); setAction(null); onClose(); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
const remove = async () => { if (!removeTarget) return; const requires = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim(); if (requires && !removeReason.trim()) { setRemoveError("请填写无发票原因"); return; } setBusy(true); setRemoveError(""); try { const body: { version: number; invoiceMissingReason?: string } = { version: detail.version }; if (requires) body.invoiceMissingReason = removeReason.trim(); const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(body) }); setDetail(result.expense); setRemoveTarget(null); notify?.("附件已删除", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setRemoveError)) setRemoveError((caught as Error).message); } finally { setBusy(false); } };
return <>
<Drawer className="tn-content-drawer tn-detail-drawer" placement="right" size="520px" visible destroyOnClose header="账目详情" onClose={onClose} footer={<Space><Button onClick={() => setAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button theme="danger" onClick={() => setAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></Space>}>
{loading ? <div role="status" aria-live="polite"><Loading text="加载详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
<div className="expense-detail-head"><strong>{money(detail.amountCents)}</strong><Button variant="outline" onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
<dl><div><dt>支付时间</dt><dd>{dateText(detail.paidAt, timezone)}</dd></div><div><dt>发票</dt><dd>{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <><Tag theme="warning">无发票</Tag>:{detail.invoiceMissingReason}</> : <Tag theme="danger">未说明</Tag>}</dd></div><div><dt>状态</dt><dd><Tag theme={detail.status === "reimbursed" ? "success" : "warning"}>{detail.status === "reimbursed" ? "已报销" : "未报销"}</Tag></dd></div><div><dt>备注</dt><dd>{detail.note || "无"}</dd></div></dl>
<h3>附件 <small>{detail.attachments?.length || 0}</small></h3><div className="expense-attachments">{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return <div className="expense-attachment" key={a.id}><span title={a.originalName}>{a.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />} {a.originalName}<small>{formatBytes(a.sizeBytes)}</small></span><Space>{a.previewable && <Tooltip content="预览附件" placement="left"><Button variant="text" shape="circle" onClick={() => setPreview(a)} aria-label={`预览 ${a.originalName}`}><Search size={15} /></Button></Tooltip>}<Tooltip content="下载附件" placement="left"><Button variant="text" shape="circle" onClick={() => { window.location.href = `/api/attachments/${a.id}/content?download=1`; }} aria-label={`下载 ${a.originalName}`}><ArrowDownToLine size={15} /></Button></Tooltip><Tooltip content={protectsLastProof ? "至少保留一张付款凭证" : "删除附件"} placement="left"><Button variant="text" shape="circle" theme="danger" disabled={protectsLastProof} onClick={() => { setRemoveReason(""); setRemoveError(""); setRemoveTarget(a); }} aria-label={protectsLastProof ? `不可删除最后一张付款凭证 ${a.originalName}` : `删除 ${a.originalName}`}><Trash2 size={14} /></Button></Tooltip></Space></div>; })}</div>
{timeline.length > 0 && <><h3>操作记录</h3><div className="expense-timeline">{timeline.slice(0, 12).map(t => <div key={t.id}><span>{dateText(t.occurredAt, timezone)}</span><strong title={t.action}>{TIMELINE_LABELS[t.action] || t.action}</strong><small>{t.actorUsername || "系统"}</small></div>)}</div></>}
</div>}
</Drawer>
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"}</Dialog>
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。</Dialog>
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert"><AlertCircle size={16} />{removeError}</div>}</>}</Dialog>
<Dialog visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
</>;
}
@@ -0,0 +1,169 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, Check, FileUp, Loader2, X } from "lucide-react";
import { Button, Checkbox, DatePicker, Dialog, Drawer, Textarea, Tooltip, Upload } from "tdesign-react";
import AccessibleInput from "../../components/AccessibleInput";
import { api, ApiError } from "../../services/api";
import { apiFieldErrors } from "../../services/formErrors";
import { dateFromInput, dateInputValue, formatBytes } from "./date";
import type { Expense, Notify } from "./types";
import { useUnsavedActions, useUnsavedChanges } from "../../contexts/UnsavedChanges";
import { useDrawerFocus } from "../../hooks/useDrawerFocus";
type Props = { expense?: Expense | null; timezone?: string; onClose: () => void; onSaved: () => void; notify?: Notify };
type UploadFile = File & { raw?: File };
type ExpenseField = "paidAt" | "amount" | "note" | "paymentProofs" | "invoices" | "invoiceMissingReason";
export default function ExpenseDrawer({ expense, timezone = "Asia/Shanghai", onClose, onSaved, notify }: Props) {
const initial = useMemo(() => ({ amount: expense ? (expense.amountCents / 100).toFixed(2) : "", note: expense?.note || "", paidAt: expense ? dateInputValue(new Date(expense.paidAt), timezone) : dateInputValue(new Date(), timezone), reason: expense?.invoiceMissingReason || "" }), [expense, timezone]);
const [amount, setAmount] = useState(initial.amount); const [note, setNote] = useState(initial.note); const [paidAt, setPaidAt] = useState(initial.paidAt);
const [proofs, setProofs] = useState<File[]>([]); const [invoices, setInvoices] = useState<File[]>([]); const [missing, setMissing] = useState(Boolean(initial.reason)); const [reason, setReason] = useState(initial.reason);
const [serverInvoiceCount, setServerInvoiceCount] = useState(expense?.invoiceCount || 0); const [version, setVersion] = useState(expense?.version || 1); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [conflict, setConflict] = useState<Expense | null>(null);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<ExpenseField, string>>>({});
const [datePickerOpen, setDatePickerOpen] = useState(false);
const submitInFlight = useRef(false);
useDrawerFocus(true, ".tn-expense-drawer");
useEffect(() => { setAmount(initial.amount); setNote(initial.note); setPaidAt(initial.paidAt); setReason(initial.reason); setMissing(Boolean(initial.reason)); setServerInvoiceCount(expense?.invoiceCount || 0); setVersion(expense?.version || 1); setProofs([]); setInvoices([]); }, [initial, expense]);
// The drawer is mounted through a portal; explicitly hydrate from the record
// identity as well so a portal reuse cannot leave stale blank controls.
useEffect(() => {
if (!expense) return;
setAmount((expense.amountCents / 100).toFixed(2));
setNote(expense.note || "");
setPaidAt(dateInputValue(new Date(expense.paidAt), timezone));
setReason(expense.invoiceMissingReason || "");
setMissing(Boolean(expense.invoiceMissingReason));
setServerInvoiceCount(expense.invoiceCount || 0);
setVersion(expense.version || 1);
}, [expense?.id, expense?.amountCents, expense?.paidAt, expense?.note, expense?.invoiceMissingReason, expense?.invoiceCount, expense?.version, timezone]);
useEffect(() => {
// TDesign keeps its native file input hidden and does not forward ARIA
// props to it. Label both inputs explicitly for assistive technology;
// the visible trigger buttons remain the primary keyboard targets.
const inputs: Array<[string, string, string, string]> = [
[".tn-payment-proof-upload input[type=file]", "expense-payment-proofs-input", "expense-payment-proofs-label", "付款凭证文件"],
[".tn-invoice-upload input[type=file]", "expense-invoices-input", "expense-invoices-label", "发票文件"],
];
const applyLabels = () => inputs.forEach(([selector, id, labelledBy, label]) => {
const input = document.querySelector<HTMLInputElement>(selector);
if (!input) return;
input.id = id;
input.setAttribute("aria-labelledby", labelledBy);
input.setAttribute("aria-label", label);
});
// Drawer content is portalled after the first effect pass. Retry on the
// next frame and once after the opening transition so the native inputs
// receive their labels in both mounted and animated states.
applyLabels();
const frame = window.requestAnimationFrame(applyLabels);
const timer = window.setTimeout(applyLabels, 260);
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(timer); };
}, []);
const hasInvoice = serverInvoiceCount + invoices.length > 0;
const dirty = proofs.length > 0 || invoices.length > 0 || amount !== initial.amount || note !== initial.note || paidAt !== initial.paidAt || reason !== initial.reason || missing !== Boolean(initial.reason);
const { requestDiscard } = useUnsavedActions();
useUnsavedChanges(dirty);
const clearField = (...fields: ExpenseField[]) => {
setFieldErrors(current => {
const next = { ...current };
fields.forEach(field => delete next[field]);
return next;
});
setError("");
};
const choose = (kind: "proof" | "invoice") => (files: any[]) => {
clearField(kind === "proof" ? "paymentProofs" : "invoices", "invoiceMissingReason");
const next = files.map((f: any) => (f.raw || f.originFileObj || f) as UploadFile).filter((f: any) => f instanceof File || f?.name);
if (kind === "proof") setProofs(next.slice(0, Math.max(0, 20 - invoices.length))); else { setInvoices(next.slice(0, Math.max(0, 20 - proofs.length))); if (next.length) { setMissing(false); setReason(""); } }
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (busy || submitInFlight.current) return;
setError("");
const nextErrors: Partial<Record<ExpenseField, string>> = {};
if (!expense && !proofs.length) nextErrors.paymentProofs = "至少选择一张付款凭证";
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(amount) || Number(amount) <= 0) nextErrors.amount = "金额必须为大于零且最多两位小数";
let iso = "";
try { iso = dateFromInput(paidAt, timezone); } catch (caught) { nextErrors.paidAt = (caught as Error).message; }
const normalized = missing ? reason.trim() : "";
if (!hasInvoice && !normalized) nextErrors.invoiceMissingReason = "请上传发票,或勾选“无发票”并填写原因";
if (Object.keys(nextErrors).length) { setFieldErrors(nextErrors); setError("请检查标记的字段后再保存"); focusExpenseField(nextErrors); return; }
setFieldErrors({});
submitInFlight.current = true;
setBusy(true);
try {
if (expense) {
// A multipart edit carries the fields and all new attachments in one
// optimistic-lock transaction. This prevents partial edits when one
// upload or a later validation fails.
const body = new FormData();
body.append("paidAt", iso);
body.append("amount", amount);
body.append("note", note);
body.append("invoiceMissingReason", hasInvoice ? "" : normalized);
body.append("version", String(version));
proofs.forEach(file => body.append("paymentProofs", file));
invoices.forEach(file => body.append("invoices", file));
const updated = await api<{ expense: Expense }>(`/api/expenses/${expense.id}`, { method: "PATCH", body });
setVersion(updated.expense.version);
setServerInvoiceCount(updated.expense.invoiceCount);
} else { const body = new FormData(); body.append("paidAt", iso); body.append("amount", amount); body.append("note", note); body.append("invoiceMissingReason", normalized); proofs.forEach(f => body.append("paymentProofs", f)); invoices.forEach(f => body.append("invoices", f)); await api("/api/expenses", { method: "POST", body }); }
notify?.(expense ? "账目已更新" : "账目已保存", "success"); onSaved(); onClose();
} catch (caught) {
if (caught instanceof ApiError && caught.status === 409 && (caught as any).details?.current) setConflict((caught as any).details.current);
else {
const mapped = apiFieldErrors(caught) as Partial<Record<ExpenseField, string>>;
if (Object.keys(mapped).length) { setFieldErrors(mapped); setError("请检查标记的字段后再保存"); focusExpenseField(mapped); }
else setError((caught as Error).message);
}
} finally { submitInFlight.current = false; setBusy(false); }
};
const closeDrawer = (context?: { trigger?: string }) => {
// ESC first belongs to the nested date/time popup. TDesign bubbles the
// same key to Drawer, so consume that close event while the popup is open.
if (context?.trigger === "esc" && datePickerOpen) {
setDatePickerOpen(false);
return;
}
requestDiscard(onClose);
};
return <>
<Drawer className="tn-content-drawer tn-expense-drawer" placement="right" size="480px" visible destroyOnClose header={expense ? "编辑账目" : "新增账目"} onClose={closeDrawer} footer={<><Button variant="outline" onClick={() => requestDiscard(onClose)} disabled={busy}>取消</Button><Button theme="primary" onClick={() => { void submit({ preventDefault() {} } as React.FormEvent); }} disabled={busy}>{busy ? <Loader2 size={16} className="spin" /> : <><Check size={16} />保存</>}</Button></>}>
<form className="expense-form" onSubmit={submit}>
<label className="tn-form-field"><span>支付时间</span><DatePicker disabled={busy} inputProps={{ "aria-label": "支付时间", "aria-invalid": Boolean(fieldErrors.paidAt), "aria-describedby": fieldErrors.paidAt ? "expense-paid-at-error" : undefined } as any} popupProps={{ onVisibleChange: (visible: boolean) => setDatePickerOpen(visible) }} enableTimePicker format="YYYY-MM-DD HH:mm" value={paidAt} onChange={(v: any) => { setPaidAt(String(v)); clearField("paidAt"); }} /><small>选择日期和时间(精确到分钟,按应用时区保存)</small><FieldError id="expense-paid-at-error" message={fieldErrors.paidAt} /></label>
<label className="tn-form-field"><span>金额(元)</span><AccessibleInput disabled={busy} inputAriaLabel="金额(元)" inputAriaInvalid={Boolean(fieldErrors.amount)} inputAriaDescribedby={fieldErrors.amount ? "expense-amount-error" : undefined} value={amount} onChange={value => { setAmount(value); clearField("amount"); }} placeholder="0.00" /><FieldError id="expense-amount-error" message={fieldErrors.amount} /></label>
<label className="tn-form-field"><span>备注</span><Textarea disabled={busy} aria-label="备注" aria-invalid={Boolean(fieldErrors.note)} aria-describedby={fieldErrors.note ? "expense-note-error" : undefined} value={note} onChange={value => { setNote(value); clearField("note"); }} maxlength={2000} placeholder="可选" /><FieldError id="expense-note-error" message={fieldErrors.note} /></label>
<div className="tn-form-field" role="group" aria-labelledby="expense-payment-proofs-label"><span id="expense-payment-proofs-label">付款凭证(至少 1 张)</span><Upload disabled={busy} className="tn-payment-proof-upload" aria-label="选择付款凭证文件" aria-labelledby="expense-payment-proofs-label" multiple autoUpload={false} accept="image/jpeg,image/png,image/webp" onChange={choose("proof") as any} trigger={<Button type="button" variant="outline" disabled={busy} aria-invalid={Boolean(fieldErrors.paymentProofs)} aria-describedby={fieldErrors.paymentProofs ? "expense-proof-error" : undefined} icon={<FileUp size={15} />}>选择付款凭证</Button>} /><FieldError id="expense-proof-error" message={fieldErrors.paymentProofs} /></div>
{proofs.length > 0 && <FileList files={proofs} disabled={busy} remove={f => setProofs(proofs.filter(x => x !== f))} />}
<div className="tn-form-field" role="group" aria-labelledby="expense-invoices-label"><span id="expense-invoices-label">发票(可选)</span><Upload disabled={busy} className="tn-invoice-upload" aria-label="选择发票文件" aria-labelledby="expense-invoices-label" multiple autoUpload={false} accept=".pdf,.ofd,.xml,application/pdf,application/ofd,application/xml" onChange={choose("invoice") as any} trigger={<Button type="button" variant="outline" disabled={busy} aria-invalid={Boolean(fieldErrors.invoices)} aria-describedby={fieldErrors.invoices ? "expense-invoice-error" : undefined} icon={<FileUp size={15} />}>选择发票</Button>} /><FieldError id="expense-invoice-error" message={fieldErrors.invoices} /></div>
{invoices.length > 0 && <FileList files={invoices} disabled={busy} remove={f => setInvoices(invoices.filter(x => x !== f))} />}
<Checkbox className="invoice-missing-toggle" aria-invalid={Boolean(fieldErrors.invoiceMissingReason)} aria-describedby={fieldErrors.invoiceMissingReason ? "expense-reason-error" : undefined} checked={missing} disabled={busy || hasInvoice} onChange={(checked: boolean) => { setMissing(checked); clearField("invoiceMissingReason"); if (!checked) setReason(""); }}>无发票</Checkbox>
{!hasInvoice && !missing && (fieldErrors.invoiceMissingReason ? <small id="expense-reason-error" className="tn-field-error" role="alert">{fieldErrors.invoiceMissingReason}</small> : <small className="tn-form-help">请上传发票,或勾选“无发票”并填写原因。</small>)}
{hasInvoice && <small>已上传发票,无需填写无发票原因。</small>}
{missing && !hasInvoice && <label className="tn-form-field"><span>无发票原因</span><Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(fieldErrors.invoiceMissingReason)} aria-describedby={fieldErrors.invoiceMissingReason ? "expense-reason-error" : undefined} value={reason} onChange={value => { setReason(value); clearField("invoiceMissingReason"); }} maxlength={500} placeholder="例如:商家无法开具发票" /><FieldError id="expense-reason-error" message={fieldErrors.invoiceMissingReason} /></label>}
{error && <div role="alert" className="expense-error"><AlertCircle size={16} />{error}</div>}
</form>
</Drawer>
<Dialog visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("冲突提示已关闭,请再次保存以重新确认最新版本。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("当前内容已保留,请再次保存以覆盖服务器版本。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>另一位管理员刚刚修改了这笔账目。请选择如何处理,系统不会静默覆盖。</Dialog>
</>;
}
function focusExpenseField(errors: Partial<Record<ExpenseField, string>>) {
const selectors: Partial<Record<ExpenseField, string>> = {
paidAt: "[aria-describedby='expense-paid-at-error'], [aria-describedby='expense-paid-at-error'] input",
amount: "[aria-describedby='expense-amount-error'] input, [aria-describedby='expense-amount-error']",
note: "[aria-describedby='expense-note-error'] textarea, [aria-describedby='expense-note-error']",
paymentProofs: "[aria-describedby='expense-proof-error']",
invoices: "[aria-describedby='expense-invoice-error']",
invoiceMissingReason: "[aria-describedby='expense-reason-error'] textarea, [aria-describedby='expense-reason-error'], .invoice-missing-toggle",
};
const field = (["paidAt", "amount", "note", "paymentProofs", "invoices", "invoiceMissingReason"] as ExpenseField[]).find(key => errors[key]);
if (!field) return;
requestAnimationFrame(() => {
const node = document.querySelector<HTMLElement>(selectors[field] || "");
const target = node && /^(INPUT|TEXTAREA|BUTTON|SELECT)$/.test(node.tagName)
? node
: node?.querySelector<HTMLElement>("input, textarea, button, select");
target?.focus();
});
}
function FieldError({ id, message }: { id: string; message?: string }) { return message ? <small id={id} className="tn-field-error" role="alert">{message}</small> : null; }
function FileList({ files, remove, disabled = false }: { files: File[]; remove: (f: File) => void; disabled?: boolean }) { return <ul className="expense-file-list">{files.map(f => <li key={`${f.name}-${f.size}-${f.lastModified}`}><FileUp size={14} />{f.name}<small>{formatBytes(f.size)}</small><Tooltip content="移除文件" placement="left"><Button variant="text" shape="circle" disabled={disabled} onClick={() => remove(f)} aria-label={`移除文件 ${f.name}`}><X size={14} /></Button></Tooltip></li>)}</ul>; }
@@ -0,0 +1,131 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronLeft, ChevronRight, ClipboardList, FileDown, FileText, Pencil, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { Button, Checkbox, DatePicker, Dialog, Loading, Radio, Space, Table, Tag, Tooltip } from "tdesign-react";
import { useSearchParams } from "react-router-dom";
import { api } from "../../services/api";
import ExpenseDrawer from "./ExpenseDrawer";
import ExpenseDetail from "./ExpenseDetail";
import AccessibleInput from "../../components/AccessibleInput";
import { dateText, money, monthNow } from "./date";
import type { Expense, Notify } from "./types";
type Props = { timezone?: string; notify?: Notify; sessionKey?: string };
const EXPORT_JOB_STORAGE_KEY = "tallynote.exportJobId";
function savedExportJob(storageKey: string): string | null {
try { return window.sessionStorage.getItem(storageKey); } catch { return null; }
}
export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai", notify, sessionKey = "current" }: Props) {
const timezone = timezoneProp;
const exportStorageKey = `${EXPORT_JOB_STORAGE_KEY}.${sessionKey}`;
const [searchParams, setSearchParams] = useSearchParams();
const rawMonthParam = searchParams.get("month");
const rawMonth = rawMonthParam || monthNow(timezone);
const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : monthNow(timezone);
const rawStatus = searchParams.get("status");
const status: "unreimbursed" | "reimbursed" = rawStatus === "reimbursed" ? "reimbursed" : "unreimbursed";
const rawQuery = searchParams.get("query") || "";
const query = rawQuery.slice(0, 200);
const rawMissingInvoice = searchParams.get("missingInvoice");
const missingInvoice = searchParams.get("missingInvoice") === "true";
const [queryDraft, setQueryDraft] = useState(query);
const [items, setItems] = useState<Expense[]>([]); const [summary, setSummary] = useState({ count: 0, amountCents: 0 }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(null); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
const filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`;
useEffect(() => {
const params = new URLSearchParams(searchParams);
let changed = false;
if (rawMonthParam && rawMonthParam !== month) { params.set("month", month); changed = true; }
if (rawStatus && rawStatus !== status) { params.set("status", status); changed = true; }
if (rawQuery !== query) {
if (query) params.set("query", query); else params.delete("query");
changed = true;
}
if (rawMissingInvoice && rawMissingInvoice !== "true") { params.delete("missingInvoice"); changed = true; }
if (changed) setSearchParams(params, { replace: true });
}, [month, query, rawMissingInvoice, rawMonthParam, rawQuery, rawStatus, searchParams, setSearchParams, status]);
useEffect(() => { setQueryDraft(query); }, [query]);
const updateFilters = (next: { month?: string; status?: "unreimbursed" | "reimbursed"; query?: string; missingInvoice?: boolean }) => {
const params = new URLSearchParams(searchParams);
params.set("month", next.month ?? month);
params.set("status", next.status ?? status);
const nextQuery = (next.query ?? query).trim().slice(0, 200);
if (nextQuery) params.set("query", nextQuery); else params.delete("query");
if (next.missingInvoice ?? missingInvoice) params.set("missingInvoice", "true"); else params.delete("missingInvoice");
setSearchParams(params);
};
const load = async () => { const s = ++sequence.current; const requestedKey = filterKey; setLoading(true); setError(""); try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(query)}&missingInvoice=${missingInvoice}`); if (s === sequence.current) { setItems(result.items); setSummary(result.summary); setLoadedFilterKey(requestedKey); setSelectedKeys(keys => keys.filter(k => result.items.some(x => x.id === k))); } } catch (e) { if (s === sequence.current) { setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); setError((e as Error).message); } } finally { if (s === sequence.current) setLoading(false); } };
useEffect(() => { setSelectedKeys([]); setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); void load(); }, [filterKey]);
const selectedTotal = useMemo(() => items.filter(i => selectedKeys.includes(i.id)).reduce((sum, i) => sum + i.amountCents, 0), [items, selectedKeys]);
const shiftMonth = (delta: number) => { const [rawYear, rawMonthNumber] = month.split("-").map(Number); const y = rawYear || new Date().getFullYear(); const m = rawMonthNumber || 1; const d = new Date(y, m - 1 + delta, 1); updateFilters({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` }); };
const rememberExportJob = (jobId: string | null) => {
setExporting(jobId);
try {
if (jobId) window.sessionStorage.setItem(exportStorageKey, jobId);
else window.sessionStorage.removeItem(exportStorageKey);
} catch { /* Session storage can be unavailable in hardened browsers. */ }
};
const exportAll = async () => { if (exporting || exportStarting.current) return; exportStarting.current = true; try { const body = selectedKeys.length ? { ids: selectedKeys, includeManifest } : { month, status, query, missingInvoice, includeManifest }; const r = await api<{ job: { id: string } }>("/api/exports", { method: "POST", body: JSON.stringify(body) }); setExportIssue(""); rememberExportJob(r.job.id); notify?.(includeManifest ? "导出任务已创建(含 manifest.json)" : "导出任务已创建", "info"); } catch (e) { notify?.((e as Error).message, "error"); } finally { exportStarting.current = false; } };
useEffect(() => {
if (!exporting) return;
let disposed = false;
let timer: number | undefined;
let failureCount = 0;
const finish = () => { setExportIssue(""); rememberExportJob(null); };
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
const poll = async () => {
try {
const r = await api<{ job: { status: string; errorMessage?: string } }>(`/api/exports/${exporting}`);
if (disposed) return;
failureCount = 0;
setExportIssue("");
if (r.job.status === "ready") {
const downloadUrl = `/api/exports/${exporting}/download`;
finish();
window.location.href = downloadUrl;
} else if (["failed", "expired"].includes(r.job.status)) {
finish();
notify?.(r.job.errorMessage || "导出失败", "error");
} else {
schedule(1000);
}
} catch {
if (disposed) return;
failureCount += 1;
setExportIssue("网络连接不稳定,导出仍在后台进行,正在重新查询状态…");
schedule(Math.min(1000 * (2 ** Math.min(failureCount, 3)), 8000));
}
};
void poll();
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
}, [exporting, notify]);
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
const columns = [
{ colKey: "row-select", type: "multiple" },
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <strong>{money(row.amountCents)}</strong> },
{ colKey: "note", title: "备注", cell: ({ row }: any) => row.note || <span className="tn-muted">无备注</span>, ellipsis: true },
{ colKey: "paymentProofCount", title: "凭证" },
{ colKey: "invoiceCount", title: "发票", cell: ({ row }: any) => row.invoiceCount === 0 ? <Tag theme="danger" title={row.invoiceMissingReason || "缺发票"}>{row.invoiceMissingReason ? `无发票:${row.invoiceMissingReason}` : "缺发票"}</Tag> : row.invoiceCount },
{ colKey: "status", title: "状态", cell: ({ row }: any) => <Tag theme={row.status === "reimbursed" ? "success" : "warning"}>{row.status === "reimbursed" ? "已报销" : "未报销"}</Tag> },
{ colKey: "op", title: "操作", width: 150, cell: ({ row }: any) => {
const label = row.note || "账目";
return <Space className="tn-action-group"><Tooltip content="查看详情"><Button variant="text" shape="square" onClick={() => { setSelected(row); setDrawer("detail"); }} aria-label={`查看 ${label}`} icon={<FileText size={16} />} /></Tooltip><Tooltip content="编辑"><Button variant="text" shape="square" onClick={() => { setSelected(row); setDrawer("edit"); }} aria-label={`编辑 ${label}`} icon={<Pencil size={15} />} /></Tooltip><Tooltip content="移入回收站"><Button variant="text" shape="square" theme="danger" onClick={() => setTrashTarget(row)} aria-label={`将 ${label} 移入回收站`} icon={<Trash2 size={15} />} /></Tooltip></Space>;
}},
];
const emptyState = query || missingInvoice
? <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>没有符合当前筛选条件的账目</p><Button variant="outline" onClick={() => { setQueryDraft(""); updateFilters({ query: "", missingInvoice: false }); }}>清除筛选</Button></div>
: status === "reimbursed"
? <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>本月暂无已报销账目</p><Button variant="outline" onClick={() => updateFilters({ status: "unreimbursed" })}>查看未报销</Button></div>
: <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>本月暂无未报销账目</p><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={15} />}>新增第一笔</Button></div>;
const dataReady = loadedFilterKey === filterKey;
return <div className="tn-page expenses-page"><div className="tn-page-head expenses-head"><div><h1 className="tn-page-title">账目列表</h1></div><div className="tn-page-actions"><Checkbox checked={includeManifest} onChange={setIncludeManifest}>包含 manifest.json</Checkbox><Button variant="outline" onClick={() => void exportAll()} disabled={Boolean(exporting) || (!selectedKeys.length && (!items.length || !dataReady))} icon={<FileDown size={16} />}>{exporting ? "导出中…" : selectedKeys.length ? `导出所选(${selectedKeys.length})` : "导出筛选结果"}</Button><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={16} />}>新增账目</Button></div></div>
<div className="tn-toolbar expenses-toolbar"><div className="tn-expense-month-controls"><Tooltip content="上个月"><Button variant="text" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={18} />} /></Tooltip><DatePicker className="tn-month-picker" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) updateFilters({ month: next }); }} placeholder="选择月份" inputProps={{ "aria-label": "账目月份" } as any} /><Tooltip content="下个月"><Button variant="text" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={18} />} /></Tooltip></div><Radio.Group className="tn-segmented" theme="button" variant="primary-filled" value={status} onChange={(value: any) => updateFilters({ status: value as "unreimbursed" | "reimbursed" })} aria-label="报销状态"><Radio.Button value="unreimbursed">未报销</Radio.Button><Radio.Button value="reimbursed">已报销</Radio.Button></Radio.Group><div className="tn-expense-search-controls"><AccessibleInput inputAriaLabel="搜索备注" className="tn-expense-search" value={queryDraft} onChange={setQueryDraft} onEnter={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} maxlength={200} placeholder="搜索备注" prefixIcon={<Search size={16} />} suffix={queryDraft ? <Tooltip content="清除搜索"><Button variant="text" shape="square" onClick={() => { setQueryDraft(""); updateFilters({ query: "" }); }} aria-label="清除搜索" icon={<X size={14} />} /></Tooltip> : undefined} /><Button variant="outline" onClick={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} disabled={loading} icon={<Search size={15} />}>搜索</Button></div><div className="tn-expense-filter-actions"><Checkbox checked={missingInvoice} onChange={checked => updateFilters({ missingInvoice: checked })}>缺发票</Checkbox><Tooltip content="刷新当前结果"><Button variant="outline" shape="square" onClick={() => void load()} disabled={loading} aria-label="刷新当前结果" icon={<RefreshCw size={15} />} /></Tooltip></div></div>
<div className="tn-summary expenses-summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong>{selectedKeys.length > 0 && <><span>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}</span><Button variant="text" onClick={() => setSelectedKeys([])}>清除选择</Button></>}</div>
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>停止等待</Button></div>}
{error && <div className="expense-error" role="alert"><AlertCircle size={16} />{error}<Button variant="text" onClick={() => void load()}>重试</Button></div>}
{error ? null : !dataReady || (loading && !items.length) ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
{drawer === "new" && <ExpenseDrawer timezone={timezone} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />}
{trashTarget && <Dialog visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</Dialog>}
</div>;
}
+26
View File
@@ -0,0 +1,26 @@
export function money(cents: number) { return `¥${(cents / 100).toFixed(2)}`; }
export function dateText(ms: number, timezone = "Asia/Shanghai") { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: timezone }).format(new Date(ms)); }
export function monthNow(timezone = "Asia/Shanghai") {
const p = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit" }).formatToParts(new Date()).map(x => [x.type, x.value]));
return `${p.year}-${p.month}`;
}
export function dateInputValue(date: Date, timezone = "Asia/Shanghai") {
const p = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(date).map(x => [x.type, x.value]));
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`;
}
export function dateFromInput(value: string, timezone = "Asia/Shanghai") {
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(value.trim());
if (!m) throw new Error("请选择有效的支付日期和时间");
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
const hour = Number(m[4]);
const minute = Number(m[5]);
const probe = new Date(0); probe.setUTCFullYear(year, month - 1, day); probe.setUTCHours(0, 0, 0, 0);
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day || hour > 23 || minute > 59) throw new Error("支付时间无效");
const wall = Date.UTC(year, month - 1, day, hour, minute);
const observed = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(new Date(wall)).map(x => [x.type, x.value]));
const observedUtc = Date.UTC(Number(observed.year), Number(observed.month) - 1, Number(observed.day), Number(observed.hour), Number(observed.minute));
return new Date(wall + wall - observedUtc).toISOString();
}
export function formatBytes(bytes: number) { return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`; }
+5
View File
@@ -0,0 +1,5 @@
export { default } from "./ExpensesPage";
export { default as ExpensesPage } from "./ExpensesPage";
export { default as ExpenseDrawer } from "./ExpenseDrawer";
export { default as ExpenseDetail } from "./ExpenseDetail";
export * from "./types";
+6
View File
@@ -0,0 +1,6 @@
export type ExpenseStatus = "unreimbursed" | "reimbursed";
export type AttachmentKind = "payment_proof" | "invoice";
export type Attachment = { id: string; kind: AttachmentKind; originalName: string; mimeType: string; sizeBytes: number; previewable: boolean };
export type Expense = { id: string; paidAt: number; amountCents: number; note: string; invoiceMissingReason: string | null; status: ExpenseStatus; version: number; paymentProofCount: number; invoiceCount: number; deletedAt?: number | null; attachments?: Attachment[] };
export type TimelineEvent = { id: number; occurredAt: number; actorUsername?: string | null; action: string };
export type Notify = (message: string, kind?: "success" | "error" | "info") => void;
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import { Button, Dialog, Space, Table } from "tdesign-react";
import { api } from "../../services/api";
import AccessibleInput from "../../components/AccessibleInput";
import type { Notify } from "../expenses/types";
import type { Expense } from "../expenses/types";
import { dateText, money } from "../expenses/date";
import { AsyncState, BusyIcon, ErrorBanner, Page, StatusTag } from "../common";
export default function TrashPage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
const [items, setItems] = useState<Expense[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [purgeTarget, setPurgeTarget] = useState<Expense | null>(null);
const [password, setPassword] = useState("");
const [purgeError, setPurgeError] = useState("");
const loadSequence = useRef(0);
const load = async () => {
const sequence = ++loadSequence.current;
setLoading(true); setError("");
try {
const result = await api<{ items: Expense[] }>("/api/trash");
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 restore = async (item: Expense) => {
setBusy(true); setError("");
try { await api(`/api/trash/${item.id}/restore`, { method: "POST", body: JSON.stringify({ version: item.version }) }); notify?.("账目已恢复", "success"); await load(); }
catch (e) { setError((e as Error).message); }
finally { setBusy(false); }
};
const purge = async () => {
if (!purgeTarget || !password) { setPurgeError("请输入当前管理员密码"); return; }
setBusy(true); setPurgeError("");
try { await api(`/api/trash/${purgeTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: purgeTarget.version, password }) }); setPurgeTarget(null); setPassword(""); notify?.("账目已永久删除,审计历史仍保留", "success"); await load(); }
catch (e) { setPurgeError((e as Error).message); }
finally { setBusy(false); }
};
const columns = [
{ colKey: "deletedAt", title: "删除时间", cell: ({ row }: any) => row.deletedAt ? dateText(row.deletedAt, timezone) : "-" },
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <span className="tn-amount">{money(row.amountCents)}</span> },
{ colKey: "note", title: "备注", cell: ({ row }: any) => <span className="tn-note">{row.note || "无备注"}</span> },
{ colKey: "status", title: "原状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
{ colKey: "actions", title: "操作", width: 190, cell: ({ row }: any) => <Space className="tn-action-group"><Button variant="outline" onClick={() => void restore(row)} disabled={busy} icon={busy ? <BusyIcon /> : <RotateCcw size={15} />}>恢复</Button><Button theme="danger" variant="outline" onClick={() => { setPurgeTarget(row); setPassword(""); setPurgeError(""); }} disabled={busy} icon={<Trash2 size={15} />}>永久删除</Button></Space> },
];
return <Page title="回收站" subtitle="已删除的账目会保留附件,可恢复或经过密码确认后永久删除。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || busy} icon={<RotateCcw size={15} />}>刷新</Button>}>
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Trash2 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>
<Dialog visible={Boolean(purgeTarget)} header="永久删除账目" confirmBtn={{ content: "永久删除", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }} onConfirm={() => void purge()} onCancel={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }}>
<p>此操作会移除账目和附件字节,完整审计内容仍会保留,且无法恢复。</p>
<p className="tn-dialog-note">请输入当前管理员密码确认。</p>
<AccessibleInput inputAriaLabel="当前管理员密码" inputAriaInvalid={Boolean(purgeError)} inputAriaDescribedby={purgeError ? "trash-purge-error" : undefined} type="password" value={password} onChange={value => { setPassword(value); setPurgeError(""); }} placeholder="当前管理员密码" autocomplete="current-password" />
{purgeError && <div id="trash-purge-error" className="tn-inline-error" role="alert">{purgeError}</div>}
</Dialog>
</Page>;
}
+1
View File
@@ -0,0 +1 @@
export { default as TrashPage } from "./TrashPage";
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal } from "lucide-react";
import { Button, Dialog, Tag } from "tdesign-react";
import { ApiError, api } from "../../services/api";
import { dateText } from "../expenses/date";
import { ErrorBanner, Page, Surface } from "../common";
import type { Notify } from "../expenses/types";
type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
type UpdateJob = { id: string; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt: number; updatedAt: number; completedAt?: number | null };
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: { version: string; tagName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number } | null; job: UpdateJob | null };
const active = new Set<JobStatus>(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]);
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "准备完成", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [error, setError] = useState("");
const [pollError, setPollError] = useState("");
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [reloadReady, setReloadReady] = useState(false);
const announced = useRef<string | null>(null);
const checkInFlight = useRef(false);
const applyInFlight = useRef(false);
const load = async () => { setLoading(true); setError(""); try { setInfo(await api<UpdateInfo>("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
useEffect(() => {
const job = info?.job;
if (!job) { setPollError(""); return; }
const announceCompletion = (completedJob: UpdateJob) => {
if (completedJob.status === "completed" && announced.current !== completedJob.id) {
announced.current = completedJob.id;
setReloadReady(true);
notify?.("更新完成,请重新加载页面", "success");
}
};
if (job.status === "completed") {
setPollError("");
announceCompletion(job);
return;
}
if (!active.has(job.status)) { setPollError(""); return; }
let disposed = false;
let timer: number | undefined;
let failureCount = 0;
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
const poll = async () => {
try {
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
if (disposed) return;
failureCount = 0;
setPollError("");
setInfo(current => current ? { ...current, job: result.job } : current);
announceCompletion(result.job);
if (active.has(result.job.status)) schedule(1500);
} catch (caught) {
if (disposed) return;
failureCount += 1;
setPollError(`${(caught as Error).message}。更新任务仍在后台运行,页面会自动重试。`);
schedule(Math.min(1500 * (2 ** Math.min(failureCount, 3)), 12_000));
}
};
void poll();
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
}, [info?.job?.id, info?.job?.status, notify]);
const check = async () => {
if (checkInFlight.current) return;
checkInFlight.current = true;
setChecking(true);
setError("");
try {
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null }));
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
} catch (caught) {
// A configured release source is intentionally rate-limited. A repeated
// click should still be useful: show the cached status instead of a
// blocking error, while preserving the server-side flood protection.
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
try {
await load();
const seconds = caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查";
notify?.(`已显示最近一次检查结果${seconds}`, "info");
return;
} catch {
// Fall through to the normal error surface if the status read fails.
}
}
setError((caught as Error).message);
} finally {
checkInFlight.current = false;
setChecking(false);
}
};
const apply = async () => { if (!confirmVersion || applyInFlight.current) return; applyInFlight.current = true; setApplying(true); setError(""); try { const result = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: confirmVersion, confirm: true }) }); setConfirmVersion(null); setInfo(current => current ? { ...current, job: result.job } : current); notify?.("更新请求已提交,服务会短暂重启", "info"); } catch (e) { setError((e as Error).message); } finally { applyInFlight.current = false; setApplying(false); } };
const latest = info?.latest; const job = info?.job; const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !job || info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job && !active.has(job.status));
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
{error && <ErrorBanner message={error} onRetry={() => void load()} />}
{pollError && <ErrorBanner message={pollError} />}
{loading ? <div className="tn-empty" role="status" aria-live="polite">正在读取版本信息…</div> : info && <>
<div className="tn-update-grid"><Surface className="tn-update-block"><Server size={20} /><span className="tn-eyebrow">当前版本</span><strong className="tn-update-value">v{info.currentVersion}</strong><small>运行平台:{info.platform.target}</small></Surface><Surface className="tn-update-block"><ShieldCheck size={20} /><span className="tn-eyebrow">更新方式</span><strong>{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}</strong><small>{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}</small></Surface></div>
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div><div className="tn-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canApply && <Button theme="primary" onClick={() => setConfirmVersion(latest.version)} disabled={applying} icon={<Download size={16} />}>更新到 v{latest.version}</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
{!info.configured && <div className="tn-update-explainer"><Terminal size={17} /><div><strong>当前为手动更新模式</strong><p>源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。</p></div></div>}
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>更新服务正在后台运行,页面会自动刷新状态。</small></>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
</>}
<Dialog visible={Boolean(confirmVersion)} header="确认系统更新" confirmBtn={{ content: "确认更新", theme: "primary", loading: applying, disabled: applying }} cancelBtn="取消" onClose={() => { if (!applying) setConfirmVersion(null); }} onConfirm={() => void apply()} onCancel={() => { if (!applying) setConfirmVersion(null); }}>将更新到 v{confirmVersion}。服务会短暂重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</Dialog>
</Page>;
}
+1
View File
@@ -0,0 +1 @@
export { default as UpdatePage } from "./UpdatePage";