release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal } from "lucide-react";
import { Button, Dialog, Tag } from "tdesign-react";
import { ApiError, api } from "../../services/api";
import { dateText } from "../expenses/date";
import { ErrorBanner, Page, Surface } from "../common";
import type { Notify } from "../expenses/types";
type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
type UpdateJob = { id: string; status: JobStatus; 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 };
const active = new Set<JobStatus>(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]);
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "准备完成", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [error, setError] = useState("");
const [pollError, setPollError] = useState("");
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [reloadReady, setReloadReady] = useState(false);
const announced = useRef<string | null>(null);
const checkInFlight = useRef(false);
const applyInFlight = useRef(false);
const load = async () => { setLoading(true); setError(""); try { setInfo(await api<UpdateInfo>("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } };
useEffect(() => { void load(); }, []);
useEffect(() => {
const job = info?.job;
if (!job) { setPollError(""); return; }
const announceCompletion = (completedJob: UpdateJob) => {
if (completedJob.status === "completed" && announced.current !== completedJob.id) {
announced.current = completedJob.id;
setReloadReady(true);
notify?.("更新完成,请重新加载页面", "success");
}
};
if (job.status === "completed") {
setPollError("");
announceCompletion(job);
return;
}
if (!active.has(job.status)) { setPollError(""); return; }
let disposed = false;
let timer: number | undefined;
let failureCount = 0;
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
const poll = async () => {
try {
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
if (disposed) return;
failureCount = 0;
setPollError("");
setInfo(current => current ? { ...current, job: result.job } : current);
announceCompletion(result.job);
if (active.has(result.job.status)) schedule(1500);
} catch (caught) {
if (disposed) return;
failureCount += 1;
setPollError(`${(caught as Error).message}。更新任务仍在后台运行,页面会自动重试。`);
schedule(Math.min(1500 * (2 ** Math.min(failureCount, 3)), 12_000));
}
};
void poll();
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
}, [info?.job?.id, info?.job?.status, notify]);
const check = async () => {
if (checkInFlight.current) return;
checkInFlight.current = true;
setChecking(true);
setError("");
try {
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null }));
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
} catch (caught) {
// A configured release source is intentionally rate-limited. A repeated
// click should still be useful: show the cached status instead of a
// blocking error, while preserving the server-side flood protection.
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
try {
await load();
const seconds = caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查";
notify?.(`已显示最近一次检查结果${seconds}`, "info");
return;
} catch {
// Fall through to the normal error surface if the status read fails.
}
}
setError((caught as Error).message);
} finally {
checkInFlight.current = false;
setChecking(false);
}
};
const apply = async () => { if (!confirmVersion || applyInFlight.current) return; applyInFlight.current = true; setApplying(true); setError(""); try { const result = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: confirmVersion, confirm: true }) }); setConfirmVersion(null); setInfo(current => current ? { ...current, job: result.job } : current); notify?.("更新请求已提交,服务会短暂重启", "info"); } catch (e) { setError((e as Error).message); } finally { applyInFlight.current = false; setApplying(false); } };
const latest = info?.latest; const job = info?.job; const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !job || info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job && !active.has(job.status));
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
{error && <ErrorBanner message={error} onRetry={() => void load()} />}
{pollError && <ErrorBanner message={pollError} />}
{loading ? <div className="tn-empty" role="status" aria-live="polite">正在读取版本信息…</div> : info && <>
<div className="tn-update-grid"><Surface className="tn-update-block"><Server size={20} /><span className="tn-eyebrow">当前版本</span><strong className="tn-update-value">v{info.currentVersion}</strong><small>运行平台:{info.platform.target}</small></Surface><Surface className="tn-update-block"><ShieldCheck size={20} /><span className="tn-eyebrow">更新方式</span><strong>{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}</strong><small>{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}</small></Surface></div>
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div><div className="tn-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canApply && <Button theme="primary" onClick={() => setConfirmVersion(latest.version)} disabled={applying} icon={<Download size={16} />}>更新到 v{latest.version}</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
{!info.configured && <div className="tn-update-explainer"><Terminal size={17} /><div><strong>当前为手动更新模式</strong><p>源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。</p></div></div>}
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>更新服务正在后台运行,页面会自动刷新状态。</small></>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
</>}
<Dialog visible={Boolean(confirmVersion)} header="确认系统更新" confirmBtn={{ content: "确认更新", theme: "primary", loading: applying, disabled: applying }} cancelBtn="取消" onClose={() => { if (!applying) setConfirmVersion(null); }} onConfirm={() => void apply()} onCancel={() => { if (!applying) setConfirmVersion(null); }}>将更新到 v{confirmVersion}。服务会短暂重启,更新前会备份数据目录;账目、附件、回收站和审计记录不会被删除。</Dialog>
</Page>;
}
+1
View File
@@ -0,0 +1 @@
export { default as UpdatePage } from "./UpdatePage";