1060 lines
40 KiB
TypeScript
1060 lines
40 KiB
TypeScript
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>
|
||
);
|
||
}
|