Files
TallyNote/web/src/main.tsx
T
Qiufeng 91621df6c4
TallyNote release / linux-x64 (push) Successful in 6m34s
fix: recover stuck update queue
2026-09-03 12:39:44 +08:00

893 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { createRoot } from "react-dom/client";
import {
AlertCircle,
Archive,
ArrowDownToLine,
CheckCircle2,
Check,
ChevronLeft,
ChevronRight,
CircleDollarSign,
ClipboardList,
Copy,
Eye,
FileDown,
FileText,
Image as ImageIcon,
Loader2,
LogOut,
Menu,
Plus,
RotateCcw,
RefreshCw,
Search,
Settings,
ShieldCheck,
Server,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import "./styles.css";
type Admin = {
id: string;
username: string;
displayName: string;
status: string;
mustChangePassword: boolean;
version: number;
lastLoginAt?: number | null;
};
type Attachment = {
id: string;
kind: "payment_proof" | "invoice";
originalName: string;
mimeType: string;
sizeBytes: number;
previewable: boolean;
};
type Expense = {
id: string;
paidAt: number;
amountCents: number;
note: string;
invoiceMissingReason: string | null;
status: "unreimbursed" | "reimbursed";
version: number;
paymentProofCount: number;
invoiceCount: number;
deletedAt?: number | null;
attachments?: Attachment[];
createdByName?: string;
updatedByName?: string;
};
type TimelineEvent = {
id: number;
occurredAt: number;
actorUsername?: string | null;
action: string;
};
type Notice = { id: number; kind: "success" | "error" | "info"; message: string };
type UpdateJob = {
id: string;
status: "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
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;
};
class ApiError extends Error {
constructor(public readonly status: number, message: string, public readonly code?: string, public readonly details?: any) {
super(message);
}
}
let appTimezone = "Asia/Shanghai";
const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
const dateText = (ms: number) => new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: appTimezone }).format(new Date(ms));
const dateInputValue = (date: Date) => {
const parts = new Intl.DateTimeFormat("en-CA", { timeZone: appTimezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}`;
};
const dateFromInput = (value: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(value.trim());
if (!match) throw new Error("请选择有效的支付日期和时间");
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const calendarProbe = new Date(0);
calendarProbe.setUTCFullYear(year, month - 1, day);
calendarProbe.setUTCHours(0, 0, 0, 0);
if (month < 1 || month > 12 || day < 1 || day > 31 || calendarProbe.getUTCFullYear() !== year || calendarProbe.getUTCMonth() !== month - 1 || calendarProbe.getUTCDate() !== day || hour < 0 || hour > 23 || minute < 0 || minute > 59) throw new Error("支付时间无效");
const wall = new Date(0);
wall.setUTCFullYear(year, month - 1, day);
wall.setUTCHours(hour, minute, 0, 0);
const utc = wall.getTime();
const parts = new Intl.DateTimeFormat("en-CA", { timeZone: appTimezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(new Date(utc));
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
const observed = Date.UTC(Number(values.year), Number(values.month) - 1, Number(values.day), Number(values.hour), Number(values.minute));
return new Date(utc + (utc - observed)).toISOString();
};
const monthNow = () => {
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: appTimezone, year: "numeric", month: "2-digit" }).formatToParts(new Date()).map((part) => [part.type, part.value]));
return `${parts.year}-${parts.month}`;
};
const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
const readCookie = (name: string) => document.cookie.split("; ").find((value) => value.startsWith(`${name}=`))?.split("=")[1] ?? "";
async function api<T = any>(url: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (["POST", "PUT", "PATCH", "DELETE"].includes((init.method || "GET").toUpperCase())) {
const raw = readCookie("tally_csrf");
try { headers.set("X-CSRF-Token", decodeURIComponent(raw)); } catch { headers.set("X-CSRF-Token", raw); }
}
let response: Response;
try {
response = await fetch(url, { credentials: "include", ...init, headers });
} catch {
throw new ApiError(0, "网络连接失败,请确认服务仍在运行");
}
if (response.status === 204) return undefined as T;
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const code = data?.error?.code;
if (response.status === 401 && code === "AUTH_REQUIRED" && !["/api/auth/login", "/api/auth/session", "/api/auth/change-password"].includes(url)) {
window.dispatchEvent(new CustomEvent("tallynote-auth-expired", { detail: data?.error?.message || "登录已失效,请重新登录" }));
}
throw new ApiError(response.status, data?.error?.message || `请求失败(${response.status})`, code, data?.error?.details);
}
return data;
}
function Button({ children, kind = "default", ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { kind?: "default" | "primary" | "danger" | "ghost" }) {
return <button className={`btn btn-${kind}`} {...props}>{children}</button>;
}
function TooltipLayer() {
const activeRef = useRef<HTMLElement | null>(null);
const [tooltip, setTooltip] = useState<{ id: string; label: string; left: number; top: number; placement: "above" | "below" } | null>(null);
const update = useCallback((element: HTMLElement) => {
const label = element.getAttribute("title");
if (!label) return;
const rect = element.getBoundingClientRect();
const placement = rect.top > 56 ? "above" : "below";
setTooltip({ id: "tallynote-tooltip", label, left: rect.left + rect.width / 2, top: placement === "above" ? rect.top : rect.bottom, placement });
}, []);
useEffect(() => {
const show = (event: Event) => {
const target = (event.target as Element | null)?.closest<HTMLElement>("[title]");
if (!target) return;
activeRef.current = target;
update(target);
};
const hide = (event: Event) => {
const target = activeRef.current;
const related = (event as MouseEvent).relatedTarget as Node | null;
if (target && related && target.contains(related)) return;
activeRef.current = null;
setTooltip(null);
};
const onViewportChange = () => { if (activeRef.current) update(activeRef.current); };
document.addEventListener("pointerover", show);
document.addEventListener("pointerout", hide);
document.addEventListener("focusin", show);
document.addEventListener("focusout", hide);
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
return () => {
document.removeEventListener("pointerover", show);
document.removeEventListener("pointerout", hide);
document.removeEventListener("focusin", show);
document.removeEventListener("focusout", hide);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
};
}, [update]);
if (!tooltip) return null;
const style: React.CSSProperties = tooltip.placement === "above"
? { left: tooltip.left, top: tooltip.top, transform: "translate(-50%, calc(-100% - 8px))" }
: { left: tooltip.left, top: tooltip.top, transform: "translate(-50%, 8px)" };
return createPortal(<span id={tooltip.id} className="tooltip" role="tooltip" style={style}>{tooltip.label}</span>, document.body);
}
let overlayLockCount = 0;
let previousBodyOverflow = "";
let previousBodyPaddingRight = "";
function useOverlayScrollLock() {
useLayoutEffect(() => {
const body = document.body;
if (overlayLockCount === 0) {
previousBodyOverflow = body.style.overflow;
previousBodyPaddingRight = body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
const existingPaddingRight = Number.parseFloat(window.getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${existingPaddingRight + scrollbarWidth}px`;
}
body.style.overflow = "hidden";
}
overlayLockCount += 1;
return () => {
overlayLockCount = Math.max(0, overlayLockCount - 1);
if (overlayLockCount === 0) {
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
}
};
}, []);
}
function NoticeRegion({ notices, dismiss }: { notices: Notice[]; dismiss: (id: number) => void }) {
return <div className="notice-region" aria-live="polite" aria-atomic="true">{notices.map((notice) => <div key={notice.id} className={`notice notice-${notice.kind}`} role={notice.kind === "error" ? "alert" : "status"}><span>{notice.kind === "error" ? <AlertCircle size={16} /> : <Check size={16} />}{notice.message}</span><button className="icon-btn compact" onClick={() => dismiss(notice.id)} aria-label="关闭通知" title="关闭"><X size={15} /></button></div>)}</div>;
}
function Login({ onDone, notice }: { onDone: (admin: Admin) => void; notice?: string }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true); setError("");
try { const result = await api<{ admin: Admin }>("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); onDone(result.admin); }
catch (error) { setError((error as Error).message); }
finally { setBusy(false); }
};
return <div className="auth-shell"><div className="auth-panel"><div className="brand-mark"><CircleDollarSign size={28} /><span>TallyNote</span></div><p className="muted">账目与凭证工作台</p>{notice && <div className="info" role="status"><AlertCircle size={16} />{notice}</div>}<form onSubmit={submit} className="stack"><label>用户名<input value={username} onChange={(event) => setUsername(event.target.value)} autoFocus autoComplete="username" required /></label><label>密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required /></label>{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}<Button kind="primary" disabled={busy} type="submit">{busy ? <Loader2 className="spin" size={16} /> : "登录"}</Button></form></div></div>;
}
function ChangePassword({ admin, onDone }: { admin: Admin; onDone: (admin: Admin) => void }) {
const [currentPassword, setCurrent] = useState("");
const [newPassword, setNew] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (newPassword !== confirm) { setError("两次输入的新密码不一致"); return; }
setBusy(true); setError("");
try { const result = await api<{ admin: Admin }>("/api/auth/change-password", { method: "POST", body: JSON.stringify({ currentPassword, newPassword }) }); onDone(result.admin); }
catch (error) { setError((error as Error).message); }
finally { setBusy(false); }
};
return <div className="auth-shell"><div className="auth-panel"><div className="brand-mark"><ShieldCheck size={28} /><span>首次登录保护</span></div><p className="muted">管理员 <strong>{admin.displayName}</strong> 需要设置新密码(至少 12 位)。</p><form onSubmit={submit} className="stack"><label>当前密码<input type="password" value={currentPassword} onChange={(event) => setCurrent(event.target.value)} autoComplete="current-password" required /></label><label>新密码<input type="password" minLength={12} value={newPassword} onChange={(event) => setNew(event.target.value)} autoComplete="new-password" required /></label><label>确认新密码<input type="password" value={confirm} onChange={(event) => setConfirm(event.target.value)} autoComplete="new-password" required /></label>{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}<Button kind="primary" disabled={busy} type="submit">{busy ? <Loader2 className="spin" size={16} /> : "更新密码"}</Button></form></div></div>;
}
function Modal({ title, onClose, children, footer, initialFocus = "close" }: { title: string; onClose: () => void; children: React.ReactNode; footer?: React.ReactNode; initialFocus?: "close" | "content" }) {
useOverlayScrollLock();
const titleId = useId();
const firstRef = useRef<HTMLButtonElement>(null);
const modalRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
if (initialFocus === "close") firstRef.current?.focus(); else modalRef.current?.querySelector<HTMLElement>("input, textarea, select, [role=button]")?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onCloseRef.current(); return; }
if (event.key !== "Tab" || !modalRef.current) return;
const focusable = Array.from(modalRef.current.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\")]"));
if (!focusable.length) return;
const first = focusable[0]!; const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown, true);
return () => { document.removeEventListener("keydown", onKeyDown, true); previous?.focus(); };
}, [initialFocus]);
return createPortal(<div className="modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><section ref={modalRef} className="modal" role="dialog" aria-modal="true" aria-labelledby={titleId}><div className="modal-head"><h2 id={titleId}>{title}</h2><button ref={firstRef} className="icon-btn" onClick={onClose} aria-label="关闭" title="关闭"><X size={18} /></button></div><div className="modal-body">{children}</div>{footer && <div className="modal-footer">{footer}</div>}</section></div>, document.body);
}
function ConfirmDialog({ title, message, confirmLabel = "确认", danger = false, busy = false, onClose, onConfirm }: { title: string; message: React.ReactNode; confirmLabel?: string; danger?: boolean; busy?: boolean; onClose: () => void; onConfirm: () => void }) {
return <Modal title={title} onClose={busy ? () => undefined : onClose} footer={<><Button onClick={onClose} disabled={busy}>取消</Button><Button kind={danger ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? <Loader2 className="spin" size={16} /> : confirmLabel}</Button></>}><p className="modal-message">{message}</p></Modal>;
}
function AttachmentDeleteDialog({ attachment, requiresReason, reason, error, busy, onReasonChange, onClose, onConfirm }: {
attachment: Attachment;
requiresReason: boolean;
reason: string;
error?: string;
busy: boolean;
onReasonChange: (value: string) => void;
onClose: () => void;
onConfirm: (reason: string) => void;
}) {
const reasonId = useId();
const message = attachment.kind === "payment_proof"
? "将删除这张付款凭证。账目至少需要保留一张付款凭证。"
: requiresReason
? "这是最后一张发票。删除后必须保留无发票原因,原因会与删除操作一起保存。"
: "将删除这张发票。当前已填写的无发票原因会按原样保留(如有)。";
return <Modal title="删除附件?" initialFocus={requiresReason ? "content" : "close"} onClose={busy ? () => undefined : onClose} footer={<><Button onClick={onClose} disabled={busy}>取消</Button><Button kind="danger" onClick={() => onConfirm(reason)} disabled={busy || (requiresReason && !reason.trim())}>{busy ? <Loader2 className="spin" size={16} /> : "删除附件"}</Button></>}>
<p className="modal-message">{message}</p>
{requiresReason && <label className="invoice-reason delete-invoice-reason" htmlFor={reasonId}>无发票原因 <span aria-hidden="true" className="required">*</span><textarea id={reasonId} aria-label="无发票原因" rows={3} maxLength={500} value={reason} onChange={(event) => onReasonChange(event.target.value)} placeholder="例如:商家无法开具发票" required aria-required="true" aria-describedby={`${reasonId}-hint`} /><span id={`${reasonId}-hint`} className="field-hint">删除最后一张发票后,这个原因会显示在账目和导出清单中。</span></label>}
{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}
</Modal>;
}
function SecretDialog({ password, title, onClose }: { password: string; title: string; onClose: () => void }) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const copy = async () => {
if (!navigator.clipboard?.writeText) { setCopyState("failed"); return; }
try { await navigator.clipboard.writeText(password); setCopyState("copied"); }
catch { setCopyState("failed"); }
};
return <Modal title={title} onClose={onClose} footer={<Button kind="primary" onClick={onClose}>完成</Button>}><div className="secret-box"><code>{password}</code><button className="icon-btn" onClick={copy} aria-label="复制临时密码" title="复制临时密码"><Copy size={16} /></button></div>{copyState === "copied" && <div className="copy-feedback copy-success" role="status">临时密码已复制</div>}{copyState === "failed" && <div className="copy-feedback copy-failed" role="alert">复制失败,请手动选中上方密码复制</div>}<p className="field-hint">请立即安全转交。该账号首次登录时必须修改密码。</p></Modal>;
}
function PasswordDialog({ title, onClose, onConfirm, busy, error }: { title: string; onClose: () => void; onConfirm: (password: string) => void; busy: boolean; error?: string }) {
const [password, setPassword] = useState("");
return <Modal title={title} initialFocus="content" onClose={busy ? () => undefined : onClose} footer={<><Button onClick={onClose} disabled={busy}>取消</Button><Button kind="danger" onClick={() => onConfirm(password)} disabled={busy || !password}>{busy ? <Loader2 className="spin" size={16} /> : "永久删除"}</Button></>}><p className="modal-message">业务记录和附件字节将永久删除,但完整审计历史仍会保留。请输入当前登录密码确认。</p>{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}<label>当前密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" /></label></Modal>;
}
function Drawer({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
useOverlayScrollLock();
const titleId = useId();
const drawerRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
drawerRef.current?.querySelector<HTMLElement>("input, textarea, button")?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if ((event.target as Element | null)?.closest(".modal")) return;
if (event.key === "Escape") { event.preventDefault(); onCloseRef.current(); return; }
if (event.key !== "Tab" || !drawerRef.current) return;
const focusable = Array.from(drawerRef.current.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\")]"));
if (!focusable.length) return;
const first = focusable[0]!; const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown, true);
return () => { document.removeEventListener("keydown", onKeyDown, true); previous?.focus(); };
}, []);
return createPortal(<div className="drawer-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><aside ref={drawerRef} className="drawer" role="dialog" aria-modal="true" aria-labelledby={titleId}><div className="drawer-head"><h2 id={titleId}>{title}</h2><button className="icon-btn" onClick={onClose} aria-label="关闭" title="关闭"><X size={18} /></button></div>{children}</aside></div>, document.body);
}
function FilePick({ label, kind, files, setFiles, required, maxFiles = 20 }: { label: string; kind: "payment_proof" | "invoice"; files: File[]; setFiles: (files: File[]) => void; required?: boolean; maxFiles?: number }) {
const id = useId();
const accept = kind === "payment_proof" ? "image/jpeg,image/png,image/webp" : ".pdf,.ofd,.xml,application/pdf,application/ofd,application/xml";
const choose = (event: React.ChangeEvent<HTMLInputElement>) => {
const incoming = Array.from(event.target.files || []);
const merged = [...files, ...incoming].filter((file, index, all) => all.findIndex((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified) === index);
setFiles(merged.slice(0, maxFiles));
event.target.value = "";
};
return <div className="file-pick"><span className="field-label">{label}{required && <span className="required"> *</span>}</span><div className="file-input"><Upload size={16} />选择文件<input id={id} type="file" aria-label={label} aria-required={required || undefined} accept={accept} multiple disabled={maxFiles <= 0} onChange={choose} /></div>{files.length > 0 && <ul className="file-list">{files.map((file) => <li key={`${file.name}-${file.lastModified}-${file.size}`}><span title={file.name}>{file.name} <small>{formatBytes(file.size)}</small></span><button type="button" className="icon-btn compact" onClick={() => setFiles(files.filter((item) => item !== file))} aria-label={`移除文件 ${file.name}`} title="移除"><X size={14} /></button></li>)}</ul>}<span className="field-hint">单次保存最多 20 个附件,单文件大小由服务器限制。</span></div>;
}
function ExpenseDrawer({ expense, onClose, onSaved, notify }: { expense?: Expense | null; onClose: () => void; onSaved: () => void; notify: (message: string, kind?: Notice["kind"]) => void }) {
const initialAmount = useRef(expense ? (expense.amountCents / 100).toFixed(2) : "");
const initialNote = useRef(expense?.note || "");
const initialInvoiceMissingReason = useRef(expense?.invoiceMissingReason || "");
const [amount, setAmount] = useState(initialAmount.current);
const [note, setNote] = useState(initialNote.current);
const initialPaidAt = useRef(expense ? dateInputValue(new Date(expense.paidAt)) : dateInputValue(new Date()));
const [paidAt, setPaidAt] = useState(initialPaidAt.current);
const [proofs, setProofs] = useState<File[]>([]);
const [invoices, setInvoices] = useState<File[]>([]);
const [invoiceMissing, setInvoiceMissing] = useState(Boolean(initialInvoiceMissingReason.current));
const [invoiceMissingReason, setInvoiceMissingReason] = useState(initialInvoiceMissingReason.current);
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 [discardPrompt, setDiscardPrompt] = useState(false);
const invoiceReasonId = useId();
const hasInvoice = Boolean(serverInvoiceCount + invoices.length);
const hasExistingInvoice = serverInvoiceCount > 0;
const setSelectedInvoices = (files: File[]) => {
setInvoices(files);
if (files.length > 0) {
setInvoiceMissing(false);
setInvoiceMissingReason("");
}
};
const dirty = Boolean(proofs.length || invoices.length || amount !== initialAmount.current || note !== initialNote.current || paidAt !== initialPaidAt.current || invoiceMissing !== Boolean(initialInvoiceMissingReason.current) || invoiceMissingReason !== initialInvoiceMissingReason.current);
const requestClose = () => { if (busy) return; if (dirty) setDiscardPrompt(true); else onClose(); };
const applyServer = (server: Expense, keepDraft: boolean) => {
setVersion(server.version);
setServerInvoiceCount(server.invoiceCount);
if (server.invoiceCount > 0) {
// A server-side invoice always wins the mutually exclusive policy,
// including when the user chose to keep an unsaved draft after a conflict.
setInvoiceMissing(false);
setInvoiceMissingReason("");
}
if (!keepDraft) {
const serverPaidAt = dateInputValue(new Date(server.paidAt));
const serverAmount = (server.amountCents / 100).toFixed(2);
const serverInvoiceMissingReason = server.invoiceMissingReason || "";
setPaidAt(serverPaidAt); setAmount(serverAmount); setNote(server.note);
if (server.invoiceCount === 0) {
setInvoiceMissing(Boolean(serverInvoiceMissingReason));
setInvoiceMissingReason(serverInvoiceMissingReason);
}
initialPaidAt.current = serverPaidAt; initialAmount.current = serverAmount; initialNote.current = server.note; initialInvoiceMissingReason.current = serverInvoiceMissingReason;
setProofs([]); setInvoices([]);
}
setConflict(null); setError(keepDraft ? "当前内容已保留,请再次保存以覆盖服务器版本。" : "");
};
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!expense && proofs.length === 0) { setError("至少选择一张付款凭证"); return; }
if (!/^(?:0|[1-9][0-9]*)(?:[.][0-9]{1,2})?$/.test(amount) || Number(amount) <= 0 || !Number.isFinite(Number(amount))) { setError("金额必须为大于零且最多两位小数"); return; }
const normalizedInvoiceMissingReason = invoiceMissing ? invoiceMissingReason.trim() : "";
const hasInvoiceAfterSave = Boolean(serverInvoiceCount + invoices.length);
if (!hasInvoiceAfterSave && !normalizedInvoiceMissingReason) { setError("请上传发票,或勾选“无发票”并填写原因"); return; }
setBusy(true); setError("");
try {
const normalizedPaidAt = dateFromInput(paidAt);
const proofFiles = proofs;
const invoiceFiles = invoices;
const uploadAttachments = async (kind: "payment_proof" | "invoice", files: File[], startingVersion: number) => {
if (!files.length || !expense) return startingVersion;
const form = new FormData();
form.append("version", String(startingVersion));
files.forEach((file) => form.append(kind === "payment_proof" ? "paymentProofs" : "invoices", file));
const result = await api<{ expense: Expense }>(`/api/expenses/${expense.id}/attachments?kind=${kind}`, { method: "POST", body: form });
setVersion(result.expense.version);
setServerInvoiceCount(result.expense.invoiceCount);
if (result.expense.invoiceCount > 0) {
setInvoiceMissing(false);
setInvoiceMissingReason("");
} else {
setInvoiceMissing(Boolean(result.expense.invoiceMissingReason));
setInvoiceMissingReason(result.expense.invoiceMissingReason || "");
}
return result.expense.version;
};
if (expense) {
let currentVersion = version;
const submittedInvoiceReason = hasInvoiceAfterSave ? null : (normalizedInvoiceMissingReason || null);
if (invoiceFiles.length && !hasExistingInvoice) { currentVersion = await uploadAttachments("invoice", invoiceFiles, currentVersion); setInvoices([]); }
const updated = await api<{ expense: Expense }>(`/api/expenses/${expense.id}`, { method: "PATCH", body: JSON.stringify({ paidAt: normalizedPaidAt, amount, note, invoiceMissingReason: submittedInvoiceReason, version: currentVersion }) });
currentVersion = updated.expense.version;
setVersion(currentVersion);
setServerInvoiceCount(updated.expense.invoiceCount);
if (invoiceFiles.length && hasExistingInvoice) { currentVersion = await uploadAttachments("invoice", invoiceFiles, currentVersion); setInvoices([]); }
if (proofFiles.length) { currentVersion = await uploadAttachments("payment_proof", proofFiles, currentVersion); setProofs([]); }
} else {
const form = new FormData(); form.append("paidAt", normalizedPaidAt); form.append("amount", amount); form.append("note", note); form.append("invoiceMissingReason", normalizedInvoiceMissingReason); proofFiles.forEach((file) => form.append("paymentProofs", file)); invoiceFiles.forEach((file) => form.append("invoices", file)); await api("/api/expenses", { method: "POST", body: form });
}
notify(expense ? "账目已更新" : "账目已保存", "success"); onSaved(); onClose();
} catch (caught) {
const errorValue = caught as ApiError;
if (errorValue instanceof ApiError && errorValue.status === 409 && errorValue.details?.current) setConflict(errorValue.details.current as Expense);
else setError((caught as Error).message);
} finally { setBusy(false); }
};
return <>
<Drawer title={expense ? "编辑账目" : "新增账目"} onClose={requestClose}><form noValidate className="stack drawer-form" onSubmit={submit}>
<label>支付时间<input type="datetime-local" value={paidAt} onChange={(event) => setPaidAt(event.target.value)} step={60} required /><span className="field-hint">选择日期和时间(精确到分钟,按应用时区保存)</span></label>
<label>金额(元)<input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0.00" pattern="(?:0|[1-9][0-9]*)(?:[.][0-9]{1,2})?" title="请输入大于零且最多两位小数的金额" required /></label>
<label>备注<textarea rows={4} maxLength={2000} value={note} onChange={(event) => setNote(event.target.value)} placeholder="可选" /></label>
<FilePick label="付款凭证(至少 1 张)" kind="payment_proof" files={proofs} setFiles={setProofs} required={!expense} maxFiles={Math.max(0, 20 - invoices.length)} />
<div className="invoice-policy"><FilePick label="发票(可选)" kind="invoice" files={invoices} setFiles={setSelectedInvoices} maxFiles={Math.max(0, 20 - proofs.length)} /><label className="check invoice-missing-toggle"><input type="checkbox" checked={invoiceMissing} disabled={hasInvoice} onChange={(event) => { setInvoiceMissing(event.target.checked); if (!event.target.checked) setInvoiceMissingReason(""); }} />无发票</label>{hasInvoice && <span className="field-hint invoice-policy-hint">已上传发票,无需填写无发票原因。</span>}{invoiceMissing && !hasInvoice && <label className="invoice-reason" htmlFor={invoiceReasonId}>无发票原因 <span aria-hidden="true" className="required">*</span><textarea id={invoiceReasonId} aria-label="无发票原因" rows={3} maxLength={500} value={invoiceMissingReason} onChange={(event) => setInvoiceMissingReason(event.target.value)} placeholder="例如:商家无法开具发票" required aria-required="true" /></label>}{!hasExistingInvoice && !invoices.length && !invoiceMissing && <span className="field-hint invoice-policy-hint">请上传发票,或勾选“无发票”并填写原因。</span>}</div>
{error && <div className="error" role="alert"><AlertCircle size={16} />{error}</div>}
<div className="drawer-actions"><Button type="button" onClick={requestClose}>取消</Button><Button kind="primary" disabled={busy} type="submit">{busy ? <Loader2 className="spin" size={16} /> : <><Check size={16} />保存</>}</Button></div>
</form></Drawer>
{discardPrompt && <ConfirmDialog title="放弃未保存内容?" message="当前表单有未保存的修改,关闭后这些内容会丢失。" confirmLabel="放弃并关闭" danger onClose={() => setDiscardPrompt(false)} onConfirm={onClose} />}
{conflict && <Modal title="记录已被更新" onClose={() => setConflict(null)} footer={<><Button onClick={() => applyServer(conflict, false)}>加载服务器版本</Button><Button kind="primary" onClick={() => applyServer(conflict, true)}>保留当前内容</Button></>}><p className="modal-message">另一位管理员刚刚修改了这笔账目。请选择如何处理,系统不会静默覆盖。</p><div className="conflict-summary"><span>服务器金额</span><strong>{money(conflict.amountCents)}</strong><span>服务器版本</span><strong>v{conflict.version}</strong></div></Modal>}
</>;
}
function AttachmentPreview({ attachment, onClose }: { attachment: Attachment; onClose: () => void }) {
const source = `/api/attachments/${attachment.id}/content`;
return <Modal title={attachment.originalName} onClose={onClose} footer={<><a className="btn" href={`${source}?download=1`}>下载附件</a><Button onClick={onClose}>关闭</Button></>}><div className="preview-frame">{attachment.mimeType.startsWith("image/") ? <img src={source} alt={attachment.originalName} /> : <iframe src={source} title={attachment.originalName} />}</div></Modal>;
}
function ExpenseDetail({ expense, onClose, onUpdated, onRequestEdit, notify }: { expense: Expense; onClose: () => void; onUpdated: () => void; onRequestEdit: (expense: Expense) => void; notify: (message: string, kind?: Notice["kind"]) => void }) {
const [detail, setDetail] = useState<Expense>(expense);
const [timeline, setTimeline] = useState<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmAction, setConfirmAction] = useState<"status" | "trash" | null>(null);
const [removeTarget, setRemoveTarget] = useState<Attachment | null>(null);
const [removeReason, setRemoveReason] = useState("");
const [removeError, setRemoveError] = useState("");
const [preview, setPreview] = useState<Attachment | null>(null);
const loadDetail = useCallback(async () => {
setLoading(true); setError("");
try { const result = await api<{ expense: Expense; timeline: TimelineEvent[] }>(`/api/expenses/${expense.id}`); setDetail(result.expense); setTimeline(result.timeline); }
catch (caught) { setError((caught as Error).message); }
finally { setLoading(false); }
}, [expense.id]);
useEffect(() => { void loadDetail(); }, [loadDetail]);
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); await loadDetail(); setConfirmAction(null); notify(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); }
catch (caught) { setError((caught as Error).message); setConfirmAction(null); }
finally { setBusy(false); }
};
const trash = async () => {
setBusy(true);
try { await api(`/api/expenses/${detail.id}`, { method: "DELETE", body: JSON.stringify({ version: detail.version }) }); setConfirmAction(null); notify("账目已移入回收站", "success"); onClose(); onUpdated(); }
catch (caught) { setError((caught as Error).message); setConfirmAction(null); }
finally { setBusy(false); }
};
const openRemoveDialog = (attachment: Attachment) => {
setRemoveError("");
setRemoveReason("");
setRemoveTarget(attachment);
};
const removeAttachment = async (reason: string) => {
if (!removeTarget) return;
const requiresReason = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim();
const normalizedReason = reason.trim();
if (requiresReason && !normalizedReason) {
setRemoveError("请填写无发票原因");
return;
}
setBusy(true);
try {
const payload: { version: number; invoiceMissingReason?: string } = { version: detail.version };
if (requiresReason) payload.invoiceMissingReason = normalizedReason;
const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(payload) });
setDetail(result.expense);
setRemoveTarget(null);
setRemoveReason("");
setRemoveError("");
notify("附件已删除", "success");
await loadDetail();
onUpdated();
}
catch (caught) { setRemoveError((caught as Error).message); }
finally { setBusy(false); }
};
return <>
<Drawer title="账目详情" onClose={onClose}><div className="detail">
{loading ? <div className="inline-loading"><Loader2 className="spin" size={18} />加载详情…</div> : error ? <div className="detail-error"><div className="error" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={loadDetail}>重试</button></div><Button onClick={onClose}>关闭</Button></div> : <>
<div className="detail-top"><div className="detail-amount">{money(detail.amountCents)}</div><Button onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
<div className="detail-row"><span>支付时间</span><strong>{dateText(detail.paidAt)}</strong></div><div className="detail-row"><span>发票</span>{detail.invoiceCount > 0 ? <strong>{detail.invoiceCount} 张</strong> : detail.invoiceMissingReason ? <span><span className="missing">无发票</span><span className="note-text">:{detail.invoiceMissingReason}</span></span> : <span className="missing">未说明</span>}</div><div className="detail-row"><span>状态</span><Status status={detail.status} /></div><div className="detail-row"><span>备注</span><span className="note-text">{detail.note || "无"}</span></div>
<div className="section-title">附件 <span className="muted">{(detail.attachments || []).length}</span></div><div className="attachments">{(detail.attachments || []).map((attachment) => <div className="attachment" key={attachment.id}><span className="attachment-name"><span className="attachment-icon">{attachment.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />}</span><span title={attachment.originalName}>{attachment.originalName}</span><small>{formatBytes(attachment.sizeBytes)}</small></span><span className="attachment-actions">{attachment.previewable && <button className="icon-btn compact" onClick={() => setPreview(attachment)} aria-label={`预览 ${attachment.originalName}`} title="预览"><Search size={15} /></button>}<a className="icon-btn compact" href={`/api/attachments/${attachment.id}/content?download=1`} aria-label={`下载 ${attachment.originalName}`} title="下载"><ArrowDownToLine size={15} /></a><button className="icon-btn compact danger-icon" onClick={() => openRemoveDialog(attachment)} disabled={busy} aria-label={`删除 ${attachment.originalName}`} title="删除附件"><Trash2 size={14} /></button></span></div>)}</div>
{timeline.length > 0 && <><div className="section-title">操作记录</div><div className="timeline">{timeline.slice(0, 12).map((item) => <div className="timeline-item" key={item.id}><span>{dateText(item.occurredAt)}</span><strong>{item.action}</strong><small>{item.actorUsername || "系统"}</small></div>)}</div></>}
<div className="drawer-actions"><Button onClick={() => setConfirmAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button kind="danger" onClick={() => setConfirmAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></div>
</>}
</div></Drawer>
{confirmAction === "status" && <ConfirmDialog title={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} message={detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"} confirmLabel="确认变更" busy={busy} onClose={() => setConfirmAction(null)} onConfirm={updateStatus} />}
{confirmAction === "trash" && <ConfirmDialog title="移入回收站?" message="账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。" confirmLabel="移入回收站" danger busy={busy} onClose={() => setConfirmAction(null)} onConfirm={trash} />}
{removeTarget && <AttachmentDeleteDialog attachment={removeTarget} requiresReason={removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim()} reason={removeReason} error={removeError} busy={busy} onReasonChange={setRemoveReason} onClose={() => { if (!busy) { setRemoveTarget(null); setRemoveError(""); setRemoveReason(""); } }} onConfirm={removeAttachment} />}
{preview && <AttachmentPreview attachment={preview} onClose={() => setPreview(null)} />}
</>;
}
function Status({ status }: { status: string }) { return <span className={`status status-${status}`}>{status === "reimbursed" ? "已报销" : "未报销"}</span>; }
function Expenses({ notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const [month, setMonth] = useState(monthNow());
const [status, setStatus] = useState<"unreimbursed" | "reimbursed">("unreimbursed");
const [query, setQuery] = useState("");
const [missingInvoice, setMissing] = useState(false);
const [items, setItems] = useState<Expense[]>([]);
const [summary, setSummary] = useState({ count: 0, amountCents: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null);
const [selected, setSelected] = useState<Expense | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState<string | null>(null);
const [includeManifest, setIncludeManifest] = useState(false);
const [trashTarget, setTrashTarget] = useState<Expense | null>(null);
const [trashing, setTrashing] = useState(false);
const loadSequence = useRef(0);
const load = async (queryOverride = query) => {
const sequence = ++loadSequence.current;
setLoading(true); setError("");
try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(queryOverride)}&missingInvoice=${missingInvoice}`); if (sequence === loadSequence.current) { setItems(result.items); setSummary(result.summary); setSelectedIds((current) => { const visible = new Set(result.items.map((item) => item.id)); return new Set([...current].filter((id) => visible.has(id))); }); } }
catch (caught) { if (sequence === loadSequence.current) setError((caught as Error).message); }
finally { if (sequence === loadSequence.current) setLoading(false); }
};
useEffect(() => { setSelectedIds(new Set()); void load(); }, [month, status, missingInvoice]);
const shiftMonth = (delta: number) => { const [yearText, monthText] = month.split("-"); const year = Number(yearText || new Date().getFullYear()); const currentMonth = Number(monthText || new Date().getMonth() + 1); const next = new Date(year, currentMonth - 1 + delta, 1); setMonth(`${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`); };
const selectedTotal = useMemo(() => items.filter((item) => selectedIds.has(item.id)).reduce((sum, item) => sum + item.amountCents, 0), [items, selectedIds]);
const exportAll = async () => {
try { const selection = selectedIds.size ? { ids: [...selectedIds], includeManifest } : { month, status, query, missingInvoice, includeManifest }; const result = await api<{ job: { id: string } }>("/api/exports", { method: "POST", body: JSON.stringify(selection) }); setExporting(result.job.id); notify(includeManifest ? "导出任务已创建(含 manifest.json)" : "导出任务已创建", "info"); }
catch (caught) { notify((caught as Error).message, "error"); }
};
const moveToTrash = async () => {
if (!trashTarget) return;
setTrashing(true);
try {
await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) });
setSelectedIds((current) => { const next = new Set(current); next.delete(trashTarget.id); return next; });
setTrashTarget(null);
notify("账目已移入回收站,可在回收站恢复", "success");
await load();
} catch (caught) {
notify((caught as Error).message, "error");
} finally {
setTrashing(false);
}
};
useEffect(() => {
if (!exporting) return undefined;
const timer = window.setInterval(async () => {
try { const result = await api<{ job: { status: string; errorMessage?: string } }>(`/api/exports/${exporting}`); if (result.job.status === "ready") { window.clearInterval(timer); window.location.href = `/api/exports/${exporting}/download`; setExporting(null); } else if (result.job.status === "failed" || result.job.status === "expired") { window.clearInterval(timer); notify(result.job.errorMessage || "导出失败", "error"); setExporting(null); } }
catch { window.clearInterval(timer); notify("无法查询导出状态", "error"); setExporting(null); }
}, 1000);
return () => window.clearInterval(timer);
}, [exporting, notify]);
const handleMonth = (event: React.FormEvent<HTMLInputElement>) => { const value = event.currentTarget.value; if (/^\d{4}-\d{2}$/.test(value)) setMonth(value); };
const refresh = () => { void load(); };
return <div className="page"><div className="page-head"><div><div className="eyebrow">账目台 / {month}</div><h1>账目列表</h1></div><div className="head-actions"><label className="export-option" title="额外附带附件元数据和 SHA-256 校验值"><input type="checkbox" checked={includeManifest} onChange={(event) => setIncludeManifest(event.target.checked)} />包含 manifest.json</label><Button onClick={exportAll} disabled={!!exporting || (!selectedIds.size && !items.length)}><FileDown size={16} />{exporting ? "导出中…" : selectedIds.size ? `导出所选(${selectedIds.size})` : "导出筛选结果"}</Button><Button kind="primary" onClick={() => setDrawer("new")}><Plus size={16} />新增账目</Button></div></div>
<div className="toolbar"><button className="icon-btn" onClick={() => shiftMonth(-1)} aria-label="上个月" title="上个月"><ChevronLeft size={18} /></button><input className="month-input" type="month" value={month} onChange={handleMonth} onInput={handleMonth} aria-label="账目月份" /><button className="icon-btn" onClick={() => shiftMonth(1)} aria-label="下个月" title="下个月"><ChevronRight size={18} /></button><div className="segmented" role="group" aria-label="报销状态"><button className={status === "unreimbursed" ? "active" : ""} onClick={() => setStatus("unreimbursed")} aria-pressed={status === "unreimbursed"}>未报销</button><button className={status === "reimbursed" ? "active" : ""} onClick={() => setStatus("reimbursed")} aria-pressed={status === "reimbursed"}>已报销</button></div><form className="search" onSubmit={(event) => { event.preventDefault(); void load(); }}><Search size={16} /><input aria-label="搜索备注" placeholder="搜索备注后按 Enter" value={query} onChange={(event) => setQuery(event.target.value)} />{query && <button type="button" className="search-clear" onClick={() => { setQuery(""); void load(""); }} aria-label="清除搜索"><X size={14} /></button>}</form><label className="check"><input type="checkbox" checked={missingInvoice} onChange={(event) => setMissing(event.target.checked)} />缺发票</label></div>
{selectedIds.size > 0 && <div className="selection-bar"><span>已选 {selectedIds.size} 笔</span><strong>{money(selectedTotal)}</strong><button className="text-button" onClick={() => setSelectedIds(new Set())}>清除选择</button></div>}
<div className="summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong></div>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>账目加载失败</p><Button onClick={() => void load()}>重试</Button></div> : items.length === 0 ? <div className="empty"><ClipboardList size={28} /><p>暂无符合条件的账目</p><Button kind="primary" onClick={() => setDrawer("new")}><Plus size={15} />新增第一笔</Button></div> : <table><thead><tr><th className="select-col"><input type="checkbox" aria-label="选择全部当前记录" checked={items.length > 0 && items.every((item) => selectedIds.has(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? new Set(items.map((item) => item.id)) : new Set())} /></th><th>支付时间</th><th>金额</th><th>备注</th><th>凭证</th><th>发票</th><th>状态</th><th>操作</th></tr></thead><tbody>{items.map((item) => { const invoiceLabel = item.invoiceMissingReason ? `无发票:${item.invoiceMissingReason}` : "缺发票"; return <tr key={item.id} tabIndex={0} aria-label={`查看 ${item.note || "账目"}`} onClick={() => { setSelected(item); setDrawer("detail"); }} onKeyDown={(event) => { if ((event.key === "Enter" || event.key === " ") && !(event.target as HTMLElement).closest("button,input,a,select,textarea")) { event.preventDefault(); setSelected(item); setDrawer("detail"); } }}><td className="select-col" onClick={(event) => event.stopPropagation()}><input type="checkbox" aria-label={`选择 ${dateText(item.paidAt)} ${money(item.amountCents)}`} checked={selectedIds.has(item.id)} onChange={() => setSelectedIds((current) => { const next = new Set(current); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next; })} /></td><td>{dateText(item.paidAt)}</td><td className="amount">{money(item.amountCents)}</td><td className="note-cell">{item.note || <span className="muted">无备注</span>}</td><td>{item.paymentProofCount}</td><td>{item.invoiceCount === 0 ? <span className="missing missing-label" title={invoiceLabel} aria-label={invoiceLabel}>{invoiceLabel}</span> : item.invoiceCount}</td><td><Status status={item.status} /></td><td><div className="row-actions"><button className="row-action" onClick={(event) => { event.stopPropagation(); setSelected(item); setDrawer("detail"); }} aria-label={`查看 ${item.note || "账目"}`} title="查看详情"><FileText size={15} /></button><button className="row-action" onClick={(event) => { event.stopPropagation(); setSelected(item); setDrawer("edit"); }} aria-label={`编辑 ${item.note || "账目"}`} title="编辑"><Settings size={15} /></button><button className="row-action row-action-danger" onClick={(event) => { event.stopPropagation(); setTrashTarget(item); }} aria-label={`将 ${item.note || "账目"} 移入回收站`} title="移入回收站"><Trash2 size={15} /></button></div></td></tr>; })}</tbody></table>}</div>
{drawer === "new" && <ExpenseDrawer onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail expense={selected} onClose={() => setDrawer(null)} onUpdated={refresh} onRequestEdit={(expense) => { setSelected(expense); setDrawer("edit"); }} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}
{trashTarget && <ConfirmDialog title="移入回收站?" message={<>将“{trashTarget.note || `${dateText(trashTarget.paidAt)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</>} confirmLabel="移入回收站" danger busy={trashing} onClose={() => setTrashTarget(null)} onConfirm={moveToTrash} />}
</div>;
}
function Trash({ notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
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 [purgeError, setPurgeError] = useState("");
const load = async () => { setLoading(true); setError(""); try { setItems((await api<{ items: Expense[] }>("/api/trash")).items); } catch (caught) { setError((caught as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
const restore = async (item: Expense) => { setBusy(true); try { await api(`/api/trash/${item.id}/restore`, { method: "POST", body: JSON.stringify({ version: item.version }) }); notify("账目已恢复", "success"); await load(); } catch (caught) { setError((caught as Error).message); } finally { setBusy(false); } };
const purge = async (password: string) => { if (!purgeTarget) return; setBusy(true); setPurgeError(""); try { await api(`/api/trash/${purgeTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: purgeTarget.version, password }) }); setPurgeTarget(null); notify("账目已永久删除,审计历史仍保留", "success"); await load(); } catch (caught) { setPurgeError((caught as Error).message); } finally { setBusy(false); } };
return <div className="page"><div className="page-head"><div><div className="eyebrow">管理</div><h1>回收站</h1></div><Button onClick={load} disabled={loading}><RotateCcw size={15} />刷新</Button></div>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={load}>重试</button></div>}<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>回收站加载失败</p><Button onClick={load}>重试</Button></div> : items.length === 0 ? <div className="empty"><Trash2 size={28} /><p>回收站为空</p></div> : <table><thead><tr><th>删除时间</th><th>金额</th><th>备注</th><th>状态</th><th>操作</th></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td>{item.deletedAt ? dateText(item.deletedAt) : "-"}</td><td className="amount">{money(item.amountCents)}</td><td className="note-cell">{item.note || "无备注"}</td><td><Status status={item.status} /></td><td className="actions"><Button onClick={() => restore(item)} disabled={busy}><RotateCcw size={14} />恢复</Button><Button kind="danger" onClick={() => { setPurgeError(""); setPurgeTarget(item); }} disabled={busy}><Trash2 size={14} />永久删除</Button></td></tr>)}</tbody></table>}</div>{purgeTarget && <PasswordDialog title="永久删除账目" busy={busy} error={purgeError} onClose={() => { setPurgeError(""); setPurgeTarget(null); }} onConfirm={purge} />}</div>;
}
function Admins({ notify, currentAdmin }: { notify: (message: string, kind?: Notice["kind"]) => void; currentAdmin: Admin }) {
const [items, setItems] = useState<Admin[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showCreate, setShowCreate] = useState(false); const [createDiscardPrompt, setCreateDiscardPrompt] = useState(false); const [form, setForm] = useState({ username: "", displayName: "" }); const [formError, setFormError] = useState(""); const [busy, setBusy] = useState(false); const [action, setAction] = useState<{ type: "toggle" | "reset"; admin: Admin } | null>(null); const [secret, setSecret] = useState<{ title: string; value: string } | null>(null);
const load = async () => { setLoading(true); setError(""); try { setItems((await api<{ items: Admin[] }>("/api/admins")).items); } catch (caught) { setError((caught as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
const closeCreate = () => { if (busy) return; if (form.username || form.displayName) setCreateDiscardPrompt(true); else setShowCreate(false); };
const create = async (event: React.FormEvent) => { event.preventDefault(); setBusy(true); setFormError(""); try { const result = await api<{ temporaryPassword: string }>("/api/admins", { method: "POST", body: JSON.stringify(form) }); setShowCreate(false); setForm({ username: "", displayName: "" }); setSecret({ title: "管理员已创建", value: result.temporaryPassword }); notify("管理员已创建", "success"); await load(); } catch (caught) { setFormError((caught as Error).message); } finally { 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 (caught) { setError((caught 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({ title: "临时密码已生成", value: result.temporaryPassword }); notify("密码已重置,现有会话已失效", "success"); await load(); } catch (caught) { setError((caught as Error).message); setAction(null); } finally { setBusy(false); } };
return <div className="page"><div className="page-head"><div><div className="eyebrow">系统</div><h1>管理员</h1></div><div className="head-actions"><Button onClick={load} disabled={loading}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => { setFormError(""); setCreateDiscardPrompt(false); setForm({ username: "", displayName: "" }); setShowCreate(true); }}><Plus size={16} />新增管理员</Button></div></div>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={load}>重试</button></div>}<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>管理员列表加载失败</p><Button onClick={load}>重试</Button></div> : items.length === 0 ? <div className="empty"><Users size={28} /><p>暂无管理员</p></div> : <table><thead><tr><th>用户名</th><th>显示名</th><th>状态</th><th>版本</th><th>最近登录</th><th>操作</th></tr></thead><tbody>{items.map((admin) => <tr key={admin.id}><td>{admin.username}</td><td>{admin.displayName}</td><td><span className={`status ${admin.status === "active" ? "status-reimbursed" : "status-disabled"}`}>{admin.status === "active" ? "有效" : "已停用"}</span></td><td>v{admin.version}</td><td>{admin.lastLoginAt ? dateText(admin.lastLoginAt) : "从未登录"}</td><td className="actions"><Button onClick={() => setAction({ type: "reset", admin })} disabled={admin.id === currentAdmin.id} title={admin.id === currentAdmin.id ? "请使用修改密码功能" : "重置密码"}>重置密码</Button><Button onClick={() => setAction({ type: "toggle", admin })} disabled={admin.id === currentAdmin.id} title={admin.id === currentAdmin.id ? "不能停用当前登录账号" : undefined}>{admin.status === "active" ? "停用" : "启用"}</Button></td></tr>)}</tbody></table>}</div>{showCreate && <Drawer title="新增管理员" onClose={closeCreate}><form className="stack drawer-form" onSubmit={create}><label>用户名<input value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} minLength={3} maxLength={64} autoComplete="off" required /></label><label>显示名<input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} maxLength={80} required /></label>{formError && <div className="error" role="alert"><AlertCircle size={16} />{formError}</div>}<div className="drawer-actions"><Button type="button" onClick={closeCreate}>取消</Button><Button kind="primary" type="submit" disabled={busy}>{busy ? <Loader2 className="spin" size={16} /> : "创建并生成临时密码"}</Button></div></form></Drawer>}{createDiscardPrompt && <ConfirmDialog title="放弃未保存内容?" message="当前管理员表单有未保存的修改,关闭后这些内容会丢失。" confirmLabel="放弃并关闭" danger onClose={() => setCreateDiscardPrompt(false)} onConfirm={() => { setCreateDiscardPrompt(false); setShowCreate(false); setForm({ username: "", displayName: "" }); }} />}{action?.type === "toggle" && <ConfirmDialog title={action.admin.status === "active" ? "停用管理员?" : "启用管理员?"} message={action.admin.status === "active" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"} confirmLabel={action.admin.status === "active" ? "停用" : "启用"} danger={action.admin.status === "active"} busy={busy} onClose={() => setAction(null)} onConfirm={toggle} />}{action?.type === "reset" && <ConfirmDialog title="重置管理员密码?" message={<>将生成一次性临时密码,并立即使“{action.admin.displayName}”的现有会话失效。</>} confirmLabel="重置密码" danger busy={busy} onClose={() => setAction(null)} onConfirm={reset} />}{secret && <SecretDialog title={secret.title} password={secret.value} onClose={() => setSecret(null)} />}</div>;
}
const updateStatusLabels: Record<UpdateJob["status"], string> = {
queued: "等待系统服务",
downloading: "下载中",
verifying: "校验文件",
staged: "准备完成",
backing_up: "备份数据",
applying: "切换并检查服务",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
function Update({ notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [error, setError] = useState("");
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
const [reloadReady, setReloadReady] = useState(false);
const announcedJob = useRef<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
setInfo(await api<UpdateInfo>("/api/update/status"));
} catch (caught) {
setError((caught as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
const job = info?.job;
if (!job || !["queued", "downloading", "verifying", "staged", "backing_up", "applying"].includes(job.status)) return;
let disposed = false;
const poll = async () => {
try {
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
if (disposed) return;
setInfo((current) => current ? { ...current, job: result.job } : current);
if (result.job.status === "completed" && announcedJob.current !== result.job.id) {
announcedJob.current = result.job.id;
setReloadReady(true);
notify("更新完成,请重新加载页面", "success");
}
} catch (caught) {
if (!disposed) setError((caught as Error).message);
}
};
void poll();
const timer = window.setInterval(() => { void poll(); }, 1200);
return () => { disposed = true; window.clearInterval(timer); };
}, [info?.job?.id, info?.job?.status, notify]);
const check = async () => {
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) {
setError((caught as Error).message);
} finally { setChecking(false); }
};
const apply = async () => {
if (!confirmVersion) return;
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 (caught) {
setError((caught as Error).message);
} finally { setApplying(false); }
};
const latest = info?.latest;
const job = info?.job;
const hasActiveJob = Boolean(job && ["queued", "downloading", "verifying", "staged", "backing_up", "applying"].includes(job.status));
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && (!job || job.status === "failed" || job.version !== latest.version));
return <div className="page update-page">
<div className="page-head"><div><div className="eyebrow">系统</div><h1>系统更新</h1></div><div className="head-actions"><Button onClick={() => void load()} disabled={loading || checking}><RotateCcw size={15} />刷新</Button><Button kind="primary" onClick={() => void check()} disabled={loading || checking}><RefreshCw size={15} className={checking ? "spin" : undefined} />检查更新</Button></div></div>
{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}
{loading ? <div className="update-loading"><Loader2 className="spin" size={22} />正在读取版本信息</div> : info && <>
<div className="update-overview">
<section className="update-card"><div className="update-card-icon"><Server size={20} /></div><div><span className="update-label">当前版本</span><strong className="update-version">v{info.currentVersion}</strong><span className="field-hint">运行平台:{info.platform.target}</span></div></section>
<section className="update-card"><div className="update-card-icon"><ShieldCheck size={20} /></div><div><span className="update-label">更新方式</span><strong>{info.strategy === "systemd" ? "systemd 一键更新" : "命令行更新"}</strong><span className="field-hint">{info.strategy === "systemd" ? "数据目录不会被替换" : "当前安装未启用后台更新"}</span></div></section>
</div>
{latest ? <section className="update-release"><div className="update-release-head"><div><span className="update-label">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <span className="field-hint">发布时间:{dateText(Date.parse(latest.publishedAt))}</span>}</div><span className={`update-badge ${latest.isNewer ? "update-badge-new" : "update-badge-current"}`}>{latest.isNewer ? "有新版本" : "已是最新"}</span></div><div className="update-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 + 签名可验证" : latest.signatureReady ? "缺少 SHA-256" : "缺少发布签名"}</strong></div>{latest.assetSize !== undefined && <div><span>文件大小</span><strong>{formatBytes(latest.assetSize)}</strong></div>}</div>{latest.isNewer && !latest.compatible && <div className="info"><AlertCircle size={16} />当前平台没有可安装的 release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="error"><AlertCircle size={16} />发布文件必须同时提供 SHA-256 和受信任的 Ed25519 签名,当前已禁用更新。</div>}<div className="update-actions">{canApply && <Button kind="primary" onClick={() => setConfirmVersion(latest.version)} disabled={hasActiveJob}><DownloadIcon /><span>更新到 v{latest.version}</span></Button>}{reloadReady && <Button kind="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></section> : <div className="update-empty"><RefreshCw size={24} /><p>点击“检查更新”获取最新 Release。</p></div>}
{job && <section className="update-job"><div className="update-job-head"><div><span className="update-label">最近任务</span><strong>v{job.version}</strong></div><span className={`update-job-status update-job-${job.status}`}>{updateStatusLabels[job.status]}</span></div>{hasActiveJob && <div className="update-progress" aria-label={updateStatusLabels[job.status]}><span style={{ width: `${job.status === "queued" ? 8 : job.status === "downloading" ? 28 : job.status === "verifying" ? 48 : job.status === "staged" ? 65 : job.status === "backing_up" ? 80 : 92}%` }} /></div>}{job.status === "queued" && <p className="field-hint">等待 root 权限的 systemd 更新服务接管,页面会自动刷新状态。</p>}{job.status === "failed" && job.errorMessage && <div className="error"><AlertCircle size={16} />{job.errorMessage}</div>}{job.status === "completed" && <div className="info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</section>}
</>}
{confirmVersion && <ConfirmDialog title="确认更新系统?" message={<>将更新到 <strong>v{confirmVersion}</strong>。服务会短暂停止并重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</>} confirmLabel="开始更新" busy={applying} onClose={() => setConfirmVersion(null)} onConfirm={() => void apply()} />}
</div>;
}
function DownloadIcon() { return <ArrowDownToLine size={16} />; }
function Audit({ notify: _notify }: { notify: (message: string, kind?: Notice["kind"]) => void }) {
const pageSize = 100;
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [action, setAction] = useState("");
const [targetType, setTargetType] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const load = async (append = false) => {
if (append) setLoadingMore(true); else setLoading(true);
setError("");
try {
const nextOffset = append ? offset : 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: any[] }>(`/api/audit?${params}`);
setItems((current) => append ? [...current, ...result.items] : result.items);
setOffset(nextOffset + result.items.length);
setHasMore(result.items.length === pageSize);
} catch (caught) {
setError((caught as Error).message);
} finally {
if (append) setLoadingMore(false); else setLoading(false);
}
};
useEffect(() => { void load(); }, []);
return <div className="page"><div className="page-head"><div><div className="eyebrow">系统</div><h1>审计日志</h1></div><Button onClick={() => void load()} disabled={loading}><RotateCcw size={15} />刷新</Button></div><form className="audit-filters" onSubmit={(event) => { event.preventDefault(); void load(); }}><label>动作<input value={action} onChange={(event) => setAction(event.target.value)} placeholder="例如 expense.created" /></label><label>目标<select value={targetType} onChange={(event) => setTargetType(event.target.value)}><option value="">全部目标</option><option value="expense">账目</option><option value="admin">管理员</option><option value="export">导出</option><option value="session">会话</option></select></label><Button kind="primary" type="submit"><Search size={15} />筛选</Button></form>{error && <div className="error banner" role="alert"><AlertCircle size={16} />{error}<button className="text-button" onClick={() => void load()}>重试</button></div>}<div className="table-wrap">{loading ? <div className="empty"><Loader2 className="spin" /><span>加载中…</span></div> : error && items.length === 0 ? <div className="empty"><AlertCircle size={28} /><p>审计日志加载失败</p><Button onClick={() => void load()}>重试</Button></div> : items.length === 0 ? <div className="empty"><Archive size={28} /><p>暂无审计记录</p></div> : <><table><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>目标</th><th>结果</th></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td>{dateText(item.occurredAt)}</td><td>{item.actorUsername || "系统"}</td><td><code>{item.action}</code></td><td>{item.targetType}{item.targetId ? ` / ${item.targetId.slice(0, 8)}` : ""}</td><td><span className={`outcome outcome-${item.outcome || "success"}`}>{item.outcome || "success"}</span></td></tr>)}</tbody></table>{hasMore && <div className="table-more"><Button onClick={() => void load(true)} disabled={loadingMore}>{loadingMore ? <Loader2 className="spin" size={15} /> : <RotateCcw size={15} />}加载更早记录</Button></div>}</>}</div></div>;
}
function App() {
const [admin, setAdmin] = useState<Admin | null>(null); const [boot, setBoot] = useState(true); const [page, setPage] = useState("expenses"); const [mobileNav, setMobileNav] = useState(false); const [notices, setNotices] = useState<Notice[]>([]); const [authExpiredNotice, setAuthExpiredNotice] = useState(""); const noticeId = useRef(0); const menuButtonRef = useRef<HTMLButtonElement>(null); const navRef = useRef<HTMLElement>(null); const wasMobileNavOpen = useRef(false);
const notify = useCallback((message: string, kind: Notice["kind"] = "info") => { const id = ++noticeId.current; setNotices((current) => [...current, { id, message, kind }]); window.setTimeout(() => setNotices((current) => current.filter((notice) => notice.id !== id)), 5000); }, []);
useEffect(() => {
const onExpired = (event: Event) => { setAdmin(null); setPage("expenses"); setMobileNav(false); setAuthExpiredNotice((event as CustomEvent<string>).detail || "登录已失效,请重新登录"); };
window.addEventListener("tallynote-auth-expired", onExpired);
return () => window.removeEventListener("tallynote-auth-expired", onExpired);
}, []);
useEffect(() => {
api<{ timezone?: string }>("/api/auth/status").then((result) => { if (result.timezone) appTimezone = result.timezone; }).catch(() => undefined).finally(() => {
api<{ admin: Admin }>("/api/auth/session").then((result) => setAdmin(result.admin)).catch(() => undefined).finally(() => setBoot(false));
});
}, []);
useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const media = window.matchMedia("(max-width: 900px)");
const syncAccessibility = () => {
const isMobile = media.matches;
if (isMobile && !mobileNav) {
nav.setAttribute("aria-hidden", "true");
nav.setAttribute("inert", "");
} else {
nav.removeAttribute("aria-hidden");
nav.removeAttribute("inert");
}
if (isMobile && mobileNav) {
nav.querySelector<HTMLButtonElement>(".nav-item")?.focus();
} else if (isMobile && wasMobileNavOpen.current) {
menuButtonRef.current?.focus();
}
wasMobileNavOpen.current = mobileNav;
};
syncAccessibility();
media.addEventListener?.("change", syncAccessibility);
return () => media.removeEventListener?.("change", syncAccessibility);
}, [admin, mobileNav]);
useEffect(() => {
if (!mobileNav) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setMobileNav(false);
return;
}
if (event.key !== "Tab" || !navRef.current || !window.matchMedia("(max-width: 900px)").matches) return;
const focusable = Array.from(navRef.current.querySelectorAll<HTMLButtonElement>("button:not([disabled])"));
if (!focusable.length) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [mobileNav]);
if (boot) return <div className="loading-screen" role="status" aria-live="polite"><Loader2 className="spin" /><span>正在加载 TallyNote…</span></div>;
if (!admin) return <Login notice={authExpiredNotice} onDone={(next) => { setAuthExpiredNotice(""); setAdmin(next); }} />;
if (admin.mustChangePassword) return <ChangePassword admin={admin} onDone={setAdmin} />;
const nav = [{ id: "expenses", label: "账目", icon: ClipboardList }, { id: "trash", label: "回收站", icon: Trash2 }, { id: "admins", label: "管理员", icon: Users }, { id: "audit", label: "审计日志", icon: Archive }, { id: "update", label: "系统更新", icon: RefreshCw }];
const logout = async () => { await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); setAdmin(null); };
return <div className="app-shell"><NoticeRegion notices={notices} dismiss={(id) => setNotices((current) => current.filter((notice) => notice.id !== id))} /><header className="topbar"><button ref={menuButtonRef} className="icon-btn mobile-only" onClick={() => setMobileNav((open) => !open)} aria-label={mobileNav ? "关闭导航" : "打开导航"} aria-expanded={mobileNav} aria-controls="main-navigation" title={mobileNav ? "关闭导航" : "打开导航"}>{mobileNav ? <X size={18} /> : <Menu size={18} />}</button><div className="brand-mark"><CircleDollarSign size={22} /><span>TallyNote</span></div><div className="topbar-right"><span className="user-chip">{admin.displayName}</span><button className="icon-btn" onClick={logout} aria-label="退出登录" title="退出登录"><LogOut size={17} /></button></div></header><div className="body-shell">{mobileNav && <button className="mobile-nav-backdrop" aria-label="关闭导航" onClick={() => setMobileNav(false)} /> }<aside ref={navRef} id="main-navigation" className={`sidebar ${mobileNav ? "open" : ""}`} aria-label="主导航">{nav.map((item) => { const Icon = item.icon; return <button key={item.id} className={`nav-item ${page === item.id ? "active" : ""}`} onClick={() => { setPage(item.id); setMobileNav(false); }} aria-current={page === item.id ? "page" : undefined}><Icon size={17} />{item.label}</button>; })}</aside><main key={page} className="page-transition">{page === "expenses" && <Expenses notify={notify} />}{page === "trash" && <Trash notify={notify} />}{page === "admins" && <Admins notify={notify} currentAdmin={admin} />}{page === "audit" && <Audit notify={notify} />}{page === "update" && <Update notify={notify} />}</main></div></div>;
}
const rootElement = document.getElementById("root") as (HTMLElement & { __tallynoteRoot?: ReturnType<typeof createRoot> }) | null;
if (!rootElement) throw new Error("缺少应用挂载节点");
// Vite can re-evaluate this module during HMR; retain the root on the DOM
// node so development reloads do not create a second React root.
const appRoot = rootElement.__tallynoteRoot ?? createRoot(rootElement);
rootElement.__tallynoteRoot = appRoot;
appRoot.render(<React.StrictMode><><TooltipLayer /><App /></></React.StrictMode>);