Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2fd94579 | ||
|
|
05a679c2c8 | ||
|
|
23e2f9c5e7 | ||
|
|
484881b410 | ||
|
|
32f768c8ee |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.37",
|
||||
"version": "1.1.42",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user