perf: 页面切换零延迟渲染、消除动态 chunk 加载白屏与二次 loading 闪烁、重构微滑淡入过渡
TallyNote release / linux-x64 (push) Failing after 9m14s

This commit is contained in:
Qiufeng
2026-09-04 17:29:52 +08:00
parent db37406498
commit 32f768c8ee
6 changed files with 36 additions and 20 deletions
@@ -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<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
const [reimbursed, setReimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
const [loading, setLoading] = useState(true);
const isCached = dashboardCache?.month === month;
const [unreimbursed, setUnreimbursed] = useState<ExpenseResult>(() => (isCached ? dashboardCache!.unreimbursed : { items: [], summary: { count: 0, amountCents: 0 } }));
const [reimbursed, setReimbursed] = useState<ExpenseResult>(() => (isCached ? dashboardCache!.reimbursed : { items: [], summary: { count: 0, amountCents: 0 } }));
const [loading, setLoading] = useState(() => !isCached);
const [error, setError] = useState("");
const [loadedMonth, setLoadedMonth] = useState<string | null>(null);
const [loadedMonth, setLoadedMonth] = useState<string | null>(() => (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 }
<div className="tn-page-actions"><div className="tn-dashboard-actions"><div className="tn-dashboard-month-control"><Tooltip content="上个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={16} />} /></Tooltip><DatePicker className="tn-dashboard-month" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) setDashboardMonth(next); }} inputProps={{ "aria-label": "仪表盘月份" } as any} /><Tooltip content="下个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={16} />} /></Tooltip></div><div className="tn-dashboard-secondary-actions"><Button className="tn-dashboard-refresh" variant="outline" onClick={() => void load()} disabled={loading} icon={<RefreshCw size={15} />}>刷新</Button><Button className="tn-dashboard-view" theme="primary" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看账目</Button></div></div></div>
</div>
{error && hasCurrentSnapshot && <Alert theme="error" icon={<AlertCircle size={16} />} message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />}
{loading ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="正在汇总本月数据…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
{loading && !hasCurrentSnapshot ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="正在汇总本月数据…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
<div className="tn-dashboard-stats">
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-total"><Statistic title="本月总额" value={totalCents / 100} prefix="¥" decimalPlaces={2} /></Card>
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-count"><Statistic title="账目笔数" value={totalCount} suffix="笔" /></Card>
+12 -5
View File
@@ -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<Expense[]>([]); const [summary, setSummary] = useState({ count: 0, amountCents: 0 }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(null); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
const filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`;
const isCached = expensesCache?.key === filterKey;
const [queryDraft, setQueryDraft] = useState(query);
const [items, setItems] = useState<Expense[]>(() => (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<string | null>(() => (isCached ? filterKey : null)); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
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",
<div className="tn-summary expenses-summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong>{selectedKeys.length > 0 && <><span>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}</span><Button variant="text" onClick={() => setSelectedKeys([])}>清除选择</Button></>}</div>
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>关闭提示</Button></div>}
{error && <div className="expense-error" role="alert"><AlertCircle size={16} />{error}<Button variant="text" onClick={() => void load()}>重试</Button></div>}
{error ? null : !dataReady || (loading && !items.length) ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="正在加载账目列表…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
{error ? null : (!items.length && (loading || !dataReady)) ? <div className="tn-empty" role="status" aria-live="polite"><BeamLoading text="正在加载账目列表…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
{drawer === "new" && <ExpenseDrawer timezone={timezone} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />}
{trashTarget && <Dialog width="540px" visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>确认将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站?移入后将不在正常列表中展示,关联附件将完整保留,可随时恢复。</Dialog>}
</div>;
+6 -1
View File
@@ -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<UpdateInfo>("/api/update/status"));
const res = await api<UpdateInfo>("/api/update/status");
setLiveInfo(res);
updateInfoCache = res;
} catch (e) {
setError((e as Error).message);
} finally {