import { BeamLoading, BeamBar } from "../../components/BeamLoading"; import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { RefreshCw, Download, Zap, CheckCircle2, AlertCircle, ShieldCheck, ShieldAlert, Server, Cpu, Clock, ExternalLink, ChevronRight, Play, RotateCcw, X, FileCode, HardDrive, Terminal, } from "lucide-react"; import { Button, Card, Dialog, RadioGroup, Radio, Steps, Tag, Tooltip } from "tdesign-react"; import { Page, Surface } from "../common"; import { api } from "../../services/api"; import { dateText } from "../../utils/date"; import type { MockScenario, MockUpdateState } from "./mockData"; const { StepItem } = Steps; // Enable mock preview in local development const MOCK_PREVIEW_ENABLED = import.meta.env.DEV; export type JobStatus = | "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled"; export interface UpdateJob { id: string; operation: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; assetUrl?: string | null; releaseUrl?: string | null; sizeBytes?: number | null; downloadedBytes?: number | null; downloadStartedAt?: number | string | null; downloadSpeedBps?: number | null; errorMessage?: string | null; createdAt?: number | string | null; updatedAt?: number | string | null; completedAt?: number | string | null; applyQueuedAt?: number | string | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null; restartWindowSeconds?: number | null; } export interface LatestRelease { version: string; tagName?: string; releaseName?: string; publishedAt?: string; isNewer: boolean; notes?: string; releaseNotes?: string; body?: string; releaseUrl?: string; metadataUrl?: string; assetUrl?: string; compatible: boolean; integrityReady: boolean; assetName: string; assetSize?: number; } export interface UpdateInfo { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; checkedAt: number | null; platform: { target: string; os: string; arch: string; libc: string; }; latest: LatestRelease | null; job: UpdateJob | null; } const activeStatuses = new Set([ "queued", "downloading", "verifying", "backing_up", "applying", ]); const pollableStatuses = new Set([ "queued", "downloading", "verifying", "backing_up", "applying", ]); const statusLabels: 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 | null): string | null { if (!latest) return null; const value = latest.notes ?? latest.releaseNotes ?? latest.body; return typeof value === "string" && value.trim() ? value.trim() : null; } function inlineMarkdown(value: string): ReactNode[] { const tokens = new RegExp("(`[^`\\n]+`|\\*\\*[^*\\n]+\\*\\*|\\\\[[^\\\\]\\n]+\\\\]\\((?:https?:\\/\\/)[^)\\s]+\\))", "g"); const result: ReactNode[] = []; let last = 0; let match: RegExpExecArray | null; while ((match = tokens.exec(value))) { if (match.index > last) result.push(value.slice(last, match.index)); const token = match[0]; if (token.startsWith("`") && token.endsWith("`")) { result.push({token.slice(1, -1)}); } else if (token.startsWith("**") && token.endsWith("**")) { result.push({token.slice(2, -2)}); } else if (token.startsWith("[")) { const labelEnd = token.indexOf("]"); const label = token.slice(1, labelEnd); const url = token.slice(labelEnd + 2, -1); result.push( {label} ); } last = match.index + token.length; } if (last < value.length) result.push(value.slice(last)); return result; } function MarkdownNotes({ value, compact = false }: { value: string; compact?: boolean }) { const lines = value.split(/\r?\n/); const elements: ReactNode[] = []; let listItems: ReactNode[] = []; let listKey = 0; const flushList = () => { if (!listItems.length) return; elements.push(
    {listItems}
); listItems = []; }; for (let i = 0; i < lines.length; i++) { const raw = lines[i] ?? ""; const trimmed = raw.trim(); if (!trimmed) { flushList(); continue; } if (trimmed.startsWith("# ")) { flushList(); elements.push(

{inlineMarkdown(trimmed.slice(2))}

); } else if (trimmed.startsWith("## ")) { flushList(); elements.push(

{inlineMarkdown(trimmed.slice(3))}

); } else if (trimmed.startsWith("### ")) { flushList(); elements.push(
{inlineMarkdown(trimmed.slice(4))}
); } else if (trimmed.startsWith("> ")) { flushList(); elements.push(
{inlineMarkdown(trimmed.slice(2))}
); } else if (/^[-*]\s+/.test(trimmed)) { listItems.push(
  • {inlineMarkdown(trimmed.replace(/^[-*]\s+/, ""))}
  • ); } else { flushList(); elements.push(

    {inlineMarkdown(trimmed)}

    ); } } flushList(); return
    {elements}
    ; } function bytesText(bytes?: number | null): string { if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function speedText(bps?: number | null): string { if (!bps || bps <= 0) return "-- KB/s"; return `${bytesText(bps)}/s`; } export default function UpdatePage({ timezone = "Asia/Shanghai", notify, }: { timezone?: string; notify?: (msg: string, type?: "success" | "info" | "error") => void; }) { // Mock mode switcher for local developer preview const [mockScenario, setMockScenario] = useState<"live" | MockScenario>("live"); const mockTimer = useRef(null); const [mockCatalog, setMockCatalog] = useState | null>(null); // Live state const [liveInfo, setLiveInfo] = useState(null); const [mockInfoState, setMockInfoState] = useState(null); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); const [error, setError] = useState(""); const [pollError, setPollError] = useState(""); const [actionBusy, setActionBusy] = useState(false); const [reloadReady, setReloadReady] = useState(false); // Unified single upgrade modal const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [confirmReadyToDownload, setConfirmReadyToDownload] = useState(false); const [cancelling, setCancelling] = useState(false); const [queuedSeconds, setQueuedSeconds] = useState(0); 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); // Active info depending on mock vs live const isMock = MOCK_PREVIEW_ENABLED && mockScenario !== "live" && Boolean(mockCatalog); const selectedMock = mockScenario !== "live" ? mockCatalog?.[mockScenario] : undefined; const info = isMock && selectedMock ? (mockInfoState ?? selectedMock.info) : liveInfo; // Handle mock scenario change const handleScenarioChange = (scenario: "live" | MockScenario) => { setMockScenario(scenario); if (mockTimer.current !== null) { window.clearTimeout(mockTimer.current); mockTimer.current = null; } if (scenario !== "live") { const next = mockCatalog?.[scenario]; if (!next) return; setMockInfoState(JSON.parse(JSON.stringify(next.info))); notify?.(`已切换至 Mock 场景:${next.title}`, "info"); } else { setMockInfoState(null); notify?.("已切回真实后端模式", "info"); } }; useEffect(() => () => { if (mockTimer.current !== null) window.clearTimeout(mockTimer.current); }, []); useEffect(() => { if (!import.meta.env.DEV) return; let disposed = false; void import("./mockData").then(({ MOCK_SCENARIOS }) => { if (!disposed) setMockCatalog(MOCK_SCENARIOS); }); return () => { disposed = true; }; }, []); const load = async () => { if (isMock) return; setLoading(true); setError(""); try { setLiveInfo(await api("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }; useEffect(() => { if (!isMock) void load(); else setLoading(false); }, [isMock]); // Terminal failures or cancellations do not pollute active state const job = info?.job && info.job.status !== "failed" && info.job.status !== "cancelled" ? info.job : null; // Queued duration counter useEffect(() => { if (job?.status === "queued") { const timer = window.setInterval(() => setQueuedSeconds((s) => s + 1), 1000); return () => window.clearInterval(timer); } setQueuedSeconds(0); }, [job?.status]); 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]); // Live polling effect: only poll when active job exists useEffect(() => { if (isMock) return; const shouldPoll = Boolean( job && (pollableStatuses.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(""); setLiveInfo((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 ( pollableStatuses.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), 10_000)); } }; void poll(); return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); }; }, [isMock, job?.id, job?.status, job?.operation, notify, restartSeconds]); // Check update handler const check = async () => { if (isMock) { setChecking(true); window.setTimeout(() => { setChecking(false); notify?.("Mock:检查完成,已是最新配置状态", "success"); }, 600); return; } if (checkInFlight.current) return; checkInFlight.current = true; setChecking(true); setError(""); try { setLiveInfo(await api("/api/update/check", { method: "POST" })); notify?.("版本检查完成", "info"); } catch (e) { setError((e as Error).message); } finally { setChecking(false); checkInFlight.current = false; } }; // Cancel queued job handler const cancelJob = async () => { if (cancelling) return; setCancelling(true); try { await api("/api/update/cancel", { method: "POST", body: JSON.stringify({ jobId: job?.id }) }); notify?.("已取消更新排队", "success"); setShowUpgradeModal(false); setConfirmReadyToDownload(false); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setCancelling(false); } }; // Start download action const startDownload = async () => { if (!latest) return; setActionBusy(true); setError(""); try { const response = await api<{ job: UpdateJob }>("/api/update/download", { method: "POST", body: JSON.stringify({ version: latest.version }), }); setLiveInfo((current) => (current ? { ...current, job: response.job } : current)); setConfirmReadyToDownload(false); notify?.("已提交下载更新包请求,后台下载中", "info"); } catch (e) { setError((e as Error).message); } finally { setActionBusy(false); } }; // Start apply action const startApply = async () => { if (!latest) return; setActionBusy(true); setError(""); try { const response = await api<{ job: UpdateJob }>("/api/update/apply", { method: "POST", body: JSON.stringify({ version: latest.version, confirm: true }), }); setLiveInfo((current) => (current ? { ...current, job: response.job } : current)); notify?.("已提交升级请求,正在备份并平滑重启…", "info"); } catch (e) { setError((e as Error).message); } finally { setActionBusy(false); } }; const latest = info?.latest; const notes = notesFor(latest); const canDownload = Boolean( info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && (!job || job.status === "failed" || job.status === "cancelled") ); const canApply = Boolean( info?.strategy === "systemd" && job && job.status === "staged" && job.operation === "download" ); const hasActiveJob = Boolean( job && activeStatuses.has(job.status) && job.status !== "staged" ); // Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart) const currentStep = useMemo(() => { if (!job) return 0; switch (job.status) { case "queued": case "downloading": return 1; case "verifying": case "staged": return 2; case "backing_up": case "applying": case "completed": return 3; default: return 0; } }, [job?.status]); // Compute progress percentage const progress = useMemo(() => { if (!job) return 0; if (job.status === "completed" || job.status === "staged") return 100; if (job.status === "downloading") { if (!job.sizeBytes || !job.downloadedBytes) return 10; return Math.min(99, Math.max(1, Math.round((job.downloadedBytes / job.sizeBytes) * 100))); } if (job.status === "verifying") return 99; if (job.status === "backing_up") return 40; if (job.status === "applying") return 80; return 0; }, [job]); // Calculate ETA seconds const etaSeconds = useMemo(() => { if ( job?.status === "downloading" && job.sizeBytes && job.downloadedBytes && job.downloadSpeedBps && job.downloadSpeedBps > 0 ) { const remainingBytes = Math.max(0, job.sizeBytes - job.downloadedBytes); return Math.max(1, Math.ceil(remainingBytes / job.downloadSpeedBps)); } return null; }, [job]); return ( {hasActiveJob && ( )} {reloadReady && ( )} } className="tn-update-page" > {/* Global alert / error */} {error && (
    {error}
    )} {pollError && (
    {pollError}
    )} {loading && !info ? ( ) : !info ? (

    无法读取系统更新状态,请稍后刷新重试。

    ) : ( <> {/* 4 Metric Cards (System Overview) */}
    当前运行版本
    v{info.currentVersion}
    {latest?.isNewer ? ( 可更新至 v{latest.version} ) : ( 已是最新版本 )}
    运行目标架构
    {info.platform.target}
    ({info.platform.arch || "x86_64"} {info.platform.libc || "glibc"})
    更新守护方式
    {info.strategy === "systemd" ? "系统守护进程" : "独立运行模式"}
    ({info.strategy === "systemd" ? "systemd active" : "未激活"})
    最近一次检查
    {info.checkedAt ? dateText(info.checkedAt, timezone) : "未检查"}
    源: git.awaioi.com (HTTPS)
    {/* Container 1: Release Details Card */} {latest ? (

    发布版本详情

    {latest.isNewer ? "发现新版本" : "已是最新版本"}
    最新发布版本: v{latest.version}
    发布时间: {latest.publishedAt ? dateText(latest.publishedAt, timezone) : "官方通道发布"}
    安装包大小: {latest.assetSize ? bytesText(latest.assetSize) : "-"}
    运行环境适配: {latest.compatible ? "与当前系统完全匹配" : "环境暂未匹配"}
    安全完整性: {latest.integrityReady ? "SHA-256 + 签名已就绪" : "校验清单未就绪"}
    下载策略: 应用内流式直连 (零调度等待)
    安装包下载直链 ({latest.assetName || "发布包"}): {latest.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest.version}/${latest.assetName || `tallynote-${latest.version}-linux-x64-glibc.tar.gz`}`}
    {canDownload && ( )} {canApply && ( )} {hasActiveJob && ( )} {!latest.isNewer && !hasActiveJob && ( )}
    ) : (

    暂无待更新的版本信息,当前系统已是最新状态。

    )} {latest && notes && (

    本次版本更新说明

    )} {/* Unmanaged warning if not systemd */} {!info.configured && (
    当前运行环境未启用后台自动更新服务

    系统当前处于独立运行模式。若需开启在线一键更新,请配置系统后台更新守护进程。

    )} )} {/* Unified Single Upgrade Modal (800px width on desktop) */} setShowUpgradeModal(false)} >
    {/* 4 Steps Horizontal (Exact ASCII: (1) 版本确认 (2) 下载更新包 (3) 校验与准备 (4) 重启恢复) */}
    {/* 场景 0: 升级前预检与确认 */} {!job && confirmReadyToDownload && latest && (
    确认升级至 TallyNote v{latest.version}

    系统即将启动全自动化升级流水线,升级全过程平滑、安全且具备自动回滚保护。

    • 更新包将在后台独立下载并完成数字签名比对,不影响当前前台正常记账;
    • 应用更新前系统将自动生成数据库与凭据附件的完整安全快照;
    • 服务平滑重启预计仅需 15~30 秒,健康检查通过后页面自动刷新恢复。
    )} {/* 场景 A: 实时下载中态 (Exact ASCII) */} {job?.status === "downloading" && (
    正在从官方源直接流式拉取更新包...
    请求地址: {job.assetUrl || latest?.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest?.version || job.version}/${job.assetName || "release.tar.gz"}`}
    已下载:{bytesText(job.downloadedBytes)} / {bytesText(job.sizeBytes)} ({progress}%)
    传输速率:{speedText(job.downloadSpeedBps)}
    预估剩余:{etaSeconds !== null ? `${etaSeconds} 秒` : "计算中…"}
    [i] 更新包由应用进程直接流式拉取并自动比对 SHA-256 校验和,0 秒秒级启动,无需等待外部系统守护进程调度。
    )} {/* 场景 B: 建立连接中 (queued) (Exact ASCII) */} {job?.status === "queued" && (
    正在建立与官方 Git 仓库的流式传输连接...
    目标地址: {job.assetUrl || latest?.assetUrl || "https://git.awaioi.com"}
    )} {/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */} {job?.status === "staged" && (
    更新包下载完成,SHA-256 指纹与 Ed25519 数字签名校验无误,准备就绪。
    • 点击立即应用后,系统将自动创建数据库与附件的完整快照备份;
    • 服务将进行平滑重启(预计 15~30 秒),完成后页面将自动重连并刷新;
    • 升级过程具备原子切换与自愈保护,若健康检查异常将自动回退至当前版本。
    )} {/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */} {(job?.status === "backing_up" || job?.status === "applying") && (
    服务正在平滑重启并执行健康检查...
    预计恢复时间: {restartSeconds !== null ? `${restartSeconds} 秒` : "18 秒"} (倒计时中)

    系统正在自动进行心跳探活检测,连接成功后将自动唤醒,请勿关闭或强制断开服务器。

    )} {/* 场景 E: 升级完成 (completed) (Exact ASCII) */} {job?.status === "completed" && (
    恭喜!系统已成功平滑升级至 v{latest?.version || info.currentVersion}。

    服务健康检查已全部通过,所有账目数据与发票凭证均完好无损。请点击下方按钮完成控制台刷新。

    )} {/* 场景 F: 失败 (failed) */} {job?.status === "failed" && (
    升级任务未完成

    {job.errorMessage || "升级任务出现异常,已自动保护并回退至原版本。数据完整无损。"}

    )} {/* Footer close button */} {job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && (
    )}
    {/* Floating Mock Dock (Bottom-right, zero interference with main page) */} {MOCK_PREVIEW_ENABLED && ( )}
    ); }