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(url: string, init: RequestInit = {}): Promise { 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 & { kind?: "default" | "primary" | "danger" | "ghost" }) { return ; } function TooltipLayer() { const activeRef = useRef(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("[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({tooltip.label}, 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
{notices.map((notice) =>
{notice.kind === "error" ? : }{notice.message}
)}
; } 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
TallyNote

账目与凭证工作台

{notice &&
{notice}
}
{error &&
{error}
}
; } 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
首次登录保护

管理员 {admin.displayName} 需要设置新密码(至少 12 位)。

{error &&
{error}
}
; } 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(null); const modalRef = useRef(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("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("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(
event.target === event.currentTarget && onClose()}>

{title}

{children}
{footer &&
{footer}
}
, 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 undefined : onClose} footer={<>}>

{message}

; } 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 undefined : onClose} footer={<>}>

{message}

{requiresReason &&