diff --git a/package.json b/package.json index af9b90f..54255d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.34", + "version": "1.1.35", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 5919671..5437c8e 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -1,37 +1,36 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import type { ReactNode } from "react"; +import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { - AlertCircle, - ArrowUpRight, - CheckCircle2, + RefreshCw, Download, + Zap, + CheckCircle2, + AlertCircle, + ShieldCheck, + ShieldAlert, + Server, + Cpu, + Clock, + ExternalLink, + ChevronRight, + Play, + RotateCcw, + X, FileCode, HardDrive, - History, - Layers, - PlayCircle, - RefreshCw, - Server, - ShieldAlert, - ShieldCheck, - Sparkles, - Zap, + Terminal, } from "lucide-react"; -import { Button, Dialog, Radio, RadioGroup, Steps, 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"; +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"; -// The interactive scenario console is a development-only aid. Keep its data -// behind a compile-time DEV guard so Vite can remove the dynamic import and -// the mock catalog from production/release bundles entirely. -const MOCK_PREVIEW_ENABLED = import.meta.env.DEV; - const { StepItem } = Steps; -type JobStatus = +// Enable mock preview in local development +const MOCK_PREVIEW_ENABLED = import.meta.env.DEV; + +export type JobStatus = | "queued" | "downloading" | "verifying" @@ -42,60 +41,63 @@ type JobStatus = | "failed" | "cancelled"; -type UpdateJob = { +export interface UpdateJob { id: string; - operation?: "download" | "apply"; + operation: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; downloadedBytes?: number | null; - downloadStartedAt?: number | null; + downloadStartedAt?: number | string | null; downloadSpeedBps?: number | null; errorMessage?: string | null; - createdAt?: number; - updatedAt?: number; - completedAt?: number | null; + createdAt?: number | string | null; + updatedAt?: number | string | null; + completedAt?: number | string | null; applyQueuedAt?: number | string | null; - restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null; -}; + restartWindowSeconds?: number | null; +} -type LatestRelease = { +export interface LatestRelease { version: string; tagName?: string; releaseName?: string; publishedAt?: string; + isNewer: boolean; + notes?: string; + releaseNotes?: string; + body?: string; + releaseUrl?: string; compatible: boolean; integrityReady: boolean; - signatureReady: boolean; - isNewer: boolean; - assetName?: string; + assetName: string; assetSize?: number; - notes?: string | null; - releaseNotes?: string | null; - body?: string | null; - htmlUrl?: string | null; -}; +} -type UpdateInfo = { +export interface UpdateInfo { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; - platform: { target: string; os: string; arch: string }; - checkedAt: number; + 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", - "staged", "backing_up", "applying", ]); @@ -109,14 +111,14 @@ const pollableStatuses = new Set([ ]); const statusLabels: Record = { - queued: "等待系统调度", + queued: "排队调度中", downloading: "下载更新包中", verifying: "校验发布包完整性", staged: "更新包就绪,待应用", backing_up: "备份数据与附件快照", applying: "原子切换与重启服务", - completed: "更新成功", - failed: "更新失败", + completed: "升级成功", + failed: "升级失败", cancelled: "已取消", }; @@ -133,36 +135,27 @@ function notesFor(latest?: LatestRelease | null): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -/** - * Render the small, predictable Markdown subset used by release notes. - * - * Release notes are already reduced to text on the server. Keeping this - * renderer text-only (no HTML parsing/innerHTML) means a malformed or - * untrusted note can never inject markup into the update console. - */ function inlineMarkdown(value: string): ReactNode[] { - const tokens = /(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^\]\n]+\]\((?:https?:\/\/)[^)\s]+\))/g; + 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("**")) { - result.push({token.slice(2, -2)}); - } else if (token.startsWith("`")) { - result.push({token.slice(1, -1)}); - } else { - const link = /^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/.exec(token); - if (link) { - result.push( - - {link[1]} - , - ); - } else { - result.push(token); - } + 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; } @@ -171,82 +164,59 @@ function inlineMarkdown(value: string): ReactNode[] { } function MarkdownNotes({ value, compact = false }: { value: string; compact?: boolean }) { - const lines = value.replace(/\r\n?/g, "\n").split("\n"); - const blocks: ReactNode[] = []; - let paragraph: string[] = []; - let list: string[] = []; - let code: string[] | null = null; + const lines = value.split(/\r?\n/); + const elements: ReactNode[] = []; + let listItems: ReactNode[] = []; + let listKey = 0; - const flushParagraph = () => { - if (!paragraph.length) return; - blocks.push(

{inlineMarkdown(paragraph.join(" ").trim())}

); - paragraph = []; - }; const flushList = () => { - if (!list.length) return; - blocks.push( -
    - {list.map((item, index) =>
  • {inlineMarkdown(item)}
  • )} -
, - ); - list = []; + if (!listItems.length) return; + elements.push(
    {listItems}
); + listItems = []; }; - lines.forEach((line, index) => { - if (code) { - if (line.trim().startsWith("```")) { - blocks.push(
{code.join("\n")}
); - code = null; - } else code.push(line); - return; - } - if (line.trim().startsWith("```")) { - flushParagraph(); + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] ?? ""; + const trimmed = raw.trim(); + if (!trimmed) { flushList(); - code = []; - return; + continue; } - const heading = /^(#{1,3})\s+(.+)$/.exec(line.trim()); - if (heading) { - flushParagraph(); + if (trimmed.startsWith("# ")) { flushList(); - const level = heading[1]!.length; - const Heading = `h${level + 2}` as "h3" | "h4" | "h5"; - blocks.push({inlineMarkdown(heading[2]!)}); - return; - } - const item = /^[-*+]\s+(.+)$/.exec(line.trim()); - if (item) { - flushParagraph(); - list.push(item[1]!); - return; - } - if (!line.trim()) { - flushParagraph(); + elements.push(

{inlineMarkdown(trimmed.slice(2))}

); + } else if (trimmed.startsWith("## ")) { flushList(); - return; + 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(); - paragraph.push(line.trim()); - }); - const unfinishedCode = code as string[] | null; - if (unfinishedCode) blocks.push(
    {unfinishedCode.join("\n")}
    ); - flushParagraph(); + } flushList(); - return
    {blocks}
    ; + return
    {elements}
    ; } -function bytesText(value: number | null | undefined): string { - if (!Number.isFinite(value) || !value || value < 0) return "0 B"; - if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`; - if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`; - if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`; - return `${Math.round(value)} B`; +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(value: number | null | undefined): string { - return value && value > 0 ? `${bytesText(value)}/s` : "计算中…"; +function speedText(bps?: number | null): string { + if (!bps || bps <= 0) return "-- KB/s"; + return `${bytesText(bps)}/s`; } export default function UpdatePage({ @@ -254,9 +224,9 @@ export default function UpdatePage({ notify, }: { timezone?: string; - notify?: Notify; + notify?: (msg: string, type?: "success" | "info" | "error") => void; }) { - // Mock Mode switcher: "live" or specific scenario key + // Mock mode switcher for local developer preview const [mockScenario, setMockScenario] = useState<"live" | MockScenario>("live"); const mockTimer = useRef(null); const [mockCatalog, setMockCatalog] = useState | null>(null); @@ -268,28 +238,14 @@ export default function UpdatePage({ 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 [showNotesDialog, setShowNotesDialog] = useState(false); - const [showPipelineDialog, setShowPipelineDialog] = useState(false); - const [cancelling, setCancelling] = useState(false); - const cancelJob = async () => { - if (cancelling) return; - setCancelling(true); - try { - await api("/api/update/cancel", { method: "POST", body: JSON.stringify({ jobId: job?.id }) }); - notify?.("已取消更新任务", "success"); - setShowPipelineDialog(false); - await load(); - } catch (e) { - notify?.((e as Error).message, "error"); - } finally { - setCancelling(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); @@ -354,13 +310,17 @@ export default function UpdatePage({ else setLoading(false); }, [isMock]); - // The status endpoint intentionally hides terminal failures/cancellations. - // Keep this guard in the UI as well so a stale response from an older - // server cannot turn a fresh page visit into a false failure state. + // 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 && activeStatuses.has(job.status)) setShowPipelineDialog(true); - }, [job?.id, job?.status]); + 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 = @@ -380,7 +340,7 @@ export default function UpdatePage({ return () => window.clearInterval(timer); }, [restartAt]); - // Live polling effect + // Live polling effect: only poll when active job exists useEffect(() => { if (isMock) return; const shouldPoll = Boolean( @@ -404,7 +364,7 @@ export default function UpdatePage({ failures = 0; if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; - notify?.("服务已恢复,更新状态已刷新", "success"); + notify?.("服务连接正常,更新状态已同步", "success"); } disconnected.current = false; setPollError(""); @@ -412,7 +372,7 @@ export default function UpdatePage({ if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); - notify?.("更新完成,请重新加载页面", "success"); + notify?.("系统升级完成,请刷新页面", "success"); } if ( pollableStatuses.has(result.job.status) || @@ -426,11 +386,11 @@ export default function UpdatePage({ disconnected.current = true; recoveredNotice.current = false; setPollError( - `服务暂时不可用${ - restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试" - }。更新任务仍在后台运行。` + `服务正在平滑重启${ + restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面正在自动探活重试" + }。升级任务仍在后台安全执行。` ); - schedule(Math.min(1500 * 2 ** Math.min(failures, 3), 12_000)); + schedule(Math.min(1500 * 2 ** Math.min(failures, 3), 10_000)); } }; void poll(); @@ -438,14 +398,13 @@ export default function UpdatePage({ disposed = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [isMock, job?.id, job?.status, job?.operation, notify]); + }, [isMock, job?.id, job?.status, job?.operation, notify, restartSeconds]); // Check update handler const check = async () => { if (isMock) { setChecking(true); - mockTimer.current = window.setTimeout(() => { - mockTimer.current = null; + window.setTimeout(() => { setChecking(false); notify?.("Mock:检查完成,已是最新配置状态", "success"); }, 600); @@ -456,132 +415,128 @@ export default function UpdatePage({ setChecking(true); setError(""); try { - const result = await api & { job?: UpdateJob | null }>("/api/update/check", { - method: "POST", - body: "{}", - }); - setLiveInfo((current) => ({ - ...result, - job: result.job ?? (current?.job && activeStatuses.has(current.job.status) ? 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); + setLiveInfo(await api("/api/update/check", { method: "POST" })); + notify?.("版本检查完成", "info"); + } catch (e) { + setError((e as Error).message); } finally { - checkInFlight.current = false; setChecking(false); + checkInFlight.current = false; } }; - // Submit download or apply action - const submitAction = async () => { - if (!confirmVersion) return; - - if (isMock) { - setActionBusy(true); - mockTimer.current = window.setTimeout(() => { - mockTimer.current = null; - setActionBusy(false); - setConfirmVersion(null); - setShowPipelineDialog(true); - if (confirmAction === "download") { - handleScenarioChange("downloading_30"); - notify?.("Mock:开始模拟下载,状态已流转至 [下载中]", "info"); - } else { - handleScenarioChange("applying"); - notify?.("Mock:开始模拟平滑重启,状态已流转至 [应用中/倒计时]", "info"); - } - }, 700); - return; + // 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); } + }; - if (actionInFlight.current) return; - actionInFlight.current = true; + // Start download action + const startDownload = async () => { + if (!latest) return; 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, { + const response = await api<{ job: UpdateJob }>("/api/update/download", { method: "POST", - body: JSON.stringify(body), + body: JSON.stringify({ version: latest.version }), }); - setConfirmVersion(null); - setReloadReady(false); - setLiveInfo((current) => (current ? { ...current, job: result.job } : current)); - setShowPipelineDialog(true); - notify?.( - confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", - "info" - ); - } catch (caught) { - setError((caught as Error).message); + 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 { - actionInFlight.current = false; setActionBusy(false); } }; const latest = info?.latest; - const hasActiveJob = Boolean(job && activeStatuses.has(job.status)); - const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version); + const notes = notesFor(latest); + const canDownload = Boolean( info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && - !hasActiveJob && - !sameCompleted + (!job || job.status === "failed" || job.status === "cancelled") ); + const canApply = Boolean( info?.strategy === "systemd" && - job?.status === "staged" && - job.operation === "download" && - latest && - job.version === latest.version + job && + job.status === "staged" && + job.operation === "download" ); - const notes = notesFor(latest); + const hasActiveJob = Boolean( + job && activeStatuses.has(job.status) && job.status !== "staged" + ); - // Download progress computation + // 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 === "staged" || job.status === "backing_up" || job.status === "applying" || job.status === "completed") { - return 100; - } - if (job.status === "downloading" && job.sizeBytes && job.downloadedBytes) { - return Math.min(100, Math.max(1, Math.round((job.downloadedBytes / job.sizeBytes) * 100))); + 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 active step for Stepper (0: Check -> 1: Download -> 2: Verify -> 3: Apply) - const currentStep = useMemo(() => { - if (!latest?.isNewer && (!job || job.status === "completed")) return 0; - if (job?.status === "downloading") return 1; - if (job?.status === "verifying") return 2; - if (job?.status === "staged" || job?.status === "backing_up" || job?.status === "applying") return 3; - if (job?.status === "completed") return 4; - return 1; - }, [latest?.isNewer, job]); - - // Estimated remaining time for download + // Calculate ETA seconds const etaSeconds = useMemo(() => { if ( job?.status === "downloading" && @@ -590,9 +545,8 @@ export default function UpdatePage({ job.downloadSpeedBps && job.downloadSpeedBps > 0 ) { - const remainingBytes = job.sizeBytes - job.downloadedBytes; - if (remainingBytes <= 0) return 0; - return Math.ceil(remainingBytes / job.downloadSpeedBps); + const remainingBytes = Math.max(0, job.sizeBytes - job.downloadedBytes); + return Math.max(1, Math.ceil(remainingBytes / job.downloadSpeedBps)); } return null; }, [job]); @@ -604,15 +558,14 @@ export default function UpdatePage({ subtitle="管理系统版本升级、更新包完整性校验与安全热重启" actions={
    - {job?.status === "queued" && ( + {hasActiveJob && ( )} - {canDownload && ( - - )} - {canApply && ( - - )} {reloadReady && ( )}
    } + className="tn-update-page" > - {/* Local Mock Preview Console - Interactive controller */} - {MOCK_PREVIEW_ENABLED && mockCatalog && -
    -
    - - 前端交互预览控制台 (Mock Preview Console) - - {isMock ? "Mock 模拟预览中" : "真实后端模式"} - -
    - - 无需真实更新或升级,点击下方场景可立即体验 10 种不同生命周期与实时流光效果 - -
    -
    - - {Object.entries(mockCatalog).map(([key, item]) => ( - - ))} -
    -
    } + - {error && void load()} />} - {pollError && !error && ( -
    + {/* Global alert / error */} + {error && ( +
    + {error} + +
    + )} + + {pollError && ( +
    + {pollError}
    )} - {loading && !info &&
    正在加载更新状态…
    } - - {info && ( + {loading && !info ? ( + + +

    正在读取系统更新服务状态…

    +
    + ) : !info ? ( + +
    + +

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

    + +
    +
    + ) : ( <> - {/* Top 4 Metric Cards */} -
    + {/* 4 Metric Cards (System Overview) */} +
    -
    - 当前运行版本 -
    v{info.currentVersion}
    -
    - {latest?.isNewer ? ( - 可更新至 v{latest.version} - ) : ( - 已是最新版本 - )} -
    + 当前运行版本 +
    v{info.currentVersion}
    +
    + {latest?.isNewer ? ( + 可更新至 v{latest.version} + ) : ( + 已是最新版本 + )}
    -
    - 运行目标架构 -
    {info.platform.target}
    + 运行目标架构 +
    {info.platform.target}
    +
    + ({info.platform.arch || "x86_64"} {info.platform.libc || "glibc"})
    -
    - 更新守护方式 -
    - {info.strategy === "systemd" ? "系统守护进程" : "独立运行模式"} -
    + 更新守护方式 +
    + {info.strategy === "systemd" ? "系统守护进程" : "独立运行模式"} +
    +
    + ({info.strategy === "systemd" ? "systemd active" : "未激活"})
    - -
    - 最近一次检查 -
    - {info.checkedAt ? dateText(info.checkedAt, timezone) : "未检查"} -
    -
    - 数据源:官方发布渠道 -
    + + 最近一次检查 +
    + {info.checkedAt ? dateText(info.checkedAt, timezone) : "未检查"} +
    +
    + 数据源: 官方发布渠道
    - {/* Stepper Process Dialog */} - setShowPipelineDialog(false)} - onClose={() => setShowPipelineDialog(false)} - > -
    -
    -
    - - 标准升级流程:获取版本信息 → 校验完整性 → 下载更新包 → 自动应用并安全重启 - + {/* Container 1: Release Details Card */} + {latest ? ( + +
    +

    发布版本详情

    + + {latest.isNewer ? "发现新版本" : "已是最新版本"} + +
    + +
    +
    + 最新发布版本: + v{latest.version}
    - {job && ( - + 发布时间: + + {latest.publishedAt ? dateText(latest.publishedAt, timezone) : "官方通道发布"} + +
    +
    + 安装包大小: + + {latest.assetSize ? bytesText(latest.assetSize) : "-"} + +
    + +
    + 运行环境适配: + + {latest.compatible ? "与当前系统完全匹配" : "环境暂未匹配"} + +
    +
    + 安全完整性: + + {latest.integrityReady ? "SHA-256 + 签名已就绪" : "校验清单未就绪"} + +
    +
    + 下载策略: + 后台静默传输 +
    +
    + +
    + {canDownload && ( + + )} + {canApply && ( + + )} + {hasActiveJob && ( + + )} + {!latest.isNewer && !hasActiveJob && ( + )}
    - -
    - - - - - - + + ) : ( + +
    + +

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

    +
    +
    + )} - {/* Active Download Progress Bar & Speed */} - {job && activeStatuses.has(job.status) && ( -
    -
    -
    - - {statusLabels[job.status as JobStatus] || job.status} - {job.status === "downloading" && ( - - ({bytesText(job.downloadedBytes)} / {bytesText(job.sizeBytes)} · {progress}%) - - )} -
    - {job.status === "downloading" && ( -
    - - {etaSeconds !== null && ( - 预估剩余 {etaSeconds} 秒 - )} -
    - )} -
    - - {/* Progress Bar */} -
    -
    -
    - - {/* Status-specific descriptions */} -
    - {job.status === "queued" && ( -
    -
    - 正在等待后台系统更新守护进程认领任务… - -
    - - 若系统超过 20 秒仍未响应,通常是后台守护服务尚未就绪,您可以点击取消排队。 - -
    - )} - {job.status === "downloading" && ( - 正在从官方渠道下载更新安装包,后台安全传输中,不影响当前系统的正常使用。 - )} - {job.status === "verifying" && ( - 更新包已下载完成,正在进行防篡改完整性核验与数字签名认证… - )} - {job.status === "staged" && ( - - - )} - {job.status === "backing_up" && ( - 正在为系统数据与附件凭据创建安全快照备份,确保升级过程数据无损… - )} - {job.status === "applying" && ( -
    -
    - -
    -
    - 系统正在安全重启并检验服务可用性… - {restartSeconds !== null && ( -
    - 预计 {restartSeconds} 秒后自动恢复连接,系统将自动刷新页面,请稍候。 -
    - )} -
    -
    - )} -
    + {latest && notes && ( + +
    +

    本次版本更新说明

    - )} - - {/* Error banner */} - {job?.status === "failed" && job.errorMessage && ( -
    - - {job.errorMessage} -
    - )} - - {/* Success banner */} - {job?.status === "completed" && ( -
    - - 系统已成功升级至最新版本,数据与附件均已完整保留。 -
    - )} -
    -
    - - {/* Release Details Section */} - {latest ? ( - -
    -
    -
    - 发布版本 - - {latest.isNewer ? "发现新版本" : "已是最新版本"} - -
    -

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

    - {latest.publishedAt && ( - - 发布于:{dateText(Date.parse(latest.publishedAt), timezone)} - - )} -
    -
    - - {/* Integrity & compatibility facts grid */} -
    -
    - 目标平台发布包 - {latest.compatible ? latest.assetName : "无匹配架构包"} - - {latest.compatible ? "与当前系统运行环境匹配" : "当前系统环境暂不支持此更新包"} - -
    - -
    - 安全完整性核验 - - {latest.integrityReady ? "SHA-256 + 签名就绪" : "未通过或缺少清单"} - - - {latest.integrityReady ? "官方发布通道受保护资产" : "缺少完整校验清单"} - -
    - -
    - 发布包体积 - - {latest.assetSize ? bytesText(latest.assetSize) : "-"} - - 完整生产环境更新程序包 -
    -
    - - {/* Action bar inside release card */} -
    - {canDownload && ( - - )} - {canApply && ( - - )} - {reloadReady && ( - - )} -
    -
    - ) : ( - -
    - -

    暂无更新版本信息,可点击右上角“检查新版本”获取最新状态。

    +
    +
    )} - {latest && notes && ( - -
    -
    - 更新日志 -

    本次版本更新内容

    -
    - -
    -
    -
    - -
    -
    -
    - )} - - {/* Manual source warning if not systemd */} + {/* Unmanaged warning if not systemd */} {!info.configured && (
    @@ -1047,67 +793,242 @@ export default function UpdatePage({ )} - {/* Confirmation Dialog */} + {/* Unified Single Upgrade Modal (800px width on desktop) */} { - if (!actionBusy) setConfirmVersion(null); - }} - onConfirm={() => void submitAction()} - onCancel={() => { - if (!actionBusy) setConfirmVersion(null); - }} - > -
    - {confirmAction === "download" ? ( - <> -

    - 即将从官方渠道下载 v{confirmVersion} 版本安装包。 -

    -

    - 安装包将在后台静默下载并完成安全性校验,期间不影响系统的正常使用。 -

    - - ) : ( - <> -

    - 即将应用 v{confirmVersion} 版本更新包并升级系统。 -

    -
    -
    • 更新前将自动创建数据与凭据附件快照,保障数据资产安全;
    -
    • 服务将进行平滑重启(预计 15~30 秒),更新完成后页面将自动刷新恢复;
    -
    • 系统具备自动容灾保护,若升级验证异常将自动回退至当前版本。
    -
    - - )} -
    -
    - - {/* Full Release Notes Dialog */} - setShowNotesDialog(false)} - onClose={() => setShowNotesDialog(false)} + footer={null} + onClose={() => setShowUpgradeModal(false)} > -
    - {notes ? :

    暂无更新日志。

    } -
    -
    +
    + {/* 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" && ( +
    +
    正在从官方发布渠道下载更新包...
    +
    +
    +
    + +
    +
    + 已下载:{bytesText(job.downloadedBytes)} / {bytesText(job.sizeBytes)} ({progress}%) +
    +
    + 传输速率:{speedText(job.downloadSpeedBps)} +
    +
    + 预估剩余:{etaSeconds !== null ? `${etaSeconds} 秒` : "计算中…"} +
    +
    + +
    + [i] 更新包在后台独立下载,数据经 TLS 加密传输,不影响当前系统的正常记账与导出操作。 +
    + +
    + +
    +
    + )} + + {/* 场景 B: 任务调度中 (queued) (Exact ASCII) */} + {job?.status === "queued" && ( +
    +
    + + 正在等待后台系统守护进程认领任务... +
    +
    + 已等待: {queuedSeconds} 秒 (超过 20 秒未响应将自动超时释放,避免系统死锁) +
    +

    + 若长时间未开始,通常为后台守护服务未激活,您可以点击下方按钮立即取消排队。 +

    +
    + +
    +
    + )} + + {/* 场景 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 && ( + + )} ); } diff --git a/web-next/src/styles/theme.css b/web-next/src/styles/theme.css index dfb2475..6e50c87 100644 --- a/web-next/src/styles/theme.css +++ b/web-next/src/styles/theme.css @@ -679,35 +679,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { gap: 14px; margin-bottom: 16px; } -.tn-metric-card .t-card__body { - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 16px 18px; - min-height: 104px; -} -.tn-metric-content { - width: 100%; -} -.tn-metric-label { - display: block; - font-size: 12px; - color: var(--tn-text-secondary); - margin-bottom: 4px; -} -.tn-metric-value { - font-size: 20px; - font-weight: 700; - color: var(--tn-navy-900); - font-family: "Plus Jakarta Sans Variable", sans-serif; - font-variant-numeric: tabular-nums; - line-height: 1.2; -} -.tn-metric-foot { - margin-top: 6px; - font-size: 12px; - color: var(--tn-text-muted); -} + /* Stepper Surface */ .tn-stepper-surface { @@ -1147,3 +1119,351 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { justify-content: flex-start; } } + + +/* System Update Console & Phase Styles */ +.tn-release-version-title { + margin: 4px 0 8px; + font-size: 20px; + font-weight: 700; + color: var(--tn-navy-900); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.tn-release-nickname { + font-size: 15px; + font-weight: 500; + color: var(--tn-text-secondary); +} +.tn-release-meta-row { + display: flex; + align-items: center; + gap: 16px; + font-size: 13px; + color: var(--tn-text-secondary); + flex-wrap: wrap; +} +.tn-meta-item { + display: inline-flex; + align-items: center; + gap: 6px; +} +.tn-meta-link { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--td-brand-color); + text-decoration: none; +} +.tn-meta-link:hover { + text-decoration: underline; +} + +/* In-Page Release Notes Container */ +.tn-release-notes-surface { + margin-bottom: 16px; + padding: 20px; +} +.tn-release-notes-box { + margin: 0; + padding: 16px 20px; + background: var(--tn-surface-alt, #f8fafc); + border: 1px solid var(--tn-border); + border-left: 3px solid var(--td-brand-color); + border-radius: 4px; +} +.tn-release-notes-content { + font-size: 13.5px; + color: var(--tn-text); + line-height: 1.7; + max-height: 380px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--td-brand-color-3) transparent; +} + +/* Upgrade Modal Phases */ +.tn-upgrade-phase-confirm { + padding: 12px 0; +} +.tn-confirm-phase-head { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} +.tn-confirm-phase-head h4 { + margin: 0 0 6px; + font-size: 17px; + font-weight: 600; + color: var(--tn-navy-900); +} +.tn-confirm-phase-head p { + margin: 0; + font-size: 13.5px; +} +.tn-upgrade-safety-tips { + background: rgba(23, 92, 211, 0.04); + border: 1px solid rgba(23, 92, 211, 0.15); + border-radius: 4px; + padding: 14px 16px; + font-size: 13px; + line-height: 1.8; + color: var(--tn-navy-900); + margin-bottom: 20px; +} +.tn-modal-actions-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; +} +.tn-staged-apply-box { + padding: 12px 0 4px; +} +.tn-staged-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 15px; +} +.tn-upgrade-phase-completed { + padding: 24px 0 12px; +} +.tn-completed-banner { + display: flex; + align-items: center; + gap: 18px; + background: rgba(47, 125, 92, 0.06); + border: 1px solid rgba(47, 125, 92, 0.2); + border-radius: 4px; + padding: 18px 22px; + margin-bottom: 16px; +} +.tn-completed-banner h4 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; + color: #1e6043; +} +.tn-completed-banner p { + margin: 0; + font-size: 13.5px; + color: var(--tn-text); +} +.tn-upgrade-phase-failed { + padding: 16px 0; +} +.tn-modal-footer-close { + display: flex; + justify-content: center; + margin-top: 18px; + border-top: 1px dashed var(--tn-border); + padding-top: 14px; +} + + +/* ============================================================ + ASCII Layout Accurate Styles for Update Center + ============================================================ */ + +/* 4 Metric Cards Grid - Strictly Fixed Height & Zero Layout Shift */ +.tn-metrics-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 20px; + align-items: start; +} +.tn-metric-card { + height: 104px !important; + min-height: 104px !important; + max-height: 104px !important; + box-sizing: border-box !important; + padding: 0 !important; + background: #ffffff; + border: 1px solid var(--tn-border); + border-radius: 4px; + overflow: hidden !important; +} +.tn-metric-card .t-card__body { + height: 100% !important; + box-sizing: border-box !important; + display: flex !important; + flex-direction: column !important; + justify-content: space-between !important; + padding: 14px 18px !important; +} +.tn-metric-label { + display: block; + font-size: 12.5px; + color: var(--tn-text-secondary); + line-height: 1; + margin: 0; +} +.tn-metric-value { + font-size: 20px; + font-weight: 700; + color: var(--tn-navy-900); + line-height: 1.2; + margin: 4px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} +.tn-metric-card.tn-metric-date .tn-metric-value { + font-size: 16px !important; + font-weight: 600 !important; + line-height: 1.3 !important; +} +.tn-metric-foot { + font-size: 12px; + color: var(--tn-text-secondary); + height: 20px !important; + min-height: 20px !important; + max-height: 20px !important; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + margin: 0; +} + +/* Container 5: Release Details (Exact ASCII: 2 rows, 3 columns) */ +.tn-ascii-release-container { + padding: 22px 24px; + margin-bottom: 20px; + background: #ffffff; + border: 1px solid var(--tn-border); + border-radius: 4px; +} +.tn-ascii-release-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; + border-bottom: 1px solid #edf1f5; + padding-bottom: 12px; +} +.tn-ascii-release-title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--tn-navy-900); +} +.tn-ascii-info-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px 24px; + margin-bottom: 24px; +} +.tn-ascii-info-item { + font-size: 13.5px; + color: var(--tn-text); + line-height: 1.6; +} +.tn-ascii-info-label { + color: var(--tn-text-secondary); + margin-right: 6px; +} +.tn-ascii-info-val { + font-weight: 600; + color: var(--tn-navy-900); +} +.tn-ascii-release-actions { + display: flex; + justify-content: flex-end; + border-top: 1px solid #edf1f5; + padding-top: 18px; +} + +/* Container 6: In-page Release Notes Card */ +.tn-ascii-notes-container { + padding: 22px 24px; + margin-bottom: 24px; + background: #ffffff; + border: 1px solid var(--tn-border); + border-radius: 4px; +} +.tn-ascii-notes-head { + margin-bottom: 16px; + border-bottom: 1px solid #edf1f5; + padding-bottom: 12px; +} +.tn-ascii-notes-title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--tn-navy-900); +} +.tn-ascii-notes-body { + font-size: 13.5px; + color: var(--tn-text); + line-height: 1.75; + max-height: 420px; + overflow-y: auto; + padding-right: 8px; +} + +/* Modal Inner Card matching ASCII */ +.tn-modal-card-box { + background: #f8fafc; + border: 1px solid var(--tn-border); + border-radius: 4px; + padding: 20px 24px; + margin: 16px 0; +} +.tn-modal-card-title { + font-size: 15px; + font-weight: 600; + color: var(--tn-navy-900); + margin-bottom: 14px; +} +.tn-modal-metrics-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 14px; + font-size: 13px; + color: var(--tn-text-secondary); + padding-top: 12px; + border-top: 1px solid #eef2f6; +} +.tn-modal-metrics-item strong { + color: var(--tn-navy-900); + margin-left: 4px; +} +.tn-modal-info-note { + font-size: 12.5px; + color: var(--tn-text-secondary); + line-height: 1.6; + margin: 12px 0 16px; + display: flex; + align-items: center; + gap: 6px; +} + +/* Compact Floating Mock Switcher at bottom-right instead of giant top banner */ +.tn-dev-mock-dock { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 999; + background: #ffffff; + border: 1px solid var(--td-brand-color); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + border-radius: 4px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; +}