import { useEffect, useRef, useState } from "react"; import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal, Zap } 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; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null }; type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null }; type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null }; const active = new Set(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]); const pollable = new Set(["queued", "downloading", "verifying", "backing_up", "applying"]); const labels: Record = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "下载完成,等待应用", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" }; function timestamp(value: number | string | null | undefined): number | null { if (value === null || value === undefined || value === "") return null; const n = typeof value === "number" ? value : Date.parse(value); if (!Number.isFinite(n)) return null; return n < 10_000_000_000 ? n * 1000 : n; } function notesFor(latest: LatestRelease): string | null { const value = latest.notes ?? latest.releaseNotes ?? latest.body; return typeof value === "string" && value.trim() ? value.trim() : null; } export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) { const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); const [error, setError] = useState(""); const [pollError, setPollError] = useState(""); const [confirmVersion, setConfirmVersion] = useState(null); const [confirmAction, setConfirmAction] = useState<"download" | "apply">("download"); const [actionBusy, setActionBusy] = useState(false); const [reloadReady, setReloadReady] = useState(false); const [now, setNow] = useState(() => Date.now()); const announced = useRef(null); const checkInFlight = useRef(false); const actionInFlight = useRef(false); const disconnected = useRef(false); const recoveredNotice = useRef(false); const load = async () => { setLoading(true); setError(""); try { setInfo(await api("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }; useEffect(() => { void load(); }, []); const job = info?.job; const applyQueuedAt = timestamp(job?.applyQueuedAt); const restartAt = timestamp(job?.restartDeadline) ?? timestamp(job?.restartAt) ?? timestamp(job?.expectedRecoveryAt) ?? (job?.operation === "apply" && applyQueuedAt ? applyQueuedAt + (job.restartWindowSeconds ?? 30) * 1000 : null) ?? (job?.status === "applying" && job.updatedAt ? timestamp(job.updatedAt)! + 30_000 : null); const restartSeconds = restartAt ? Math.max(0, Math.ceil((restartAt - now) / 1000)) : null; useEffect(() => { if (!restartAt) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [restartAt]); useEffect(() => { const shouldPoll = Boolean(job && (pollable.has(job.status) || (job.status === "staged" && job.operation === "apply"))); if (!shouldPoll || !job) { setPollError(""); return; } let disposed = false; let timer: number | undefined; let failures = 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; failures = 0; if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; notify?.("服务已恢复,更新状态已刷新", "success"); } disconnected.current = false; setPollError(""); setInfo(current => current ? { ...current, job: result.job } : current); if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); notify?.("更新完成,请重新加载页面", "success"); } if (pollable.has(result.job.status) || (result.job.status === "staged" && result.job.operation === "apply")) schedule(1500); } catch { if (disposed) return; failures += 1; disconnected.current = true; recoveredNotice.current = false; setPollError(`服务暂时不可用${restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试"}。更新任务仍在后台运行。`); schedule(Math.min(1500 * (2 ** Math.min(failures, 3)), 12_000)); } }; void poll(); return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); }; }, [job?.id, job?.status, job?.operation, notify]); const check = async () => { if (checkInFlight.current) return; checkInFlight.current = true; setChecking(true); setError(""); try { const result = await api & { 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) { if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { try { await load(); notify?.(`已显示最近一次检查结果${caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"}`, "info"); return; } catch { /* fall through */ } } setError((caught as Error).message); } finally { checkInFlight.current = false; setChecking(false); } }; const submitAction = async () => { if (!confirmVersion || actionInFlight.current) return; actionInFlight.current = true; setActionBusy(true); setError(""); try { const endpoint = confirmAction === "download" ? "/api/update/download" : "/api/update/apply"; const body = confirmAction === "download" ? { version: confirmVersion, confirm: true } : { jobId: job?.id, version: confirmVersion, confirm: true }; const result = await api<{ job: UpdateJob }>(endpoint, { method: "POST", body: JSON.stringify(body) }); setConfirmVersion(null); setReloadReady(false); setInfo(current => current ? { ...current, job: result.job } : current); notify?.(confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", "info"); } catch (caught) { setError((caught as Error).message); } finally { actionInFlight.current = false; setActionBusy(false); } }; const latest = info?.latest; const hasActiveJob = Boolean(job && active.has(job.status)); const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version); const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled")); const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version); const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial>)[job.status] ?? 100 : 0; const notes = latest ? notesFor(latest) : null; return void check()} disabled={checking || loading || hasActiveJob} icon={}>{checking ? "检查中…" : "检查更新"}}> {error && void load()} />}{pollError && } {loading ?
正在读取版本信息…
: info && <>
当前版本v{info.currentVersion}运行平台:{info.platform.target}更新方式{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}
{latest ?
最新 Release

{latest.releaseName || latest.tagName || `v${latest.version}`}

{latest.publishedAt && 发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}}
{latest.isNewer ? "有新版本" : "已是最新"}
{notes &&
Release notes
{notes}
}
平台文件{latest.compatible ? latest.assetName : "无匹配文件"}
完整性{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}
文件大小{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}
{latest.isNewer && !latest.compatible &&
当前平台没有可安装的 Release 文件。
}{latest.isNewer && latest.compatible && !latest.integrityReady &&
发布文件缺少完整校验,已禁用更新。
}
{canDownload && }{canApply && }{reloadReady && }
:
点击“检查更新”获取最新 Release。
} {!info.configured &&
当前为手动更新模式

源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。

} {job && 更新任务状态:{labels[job.status]}
最近任务

v{job.version}

{labels[job.status]}
{active.has(job.status) && <>
{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}{restartSeconds !== null && (job.status === "applying" || disconnected.current) &&
服务正在重启,预计 {restartSeconds} 秒后恢复
}}{job.status === "failed" && job.errorMessage &&
{job.errorMessage}
}{job.status === "completed" &&
新版本已通过健康检查,数据和附件保持不变。
}
} } { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}
; }