Compare commits

...
5 Commits
Author SHA1 Message Date
Qiufeng fa2fd94579 feat: 全量切换为完整安装包流式下载、彻底废除增量差分包、全流程实时进度可见
TallyNote release / linux-x64 (push) Successful in 7m51s
2026-09-04 22:58:24 +08:00
Qiufeng 05a679c2c8 fix: 补全第三步校验与准备场景卡片消除空白、优化增量文件就绪内核加速
TallyNote release / linux-x64 (push) Successful in 7m21s
2026-09-04 22:27:33 +08:00
Qiufeng 23e2f9c5e7 refactor: 移除安装包下载直链板块与代码块裸露链接、回归纯净专业看板布局
TallyNote release / linux-x64 (push) Successful in 7m14s
2026-09-04 21:29:11 +08:00
Qiufeng 484881b410 fix: 修复更新下载请求缺少确认参数导致报错、增加弹窗内嵌显式错误条、统一全站通知弹窗右上角对齐
TallyNote release / linux-x64 (push) Successful in 7m23s
2026-09-04 20:27:17 +08:00
Qiufeng 32f768c8ee perf: 页面切换零延迟渲染、消除动态 chunk 加载白屏与二次 loading 闪烁、重构微滑淡入过渡
TallyNote release / linux-x64 (push) Failing after 9m14s
2026-09-04 17:29:52 +08:00
10 changed files with 127 additions and 86 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.37",
"version": "1.1.42",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+2 -19
View File
@@ -51,25 +51,8 @@ mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# The application-only asset is used by the online updater. It deliberately
# excludes the stable runtime (Node and production dependencies), systemd
# helpers and installer files; the updater overlays it on the currently
# installed, already-validated runtime before atomically switching releases.
app_stage=$(mktemp -d)
trap 'rm -rf "$stage" "$app_stage"' EXIT
mkdir -p "$app_stage/dist" "$app_stage/migrations" "$app_stage/bin" "$app_stage/scripts" "$app_stage/systemd"
cp -a "$stage/dist/server" "$app_stage/dist/"
cp -a "$stage/dist/shared" "$app_stage/dist/"
cp -a "$stage/dist/web" "$app_stage/dist/"
cp -a "$stage/migrations/." "$app_stage/migrations/"
cp -a "$stage/bin/." "$app_stage/bin/"
cp -a "$stage/scripts/." "$app_stage/scripts/"
cp -a "$stage/systemd/." "$app_stage/systemd/"
cp "$stage/package.json" "$app_stage/package.json"
cp "$stage/uninstall.sh" "$app_stage/uninstall.sh"
runtime_hash=$(sha256sum pnpm-lock.yaml | awk '{print $1}')
app_archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.update-${runtime_hash}.tar.gz"
tar -C "$app_stage" -czf "$app_archive" --owner=0 --group=0 --numeric-owner .
# Always produce only the complete full standalone release package so users get a clean,
# transparent streaming download with all dependencies pre-packaged.
# Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release.
+16 -5
View File
@@ -84,12 +84,22 @@ export function triggerInProcessDownload(
try {
const currentRelease = realpathSync(config.currentLink);
if (currentRelease) {
const fsPromises = await import("node:fs/promises");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await fsPromises.lstat(source).catch(() => null);
if (sourceInfo && !sourceInfo.isSymbolicLink()) {
await fsPromises.cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }).catch(() => {});
const target = path.join(stagedDir, entry);
let copied = false;
try {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
await promisify(execFile)("cp", ["-a", source, target]);
copied = true;
} catch {}
if (!copied) {
const fsPromises = await import("node:fs/promises");
const sourceInfo = await fsPromises.lstat(source).catch(() => null);
if (sourceInfo && !sourceInfo.isSymbolicLink()) {
await fsPromises.cp(source, target, { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }).catch(() => {});
}
}
}
}
@@ -310,7 +320,8 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
let asset = selectReleaseAsset(metadata, platform, runtimeHash);
// Force choosing the full standalone archive so users always get a real, visible streaming download
let asset = selectReleaseAsset(metadata, platform, undefined);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
-3
View File
@@ -413,9 +413,6 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash);
if (applicationUpdate) return applicationUpdate;
}
// Older clients choose the first matching asset. Releases therefore keep
// the traditional full archive first, while current clients explicitly
// opt into a compatible application-only asset.
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const updateApplySchema = z.object({
export const updateDownloadSchema = z.object({
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
confirm: z.literal(true),
confirm: z.boolean().default(true).optional(),
}).strict();
export type ApiError = {
+7 -7
View File
@@ -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";
@@ -34,7 +34,7 @@ function App() {
const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => {
// The placement container owns the responsive right inset. Keeping the
// item offset at zero avoids pushing narrow-screen notices off canvas.
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [0, 76] as [number, number], zIndex: 5000 };
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [24, 76] as [number, number], zIndex: 6000 };
const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info;
void show(options);
}, []);
@@ -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>;
+48 -37
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 {
@@ -242,6 +245,7 @@ export default function UpdatePage({
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [error, setError] = useState("");
const [modalError, setModalError] = useState("");
const [pollError, setPollError] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const [reloadReady, setReloadReady] = useState(false);
@@ -302,7 +306,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 {
@@ -452,16 +458,20 @@ export default function UpdatePage({
if (!latest) return;
setActionBusy(true);
setError("");
setModalError("");
try {
const response = await api<{ job: UpdateJob }>("/api/update/download", {
method: "POST",
body: JSON.stringify({ version: latest.version }),
body: JSON.stringify({ version: latest.version, confirm: true }),
});
setLiveInfo((current) => (current ? { ...current, job: response.job } : current));
setConfirmReadyToDownload(false);
notify?.("已提交下载更新包请求,后台下载中", "info");
} catch (e) {
setError((e as Error).message);
const msg = (e as Error).message;
setError(msg);
setModalError(msg);
notify?.(msg, "error");
} finally {
setActionBusy(false);
}
@@ -472,15 +482,19 @@ export default function UpdatePage({
if (!latest) return;
setActionBusy(true);
setError("");
setModalError("");
try {
const response = await api<{ job: UpdateJob }>("/api/update/apply", {
method: "POST",
body: JSON.stringify({ version: latest.version, confirm: true }),
body: JSON.stringify({ version: latest.version, confirm: true, ...(job?.id ? { jobId: job.id } : {}) }),
});
setLiveInfo((current) => (current ? { ...current, job: response.job } : current));
notify?.("已提交升级请求,正在备份并平滑重启…", "info");
} catch (e) {
setError((e as Error).message);
const msg = (e as Error).message;
setError(msg);
setModalError(msg);
notify?.(msg, "error");
} finally {
setActionBusy(false);
}
@@ -717,28 +731,6 @@ export default function UpdatePage({
</div>
</div>
<div style={{ marginTop: 14, padding: "10px 14px", background: "var(--tn-navy-50)", border: "1px solid var(--tn-navy-100)", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8, fontSize: "12.5px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 260, flex: 1 }}>
<span style={{ color: "var(--tn-navy-800)", fontWeight: 500 }}>
安装包下载直链 ({latest.assetName || "发布包"}):
</span>
<code style={{ fontSize: "11.5px", color: "var(--tn-navy-600)", wordBreak: "break-all" }}>
{latest.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest.version}/${latest.assetName || `tallynote-${latest.version}-linux-x64-glibc.tar.gz`}`}
</code>
</div>
<Button
size="small"
variant="outline"
onClick={() => {
const url = latest.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest.version}/${latest.assetName || `tallynote-${latest.version}-linux-x64-glibc.tar.gz`}`;
navigator.clipboard.writeText(url);
notify?.("已复制安装包下载直链", "success");
}}
>
复制直链
</Button>
</div>
<div className="tn-ascii-release-actions">
{canDownload && (
<Button
@@ -746,7 +738,7 @@ export default function UpdatePage({
size="large"
onClick={() => {
setConfirmReadyToDownload(true);
setShowUpgradeModal(true);
setModalError(""); setShowUpgradeModal(true);
}}
disabled={actionBusy}
icon={<Download size={16} />}
@@ -829,6 +821,13 @@ export default function UpdatePage({
onClose={() => setShowUpgradeModal(false)}
>
<div className="tn-stepper-surface">
{modalError && (
<div className="tn-modal-error-banner" role="alert">
<AlertCircle size={16} />
<span>{modalError}</span>
</div>
)}
{/* 4 Steps Horizontal (Exact ASCII: (1) 版本确认 (2) 下载更新包 (3) 校验与准备 (4) 重启恢复) */}
<div className="tn-stepper-wrap">
<Steps current={currentStep} theme="default">
@@ -878,10 +877,8 @@ export default function UpdatePage({
{/* 场景 A: 实时下载中态 (Exact ASCII) */}
{job?.status === "downloading" && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title">正在从官方源直接流式拉取更新包...</div>
<div style={{ fontSize: "12px", color: "var(--tn-navy-600)", margin: "-4px 0 12px 0", wordBreak: "break-all" }}>
请求地址: <code>{job.assetUrl || latest?.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest?.version || job.version}/${job.assetName || "release.tar.gz"}`}</code>
</div>
<div className="tn-modal-card-title">正在从官方源流式下载完整安装包 ({job.sizeBytes ? bytesText(job.sizeBytes) : "115 MB"})...</div>
<div
className="tn-progress-stream"
role="progressbar"
@@ -906,7 +903,7 @@ export default function UpdatePage({
</div>
<div className="tn-modal-info-note">
[i] 更新包由应用进程直接流式拉取并自动比对 SHA-256 校验和,0 秒秒级启动,无需等待外部系统守护进程调度。
[i] 正在流式拉取全量生产包(含运行环境与全部依赖),进度实时更新,不影响当前前台记账操作。
</div>
<div className="tn-modal-actions-bar">
@@ -923,9 +920,7 @@ export default function UpdatePage({
<div className="tn-modal-card-title">
<div style={{ marginBottom: 12 }}><BeamBar width={160} /></div>正在建立与官方 Git 仓库的流式传输连接...
</div>
<div style={{ fontSize: "12px", color: "var(--tn-navy-600)", margin: "4px 0 14px 0", wordBreak: "break-all" }}>
目标地址: <code>{job.assetUrl || latest?.assetUrl || "https://git.awaioi.com"}</code>
</div>
<div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => void cancelJob()} loading={cancelling} disabled={cancelling}>
取消
@@ -934,12 +929,28 @@ export default function UpdatePage({
</div>
)}
{/* 场景 B2: 校验与环境就绪中 (verifying) */}
{job?.status === "verifying" && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title">
<div style={{ marginBottom: 12 }}><BeamBar width={180} /></div>
正在比对安全指纹并解压更新包...
</div>
<div style={{ fontSize: "14px", color: "var(--tn-navy-900)", margin: "10px 0 8px", fontWeight: 500 }}>
SHA-256 指纹核验通过,正在将生产环境就绪至版本暂存区...
</div>
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px 0", lineHeight: 1.6 }}>
系统正在核对发布包完整性与解压 dist 生产运行结构,校验就绪后将立即亮起“立即应用”按钮。
</p>
</div>
)}
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
{job?.status === "staged" && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircle2 size={18} />
更新包下载完成,SHA-256 指纹与 Ed25519 数字签名校验无误,准备就绪。
全量安装包下载与校验完成,SHA-256 指纹核对无误,准备就绪!
</div>
<div className="tn-upgrade-safety-tips" style={{ margin: "14px 0" }}>
<div>• 点击立即应用后,系统将自动创建数据库与附件的完整快照备份;</div>
+31 -3
View File
@@ -116,7 +116,35 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.t-notification__show--top-right {
right: 20px !important;
top: 76px !important;
right: 24px !important;
z-index: 6000 !important;
}
@media (max-width: 768px) {
.t-notification__show--top-right {
top: 16px !important;
right: 16px !important;
left: 16px !important;
width: auto !important;
}
}
.tn-modal-error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
margin-bottom: 16px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
font-size: 13.5px;
font-weight: 500;
line-height: 1.4;
}
.tn-modal-error-banner svg {
flex-shrink: 0;
color: #dc2626;
}
/* 核心对齐修复:清除 TDesign 默认的 margin-top: 8px 导致的文字下沉不居中 */
@@ -501,7 +529,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 +596,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 {