Files
TallyNote/web-next/src/pages/update/UpdatePage.tsx
T
2026-09-04 16:51:43 +08:00

1060 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<JobStatus>([
"queued",
"downloading",
"verifying",
"backing_up",
"applying",
]);
const pollableStatuses = new Set<JobStatus>([
"queued",
"downloading",
"verifying",
"backing_up",
"applying",
]);
const statusLabels: Record<JobStatus, string> = {
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(<code key={match.index} className="tn-code-pill">{token.slice(1, -1)}</code>);
} else if (token.startsWith("**") && token.endsWith("**")) {
result.push(<strong key={match.index}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith("[")) {
const labelEnd = token.indexOf("]");
const label = token.slice(1, labelEnd);
const url = token.slice(labelEnd + 2, -1);
result.push(
<a key={match.index} href={url} target="_blank" rel="noreferrer" className="tn-link">
{label} <ExternalLink size={12} aria-hidden="true" />
</a>
);
}
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(<ul key={`ul-${listKey++}`} className="tn-notes-list">{listItems}</ul>);
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(<h3 key={i} className="tn-notes-h1">{inlineMarkdown(trimmed.slice(2))}</h3>);
} else if (trimmed.startsWith("## ")) {
flushList();
elements.push(<h4 key={i} className="tn-notes-h2">{inlineMarkdown(trimmed.slice(3))}</h4>);
} else if (trimmed.startsWith("### ")) {
flushList();
elements.push(<h5 key={i} className="tn-notes-h3">{inlineMarkdown(trimmed.slice(4))}</h5>);
} else if (trimmed.startsWith("> ")) {
flushList();
elements.push(<blockquote key={i} className="tn-notes-quote">{inlineMarkdown(trimmed.slice(2))}</blockquote>);
} else if (/^[-*]\s+/.test(trimmed)) {
listItems.push(<li key={i}>{inlineMarkdown(trimmed.replace(/^[-*]\s+/, ""))}</li>);
} else {
flushList();
elements.push(<p key={i} className="tn-notes-paragraph">{inlineMarkdown(trimmed)}</p>);
}
}
flushList();
return <div className={`tn-notes-rendered ${compact ? "is-compact" : ""}`}>{elements}</div>;
}
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<number | null>(null);
const [mockCatalog, setMockCatalog] = useState<Record<MockScenario, MockUpdateState> | null>(null);
// Live state
const [liveInfo, setLiveInfo] = useState<UpdateInfo | null>(null);
const [mockInfoState, setMockInfoState] = useState<UpdateInfo | null>(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<string | null>(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<UpdateInfo>("/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<UpdateInfo>("/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 (
<Page
eyebrow="运维与维护"
title="系统更新中心"
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
actions={
<div className="tn-update-page-actions">
{hasActiveJob && (
<Button
theme="primary"
variant="base"
onClick={() => setShowUpgradeModal(true)}
icon={<Zap size={15} />}
>
查看升级进度
</Button>
)}
<Button
theme="default"
variant="outline"
onClick={() => void check()}
loading={checking}
disabled={checking || hasActiveJob}
icon={<RefreshCw size={15} />}
>
检查新版本
</Button>
{reloadReady && (
<Button theme="success" onClick={() => window.location.reload()} icon={<CheckCircle2 size={15} />}>
刷新页面
</Button>
)}
</div>
}
className="tn-update-page"
>
{/* Global alert / error */}
{error && (
<div className="tn-update-error-banner" role="alert">
<AlertCircle size={16} />
<span>{error}</span>
<Button variant="text" size="small" onClick={() => void check()}>
重试
</Button>
</div>
)}
{pollError && (
<div className="tn-update-error-banner is-warning" role="status">
<RefreshCw size={16} className="tn-spin" />
<span>{pollError}</span>
</div>
)}
{loading && !info ? (
<Surface className="tn-update-loading-surface">
<BeamLoading text="正在读取系统更新服务状态…" />
</Surface>
) : !info ? (
<Surface className="tn-empty-surface">
<div className="tn-empty-content">
<AlertCircle size={28} className="text-danger" />
<p>无法读取系统更新状态,请稍后刷新重试。</p>
<Button theme="primary" onClick={() => void load()} icon={<RefreshCw size={15} />}>
重新加载
</Button>
</div>
</Surface>
) : (
<>
{/* 4 Metric Cards (System Overview) */}
<div className="tn-metrics-grid">
<Surface className="tn-metric-card">
<span className="tn-metric-label">当前运行版本</span>
<div className="tn-metric-value">v{info.currentVersion}</div>
<div className="tn-metric-foot">
{latest?.isNewer ? (
<Tag theme="primary" size="small">可更新至 v{latest.version}</Tag>
) : (
<Tag theme="success" size="small">已是最新版本</Tag>
)}
</div>
</Surface>
<Surface className="tn-metric-card">
<span className="tn-metric-label">运行目标架构</span>
<div className="tn-metric-value">{info.platform.target}</div>
<div className="tn-metric-foot">
<small>({info.platform.arch || "x86_64"} {info.platform.libc || "glibc"})</small>
</div>
</Surface>
<Surface className="tn-metric-card">
<span className="tn-metric-label">更新守护方式</span>
<div className="tn-metric-value">
{info.strategy === "systemd" ? "系统守护进程" : "独立运行模式"}
</div>
<div className="tn-metric-foot">
<small>({info.strategy === "systemd" ? "systemd active" : "未激活"})</small>
</div>
</Surface>
<Surface className="tn-metric-card tn-metric-date">
<span className="tn-metric-label">最近一次检查</span>
<div className="tn-metric-value">
{info.checkedAt ? dateText(info.checkedAt, timezone) : "未检查"}
</div>
<div className="tn-metric-foot">
<small>源: git.awaioi.com (HTTPS)</small>
</div>
</Surface>
</div>
{/* Container 1: Release Details Card */}
{latest ? (
<Surface className="tn-ascii-release-container">
<div className="tn-ascii-release-head">
<h3 className="tn-ascii-release-title">发布版本详情</h3>
<Tag theme={latest.isNewer ? "primary" : "success"} variant="light-outline">
{latest.isNewer ? "发现新版本" : "已是最新版本"}
</Tag>
</div>
<div className="tn-ascii-info-grid">
<div className="tn-ascii-info-item">
<span className="tn-ascii-info-label">最新发布版本:</span>
<span className="tn-ascii-info-val">v{latest.version}</span>
</div>
<div className="tn-ascii-info-item">
<span className="tn-ascii-info-label">发布时间:</span>
<span className="tn-ascii-info-val">
{latest.publishedAt ? dateText(latest.publishedAt, timezone) : "官方通道发布"}
</span>
</div>
<div className="tn-ascii-info-item">
<span className="tn-ascii-info-label">安装包大小:</span>
<span className="tn-ascii-info-val">
{latest.assetSize ? bytesText(latest.assetSize) : "-"}
</span>
</div>
<div className="tn-ascii-info-item">
<span className="tn-ascii-info-label">运行环境适配:</span>
<span className="tn-ascii-info-val">
{latest.compatible ? "与当前系统完全匹配" : "环境暂未匹配"}
</span>
</div>
<div className="tn-ascii-info-item">
<span className="tn-ascii-info-label">安全完整性:</span>
<span className="tn-ascii-info-val">
{latest.integrityReady ? "SHA-256 + 签名已就绪" : "校验清单未就绪"}
</span>
</div>
<div className="tn-ascii-info-item">
<span className="tn-ascii-info-label">下载策略:</span>
<span className="tn-ascii-info-val">应用内流式直连 (零调度等待)</span>
</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
theme="primary"
size="large"
onClick={() => {
setConfirmReadyToDownload(true);
setShowUpgradeModal(true);
}}
disabled={actionBusy}
icon={<Download size={16} />}
>
立即升级至 v{latest.version}
</Button>
)}
{canApply && (
<Button
theme="primary"
size="large"
onClick={() => setShowUpgradeModal(true)}
disabled={actionBusy}
icon={<Zap size={16} />}
>
更新包已就绪,立即应用 (v{latest.version})
</Button>
)}
{hasActiveJob && (
<Button
theme="primary"
size="large"
onClick={() => setShowUpgradeModal(true)}
icon={<Play size={16} />}
>
查看当前升级进度
</Button>
)}
{!latest.isNewer && !hasActiveJob && (
<Button theme="default" variant="outline" size="large" onClick={() => void check()} icon={<RefreshCw size={15} />}>
检查新版本
</Button>
)}
</div>
</Surface>
) : (
<Surface className="tn-empty-surface">
<div className="tn-empty-content">
<CheckCircle2 size={32} className="text-success" />
<p>暂无待更新的版本信息,当前系统已是最新状态。</p>
<Button variant="outline" onClick={() => void check()} icon={<RefreshCw size={15} />}>
检查新版本
</Button>
</div>
</Surface>
)}
{latest && notes && (
<Surface className="tn-ascii-notes-container">
<div className="tn-ascii-notes-head">
<h3 className="tn-ascii-notes-title">本次版本更新说明</h3>
</div>
<div className="tn-ascii-notes-body">
<MarkdownNotes value={notes} />
</div>
</Surface>
)}
{/* Unmanaged warning if not systemd */}
{!info.configured && (
<div className="tn-update-explainer">
<div>
<strong>当前运行环境未启用后台自动更新服务</strong>
<p>
系统当前处于独立运行模式。若需开启在线一键更新,请配置系统后台更新守护进程。
</p>
</div>
</div>
)}
</>
)}
{/* Unified Single Upgrade Modal (800px width on desktop) */}
<Dialog
visible={showUpgradeModal}
header={`系统升级控制台 · v${latest?.version || ""}`}
className="tn-dialog-large"
width="820px"
footer={null}
onClose={() => setShowUpgradeModal(false)}
>
<div className="tn-stepper-surface">
{/* 4 Steps Horizontal (Exact ASCII: (1) 版本确认 (2) 下载更新包 (3) 校验与准备 (4) 重启恢复) */}
<div className="tn-stepper-wrap">
<Steps current={currentStep} theme="default">
<StepItem title="版本确认" content="安全预检与确认" />
<StepItem title="下载更新包" content={job?.status === "downloading" ? `下载中 ${progress}%` : "安全下载发布包"} />
<StepItem title="校验与准备" content={job?.status === "staged" ? "校验通过,就绪" : "签名与指纹比对"} />
<StepItem title="重启恢复" content={job?.status === "applying" ? "热重启探活中" : "快照备份与切换"} />
</Steps>
</div>
{/* 场景 0: 升级前预检与确认 */}
{!job && confirmReadyToDownload && latest && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title">确认升级至 TallyNote v{latest.version}</div>
<p className="text-secondary" style={{ margin: "0 0 12px 0", fontSize: "13.5px" }}>
系统即将启动全自动化升级流水线,升级全过程平滑、安全且具备自动回滚保护。
</p>
<div className="tn-upgrade-safety-tips">
<div>• 更新包将在后台独立下载并完成数字签名比对,不影响当前前台正常记账;</div>
<div>• 应用更新前系统将自动生成数据库与凭据附件的完整安全快照;</div>
<div>• 服务平滑重启预计仅需 15~30 秒,健康检查通过后页面自动刷新恢复。</div>
</div>
<div className="tn-modal-actions-bar">
<Button
variant="outline"
onClick={() => {
setConfirmReadyToDownload(false);
setShowUpgradeModal(false);
}}
disabled={actionBusy}
>
暂不升级
</Button>
<Button
theme="primary"
onClick={() => void startDownload()}
loading={actionBusy}
disabled={actionBusy}
icon={<Download size={16} />}
>
确认并开始下载 ({bytesText(latest.assetSize)})
</Button>
</div>
</div>
)}
{/* 场景 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-progress-stream"
role="progressbar"
aria-label="下载进度"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="tn-progress-stream-bar" style={{ width: `${progress}%` }} />
</div>
<div className="tn-modal-metrics-row">
<div className="tn-modal-metrics-item">
已下载:<strong>{bytesText(job.downloadedBytes)} / {bytesText(job.sizeBytes)} ({progress}%)</strong>
</div>
<div className="tn-modal-metrics-item">
传输速率:<strong>{speedText(job.downloadSpeedBps)}</strong>
</div>
<div className="tn-modal-metrics-item">
预估剩余:<strong>{etaSeconds !== null ? `${etaSeconds} 秒` : "计算中…"}</strong>
</div>
</div>
<div className="tn-modal-info-note">
[i] 更新包由应用进程直接流式拉取并自动比对 SHA-256 校验和,0 秒秒级启动,无需等待外部系统守护进程调度。
</div>
<div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => void cancelJob()} loading={cancelling} disabled={cancelling}>
取消更新
</Button>
</div>
</div>
)}
{/* 场景 B: 建立连接中 (queued) (Exact ASCII) */}
{job?.status === "queued" && (
<div className="tn-modal-card-box">
<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}>
取消
</Button>
</div>
</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 数字签名校验无误,准备就绪。
</div>
<div className="tn-upgrade-safety-tips" style={{ margin: "14px 0" }}>
<div>• 点击立即应用后,系统将自动创建数据库与附件的完整快照备份;</div>
<div>• 服务将进行平滑重启(预计 15~30 秒),完成后页面将自动重连并刷新;</div>
<div>• 升级过程具备原子切换与自愈保护,若健康检查异常将自动回退至当前版本。</div>
</div>
<div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
稍后手动应用
</Button>
<Button
theme="primary"
onClick={() => void startApply()}
loading={actionBusy}
disabled={actionBusy}
icon={<Zap size={16} />}
>
立即应用并重启
</Button>
</div>
</div>
)}
{/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */}
{(job?.status === "backing_up" || job?.status === "applying") && (
<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" }}>
预计恢复时间: <strong>{restartSeconds !== null ? `${restartSeconds} 秒` : "18 秒"}</strong> (倒计时中)
</div>
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px 0", lineHeight: 1.6 }}>
系统正在自动进行心跳探活检测,连接成功后将自动唤醒,请勿关闭或强制断开服务器。
</p>
<div className="tn-modal-actions-bar">
<Button disabled theme="default">
系统维护中...
</Button>
</div>
</div>
)}
{/* 场景 E: 升级完成 (completed) (Exact ASCII) */}
{job?.status === "completed" && (
<div className="tn-modal-card-box" style={{ background: "rgba(47, 125, 92, 0.04)", border: "1px solid rgba(47, 125, 92, 0.25)" }}>
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircle2 size={22} />
恭喜!系统已成功平滑升级至 v{latest?.version || info.currentVersion}。
</div>
<p style={{ fontSize: "13.5px", color: "var(--tn-text)", margin: "8px 0 16px" }}>
服务健康检查已全部通过,所有账目数据与发票凭证均完好无损。请点击下方按钮完成控制台刷新。
</p>
<div className="tn-modal-actions-bar">
<Button theme="success" size="large" onClick={() => window.location.reload()}>
刷新进入系统
</Button>
</div>
</div>
)}
{/* 场景 F: 失败 (failed) */}
{job?.status === "failed" && (
<div className="tn-modal-card-box" style={{ background: "rgba(227, 77, 89, 0.04)", border: "1px solid rgba(227, 77, 89, 0.25)" }}>
<div className="tn-modal-card-title" style={{ color: "#d54941", display: "flex", alignItems: "center", gap: 8 }}>
<ShieldAlert size={20} />
升级任务未完成
</div>
<p style={{ fontSize: "13.5px", color: "var(--tn-text)", margin: "8px 0 16px" }}>
{job.errorMessage || "升级任务出现异常,已自动保护并回退至原版本。数据完整无损。"}
</p>
<div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
关闭
</Button>
<Button theme="primary" onClick={() => void check()}>
重新检查
</Button>
</div>
</div>
)}
{/* Footer close button */}
{job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && (
<div className="tn-modal-footer-close">
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
关闭窗口(后台继续运行)
</Button>
</div>
)}
</div> </Dialog>
{/* Floating Mock Dock (Bottom-right, zero interference with main page) */}
{MOCK_PREVIEW_ENABLED && (
<aside className="tn-dev-mock-dock" aria-label="开发预览控制台">
<Terminal size={14} />
<strong>Mock:</strong>
<RadioGroup
variant="default-filled"
size="small"
value={mockScenario}
onChange={(value) => handleScenarioChange(value as "live" | MockScenario)}
>
<Radio.Button value="live">真实</Radio.Button>
{mockCatalog &&
Object.entries(mockCatalog).map(([key, item]) => (
<Radio.Button key={key} value={key}>
{item.title.split("(")[0]}
</Radio.Button>
))}
</RadioGroup>
</aside>
)}
</Page>
);
}