diff --git a/package.json b/package.json index dc7b08a..8949d3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.37", + "version": "1.1.38", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/web-next/src/main.tsx b/web-next/src/main.tsx index b32ada0..b5e5b9c 100644 --- a/web-next/src/main.tsx +++ b/web-next/src/main.tsx @@ -11,12 +11,12 @@ import { setAppTimezone } from "./utils/date"; import { AppLayout } from "./layouts"; import { DEFAULT_ROUTE_ID, isRouteId, routeIdFromPath, routePath, routeTitle, type RouteId } from "./router"; import { LoginPage, ChangePasswordPage } from "./pages/auth"; -const ExpensesPage = lazy(() => import("./pages/expenses")); -const DashboardPage = lazy(() => import("./pages/dashboard")); -const TrashPage = lazy(() => import("./pages/trash").then(module => ({ default: module.TrashPage }))); -const AdminsPage = lazy(() => import("./pages/admins").then(module => ({ default: module.AdminsPage }))); -const AuditPage = lazy(() => import("./pages/audit").then(module => ({ default: module.AuditPage }))); -const UpdatePage = lazy(() => import("./pages/update").then(module => ({ default: module.UpdatePage }))); +import ExpensesPage from "./pages/expenses"; +import DashboardPage from "./pages/dashboard"; +import { TrashPage } from "./pages/trash"; +import { AdminsPage } from "./pages/admins"; +import { AuditPage } from "./pages/audit"; +import { UpdatePage } from "./pages/update"; import { UnsavedChangesProvider, useUnsavedActions } from "./contexts/UnsavedChanges"; import { useDialogAccessibility } from "./hooks/useDialogAccessibility"; import "./styles/theme.css"; diff --git a/web-next/src/pages/dashboard/DashboardPage.tsx b/web-next/src/pages/dashboard/DashboardPage.tsx index 4ccc4ea..dd871ed 100644 --- a/web-next/src/pages/dashboard/DashboardPage.tsx +++ b/web-next/src/pages/dashboard/DashboardPage.tsx @@ -18,6 +18,8 @@ echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, Canvas type Props = { timezone?: string; onNavigate?: (id: RouteId, search?: string) => void }; type ExpenseResult = { items: Expense[]; summary: { count: number; amountCents: number } }; +let dashboardCache: { month: string; unreimbursed: ExpenseResult; reimbursed: ExpenseResult } | null = null; + function prefersReducedMotion(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches @@ -29,11 +31,12 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate } 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({ items: [], summary: { count: 0, amountCents: 0 } }); - const [reimbursed, setReimbursed] = useState({ items: [], summary: { count: 0, amountCents: 0 } }); - const [loading, setLoading] = useState(true); + const isCached = dashboardCache?.month === month; + const [unreimbursed, setUnreimbursed] = useState(() => (isCached ? dashboardCache!.unreimbursed : { items: [], summary: { count: 0, amountCents: 0 } })); + const [reimbursed, setReimbursed] = useState(() => (isCached ? dashboardCache!.reimbursed : { items: [], summary: { count: 0, amountCents: 0 } })); + const [loading, setLoading] = useState(() => !isCached); const [error, setError] = useState(""); - const [loadedMonth, setLoadedMonth] = useState(null); + const [loadedMonth, setLoadedMonth] = useState(() => (isCached ? month : null)); const requestSequence = useRef(0); const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion); @@ -75,6 +78,7 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate } setUnreimbursed(pending); setReimbursed(done); setLoadedMonth(month); + dashboardCache = { month, unreimbursed: pending, reimbursed: done }; } catch (caught) { if (sequence === requestSequence.current) setError((caught as Error).message); } finally { @@ -141,7 +145,7 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }
{error && hasCurrentSnapshot && } message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />} - {loading ?
: !hasCurrentSnapshot ?
} message={error || "暂时无法读取仪表盘数据"} />
: <> + {loading && !hasCurrentSnapshot ?
: !hasCurrentSnapshot ?
} message={error || "暂时无法读取仪表盘数据"} />
: <>
diff --git a/web-next/src/pages/expenses/ExpensesPage.tsx b/web-next/src/pages/expenses/ExpensesPage.tsx index c93b44b..321602b 100644 --- a/web-next/src/pages/expenses/ExpensesPage.tsx +++ b/web-next/src/pages/expenses/ExpensesPage.tsx @@ -10,6 +10,8 @@ import AccessibleInput from "../../components/AccessibleInput"; import { dateText, money, monthNow } from "./date"; import type { Expense, Notify } from "./types"; +let expensesCache: { key: string; items: Expense[]; summary: { count: number; amountCents: number } } | null = null; + type Props = { timezone?: string; notify?: Notify; sessionKey?: string }; const EXPORT_JOB_STORAGE_KEY = "tallynote.exportJobId"; @@ -30,9 +32,14 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai", 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([]); const [summary, setSummary] = useState({ count: 0, amountCents: 0 }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loadedFilterKey, setLoadedFilterKey] = useState(null); const [selectedKeys, setSelectedKeys] = useState([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false); const filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`; + const isCached = expensesCache?.key === filterKey; + const [queryDraft, setQueryDraft] = useState(query); + const [items, setItems] = useState(() => (isCached ? expensesCache!.items : [])); + const [summary, setSummary] = useState(() => (isCached ? expensesCache!.summary : { count: 0, amountCents: 0 })); + const [loading, setLoading] = useState(() => !isCached); + const [error, setError] = useState(""); + const [loadedFilterKey, setLoadedFilterKey] = useState(() => (isCached ? filterKey : null)); const [selectedKeys, setSelectedKeys] = useState([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false); useEffect(() => { const params = new URLSearchParams(searchParams); let changed = false; @@ -55,8 +62,8 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai", 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 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))); expensesCache = { key: requestedKey, items: result.items, summary: result.summary }; } } 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([]); if (expensesCache?.key !== filterKey) { 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) => { @@ -125,7 +132,7 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
{summary.count} 笔{money(summary.amountCents)}{selectedKeys.length > 0 && <>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}}
{exportIssue &&
{exportIssue}
} {error &&
{error}
} - {error ? null : !dataReady || (loading && !items.length) ?
: !items.length ? emptyState :
setSelectedKeys(keys as string[])} hover stripe />} + {error ? null : (!items.length && (loading || !dataReady)) ?
: !items.length ? emptyState :
setSelectedKeys(keys as string[])} hover stripe />} {drawer === "new" && setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />} {trashTarget && { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>确认将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站?移入后将不在正常列表中展示,关联附件将完整保留,可随时恢复。} ; diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 2cd324b..5f44d81 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -1,3 +1,6 @@ +// In-memory cache across page transitions for zero-latency instant rendering +let updateInfoCache: any = null; + import { BeamLoading, BeamBar } from "../../components/BeamLoading"; import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { @@ -302,7 +305,9 @@ export default function UpdatePage({ setLoading(true); setError(""); try { - setLiveInfo(await api("/api/update/status")); + const res = await api("/api/update/status"); + setLiveInfo(res); + updateInfoCache = res; } catch (e) { setError((e as Error).message); } finally { diff --git a/web-next/src/styles/theme.css b/web-next/src/styles/theme.css index 2e589c7..a6ee31e 100644 --- a/web-next/src/styles/theme.css +++ b/web-next/src/styles/theme.css @@ -501,7 +501,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { .tn-dashboard-month-control .tn-dashboard-month { width: 148px; flex: 0 1 148px; } .tn-dashboard-secondary-actions .tn-dashboard-refresh, .tn-dashboard-secondary-actions .tn-dashboard-view { flex: 0 0 auto; } .tn-layout { animation: tn-app-enter .24s ease-out both; } -.tn-page-transition { animation: tn-page-in .18s ease-out both; } +.tn-page-transition { animation: tn-page-in .14s cubic-bezier(0.16, 1, 0.3, 1) both; will-change: opacity, transform; } .tn-page-actions .t-button { min-height: 36px; } .expenses-toolbar { display: flex; align-items: center; gap: 8px; } .expenses-toolbar .t-button--shape-square { width: 36px; height: 36px; min-width: 36px; min-height: 36px; } @@ -568,7 +568,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { .tn-spin, .spin { animation: tn-spin .9s linear infinite; } @keyframes tn-spin { to { transform: rotate(360deg); } } -@keyframes tn-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } +@keyframes tn-page-in { from { opacity: 0.88; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } } @keyframes tn-app-enter { from { opacity: 0; } to { opacity: 1; } } @keyframes tn-auth-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } .t-dialog {