Files
kaidi/frontend/src/pages/governance/SystemUpdatePage.vue
T
2026-08-19 08:27:08 +08:00

1432 lines
43 KiB
Vue
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.
<template>
<div class="system-update-page">
<h1 class="page-title-sr-only">系统更新</h1>
<section class="update-panel" aria-label="系统版本与更新操作">
<header class="update-toolbar" aria-label="系统更新工具栏">
<div class="update-toolbar__copy">
<div class="update-toolbar__title">系统更新</div>
<p>获取版本、下载校验并安全重启应用</p>
</div>
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<div class="update-heading">
<div class="version-grid">
<div class="version-item">
<span>当前版本</span>
<strong>{{ updateStatus?.currentVersion || '-' }}</strong>
</div>
<div class="version-item">
<span>最新版本</span>
<strong>{{ updateStatus?.latestVersion || '尚未获取' }}</strong>
</div>
<div class="version-item">
<span>更新状态</span>
<t-tag :theme="updateStateTheme" variant="light">{{ updateStateLabel }}</t-tag>
</div>
</div>
<div class="update-actions">
<t-button
variant="outline"
:loading="checkingUpdate"
:disabled="!canCheckUpdate || checkingUpdate || downloadingUpdate || installingUpdate"
@click="checkLatest"
>
<template #icon><t-icon name="refresh" /></template>
获取版本
</t-button>
<t-button
v-if="canDownloadUpdate"
theme="primary"
:loading="downloadingUpdate"
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@click="downloadNow"
>
<template #icon><t-icon name="cloud-download" /></template>
立即更新
</t-button>
<t-button
v-if="canInstall && !updateDialogVisible"
theme="primary"
:loading="installingUpdate"
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@click="installNow"
>
<template #icon><t-icon name="poweroff" /></template>
立即更新并重启
</t-button>
</div>
</div>
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
<t-step-item title="获取版本" content="检查最新发布" />
<t-step-item title="下载更新" content="下载并验证签名" />
<t-step-item title="安装重启" content="备份并切换版本" />
<t-step-item title="完成更新" content="健康检查后刷新" />
</t-steps>
<div v-if="showDownloadProgress && !updateDialogVisible" class="download-progress" aria-live="polite">
<div class="download-progress__heading">
<span>更新包下载进度</span>
<strong>{{ updateProgress }}%</strong>
</div>
<t-progress :percentage="updateProgress" :label="false" />
<div class="download-progress__meta">
<span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
<span>{{ downloadSpeedLabel }}</span>
</div>
</div>
<template v-if="!updateDialogVisible">
<t-alert v-if="statusError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ statusError }}</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link>
</div>
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
<div class="update-status-message">
<strong>{{ updateStateLabel }}</strong>
<span>{{ updateStatus?.message || '正在读取系统版本状态' }}</span>
<small v-if="updateActionHint">{{ updateActionHint }}</small>
</div>
</t-alert>
</template>
<div v-if="updateStatus?.releaseNotes" class="release-notes">
<span>版本说明</span>
<p>{{ updateStatus.releaseNotes }}</p>
</div>
</section>
<section class="history-panel" aria-label="历史更新记录">
<div class="section-heading">
<div>
<h2>历史更新记录</h2>
<p>按版本展示签名 Release 提供的更新日志与最终结果。</p>
</div>
<span>已记录 {{ historyTimeline.length }} 个版本</span>
</div>
<t-alert v-if="historyError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ historyError }}</span>
<t-link theme="primary" @click="() => loadHistory()">重新加载</t-link>
</div>
</t-alert>
<div v-if="historyLoading" class="history-loading" aria-live="polite">
<t-loading text="正在加载历史更新记录" />
</div>
<div v-else-if="historyTimeline.length" class="update-timeline" aria-label="按版本排列的更新时间线">
<article
v-for="item in historyTimeline"
:key="item.key"
class="update-timeline__item"
:data-version="item.version"
>
<div class="update-timeline__rail" aria-hidden="true">
<span class="update-timeline__marker" :class="`update-timeline__marker--${resultTheme(item.resultCode)}`">
<t-icon :name="item.resultCode === 'SUCCESS' ? 'check' : 'error-circle'" />
</span>
</div>
<div class="timeline-card">
<div class="timeline-card__header">
<div class="timeline-card__identity">
<strong>{{ item.version }}</strong>
<span>{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
</div>
<t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag>
</div>
<div class="timeline-card__notes">
<span>更新日志</span>
<p>{{ item.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
</div>
<p class="timeline-card__reason">{{ item.reason || '更新结果已记录。' }}</p>
<div class="timeline-card__published">
<span v-if="item.publishedAt">发布时间:{{ formatDateTime(item.publishedAt) }}</span>
<span>记录时间:{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
</div>
<div class="timeline-card__footer">
<span>目标版本 {{ item.version }}</span>
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看更新日志</t-button>
</div>
</div>
</article>
</div>
<t-empty v-else description="暂无系统更新记录" />
</section>
<t-dialog
v-model:visible="updateDialogVisible"
aria-label="系统更新"
header="系统更新"
width="min(720px, calc(100vw - 32px))"
:footer="false"
:close-btn="dialogClosable"
:close-on-overlay-click="false"
:close-on-esc-keydown="dialogClosable"
>
<div class="update-dialog" role="dialog" aria-label="系统更新" aria-live="polite" aria-modal="true">
<t-steps :current="updateStep" :layout="stepLayout" status="process">
<t-step-item title="获取版本" content="确认目标版本" />
<t-step-item title="下载校验" content="下载并验证签名" />
<t-step-item title="安装重启" content="备份、切换、重启" />
<t-step-item title="完成" content="健康检查与刷新" />
</t-steps>
<div class="dialog-status-line">
<t-tag :theme="updateStateTheme" variant="light">{{ updateStateLabel }}</t-tag>
<span>{{ updateStatus?.message || '等待更新服务返回状态' }}</span>
</div>
<t-alert v-if="statusError" theme="error" :close-btn="false">{{ statusError }}</t-alert>
<t-alert v-else-if="installRequestUncertain" theme="warning" :close-btn="false">
安装请求连接中断,正在查询服务器确认是否已受理,请勿重复提交。
</t-alert>
<div v-if="showDownloadProgress" class="download-progress download-progress--dialog">
<div class="download-progress__heading">
<span>正在下载 {{ updateStatus?.latestVersion || updateStatus?.currentVersion }}</span>
<strong>{{ updateProgress }}%</strong>
</div>
<t-progress :percentage="updateProgress" :label="false" />
<div class="download-progress__meta">
<span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
<span>{{ downloadSpeedLabel }}</span>
</div>
</div>
<t-alert v-if="connectionInterrupted" theme="warning" :close-btn="false">
服务正在重启,页面会持续重连,不需要手动刷新。
<span v-if="restartCountdown !== null && restartCountdown > 0">预计 {{ restartCountdown }} 秒后恢复。</span>
<span v-else>预计时间已到,仍在等待服务恢复。</span>
</t-alert>
<t-alert v-else-if="restartCountdown !== null && installInProgress" theme="info" :close-btn="false">
应用进程正在切换,预计 {{ restartCountdown }} 秒后恢复访问。
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else-if="updateStatus?.state === 'RECOVERY_REQUIRED'" theme="error" :close-btn="false">
自动回滚未恢复应用,已停止接受新的更新任务,请先完成服务器恢复。
</t-alert>
<section class="runtime-log" aria-label="更新运行日志">
<div class="runtime-log__heading">
<span>运行日志</span>
<small>{{ updateEvents.length }} 条</small>
</div>
<div class="runtime-log__body" role="log">
<div v-for="(event, index) in updateEvents" :key="`${event.occurredAt}-${index}`" class="runtime-log__row">
<time>{{ formatLogTime(event.occurredAt) }}</time>
<t-tag size="small" :theme="eventTheme(event.level)" variant="light">{{
eventStageLabel(event.stage)
}}</t-tag>
<span>{{ event.message }}</span>
</div>
<t-empty v-if="!updateEvents.length" description="等待更新服务输出日志" />
</div>
</section>
<div class="dialog-actions">
<t-button v-if="canInstall" theme="primary" :loading="installingUpdate" @click="installNow">
<template #icon><t-icon name="poweroff" /></template>
立即更新并重启
</t-button>
<t-button v-if="dialogClosable" variant="outline" @click="updateDialogVisible = false">关闭</t-button>
</div>
</div>
</t-dialog>
<t-dialog
v-model:visible="historyDetailVisible"
header="更新日志详情"
aria-label="更新日志详情"
width="min(680px, calc(100vw - 32px))"
:footer="false"
>
<div v-if="selectedHistory" class="history-detail" role="dialog" aria-label="更新日志详情" aria-modal="true">
<div class="history-detail__header">
<div>
<span>目标版本</span>
<strong>{{ selectedHistory.version }}</strong>
</div>
<t-tag :theme="resultTheme(selectedHistory.resultCode)" variant="light">
{{ resultLabel(selectedHistory.resultCode) }}
</t-tag>
</div>
<div class="history-detail__metadata">
<span v-if="selectedHistory.publishedAt">发布时间:{{ formatDateTime(selectedHistory.publishedAt) }}</span>
<span
>记录时间:{{ formatDateTime(selectedHistory.occurredAt) }} · {{ selectedHistory.username || '系统' }}</span
>
</div>
<div class="history-detail__notes">
<span>更新日志</span>
<p>{{ selectedHistory.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
</div>
<p class="history-detail__reason">{{ selectedHistory.reason || '更新结果已记录。' }}</p>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { isAxiosError } from 'axios';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { AuditLog } from '@/api/audit';
import { getAuditLogs } from '@/api/audit';
import type { SystemUpdateEvent, SystemUpdateView } from '@/api/system-update';
import {
checkSystemUpdate,
downloadSystemUpdate,
getSystemUpdateStatus,
installSystemUpdate,
} from '@/api/system-update';
defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
interface HistoryTimelineItem {
key: string;
version: string;
occurredAt: string;
username: string;
resultCode: string;
reason: string;
releaseNotes: string;
publishedAt: string;
}
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
const updateStatus = ref<SystemUpdateView | null>(null);
const isNarrow = ref(false);
const statusLoading = ref(false);
const checkingUpdate = ref(false);
const downloadingUpdate = ref(false);
const installingUpdate = ref(false);
const statusError = ref('');
const historyRows = ref<AuditLog[]>([]);
const historyLoading = ref(false);
const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null);
const restartCountdown = ref<number | null>(null);
const updateDialogVisible = ref(false);
const historyDetailVisible = ref(false);
const selectedHistory = ref<HistoryTimelineItem | null>(null);
const connectionInterrupted = ref(false);
const localEvents = ref<SystemUpdateEvent[]>([]);
const awaitingRefresh = ref(false);
const installRequestUncertain = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
let restartTimer: ReturnType<typeof setInterval> | undefined;
const busyStates = new Set([
'QUEUED',
'DOWNLOAD_QUEUED',
'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING',
'DOWNLOADING',
'BACKING_UP',
'INSTALLING',
'RUNNING',
]);
const installStates = new Set(['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING']);
const updateBusy = computed(() => busyStates.has(updateStatus.value?.state || ''));
const installInProgress = computed(
() => installStates.has(updateStatus.value?.state || '') || connectionInterrupted.value,
);
const dialogClosable = computed(() => !updateBusy.value && !connectionInterrupted.value);
const showDownloadProgress = computed(() =>
['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(updateStatus.value?.state || ''),
);
const updateProgress = computed(() => {
const value = Number(updateStatus.value?.downloadPercent ?? 0);
return Math.min(100, Math.max(0, Number.isFinite(value) ? Math.round(value) : 0));
});
const downloadSpeedLabel = computed(() =>
updateStatus.value?.state === 'READY' ? '下载完成' : formatSpeed(updateStatus.value?.bytesPerSecond),
);
const updateEvents = computed(() =>
[...(updateStatus.value?.events || []), ...localEvents.value]
.sort((left, right) => left.occurredAt.localeCompare(right.occurredAt))
.slice(-120),
);
const stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal'));
const currentIsLatest = computed(
() =>
updateStatus.value?.enabled === true &&
Boolean(updateStatus.value.latestVersion) &&
!updateStatus.value.updateAvailable &&
!updateBusy.value &&
!['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(updateStatus.value.state),
);
const canCheckUpdate = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
updateStatus.value?.allowedActions.includes('CHECK') === true,
);
const canDownloadUpdate = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
Boolean(updateStatus.value?.latestVersion) &&
updateStatus.value?.updateAvailable === true &&
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true,
);
const canInstall = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
updateStatus.value?.state === 'READY' &&
updateStatus.value?.allowedActions.includes('INSTALL') === true,
);
const updateStep = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3;
if (installStates.has(state)) return 2;
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING', 'READY'].includes(state)) return 1;
return 0;
});
const updateStateLabel = computed(() => {
if (currentIsLatest.value) return '已是最新';
return (
{
IDLE: '待获取',
DISABLED: '未启用',
CURRENT: '已是最新',
QUEUED: '已排队',
DOWNLOAD_QUEUED: '下载已排队',
INSTALL_QUEUED: '等待重启',
PRECHECKING: '安装预检',
VERIFYING: '校验中',
DOWNLOADING: '下载中',
READY: '下载完成',
BACKING_UP: '备份中',
INSTALLING: '安装中',
RUNNING: '重启中',
SUCCEEDED: '更新成功',
FAILED: '更新失败',
RECOVERY_REQUIRED: '需要人工恢复',
UNKNOWN: '状态未知',
}[updateStatus.value?.state || 'IDLE'] ||
updateStatus.value?.state ||
'待获取'
);
});
const updateActionHint = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
if (['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
return '更新包已确认,系统正在预检、备份、切换并重启。';
}
if (state === 'RECOVERY_REQUIRED') return '自动回滚未完成,新的更新操作已锁定。';
if (state === 'READY') {
return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。';
}
if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。';
if (updateStatus.value?.updateAvailable) return '已发现新版本,但当前账号没有下载权限。';
if (state === 'IDLE' && !updateStatus.value?.latestVersion) return '点击“获取版本”检查最新发布。';
return '';
});
const updateStateTheme = computed<TagTheme>(() => {
const state = updateStatus.value?.state || 'IDLE';
if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success';
if (['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(state)) return 'danger';
if (updateBusy.value) return 'warning';
return 'default';
});
const historyTimeline = computed<HistoryTimelineItem[]>(() => {
const grouped = new Map<string, AuditLog[]>();
for (const row of historyRows.value) {
const version = historyVersion(row);
const key = version === '-' ? `unknown:${row.requestId || row.publicId}` : `version:${version}`;
const rows = grouped.get(key) || [];
rows.push(row);
grouped.set(key, rows);
}
return [...grouped.entries()]
.map(([key, rows]) => {
const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode));
const representative = terminal || newestFirst[0];
const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || '';
const publishedAt = newestFirst.map((row) => historyPublishedAt(row)).find(Boolean) || '';
return {
key,
version: representative ? historyVersion(representative) : '-',
occurredAt: representative?.occurredAt || '',
username: representative?.username || '',
resultCode: representative?.resultCode || 'UNKNOWN',
reason: representative?.reason || '',
releaseNotes,
publishedAt,
};
})
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
});
async function loadStatus(silent = false) {
if (!silent) {
statusLoading.value = true;
statusError.value = '';
}
try {
const status = await getSystemUpdateStatus();
if (connectionInterrupted.value) {
appendLocalEvent('INFO', 'RUNNING', '应用服务连接已恢复,正在确认更新结果');
connectionInterrupted.value = false;
}
updateStatus.value = status;
if (installStates.has(status.state)) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
installRequestUncertain.value = false;
statusError.value = '';
}
if (status.state === 'RUNNING') {
startRestartCountdown(status.restartExpectedSeconds ?? 10);
} else if (!installStates.has(status.state)) {
stopRestartCountdown();
}
if (status.state === 'RECOVERY_REQUIRED') updateDialogVisible.value = true;
handleCompletion(status);
if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling();
else stopPolling();
} catch (error) {
const restartExpected = awaitingRefresh.value || installStates.has(updateStatus.value?.state || '');
if (restartExpected) {
if (!connectionInterrupted.value) appendLocalEvent('WARN', 'RUNNING', '应用服务连接中断,正在等待进程重启');
connectionInterrupted.value = true;
updateDialogVisible.value = true;
startRestartCountdown(updateStatus.value?.restartExpectedSeconds ?? 10);
startPolling();
} else if (!silent) {
statusError.value = friendlyError(error, '系统版本状态读取失败');
}
} finally {
if (!silent) statusLoading.value = false;
}
}
async function loadHistory(silent = false) {
if (!silent) {
historyLoading.value = true;
historyError.value = '';
}
try {
const result = await getAuditLogs({
occurredFrom: '1970-01-01T00:00:00Z',
objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc',
page: 1,
size: 100,
});
historyRows.value = result.items;
} catch (error) {
if (!silent) historyError.value = friendlyError(error, '历史更新记录加载失败');
} finally {
if (!silent) historyLoading.value = false;
}
}
async function refreshPage() {
await Promise.all([loadStatus(), loadHistory()]);
}
async function checkLatest() {
if (checkingUpdate.value || updateBusy.value || !canCheckUpdate.value) return;
checkingUpdate.value = true;
statusError.value = '';
try {
const checked = await checkSystemUpdate();
updateStatus.value = checked;
await loadHistory(true);
if (!checked.updateAvailable) {
MessagePlugin.info('当前已是最新版本');
return;
}
if (checked.state === 'READY') {
MessagePlugin.success(
checked.allowedActions.includes('INSTALL')
? '更新包已下载完成,请点击“立即更新并重启”安装新版本'
: '更新包已下载完成,但当前账号没有安装权限',
);
} else if (busyStates.has(checked.state)) {
updateDialogVisible.value = true;
startPolling();
MessagePlugin.info(checked.message || '更新任务正在处理中,请查看当前进度');
} else if (!checked.allowedActions.includes('DOWNLOAD')) {
MessagePlugin.info('发现新版本,但当前账号没有下载权限');
} else {
MessagePlugin.success(`发现新版本 ${checked.latestVersion},请点击“立即更新”开始下载`);
}
} catch (error) {
statusError.value = friendlyError(error, '获取版本失败');
} finally {
checkingUpdate.value = false;
}
}
async function downloadNow() {
const version = updateStatus.value?.latestVersion;
if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return;
downloadingUpdate.value = true;
updateDialogVisible.value = true;
localEvents.value = [];
statusError.value = '';
startPolling();
try {
updateStatus.value = await downloadSystemUpdate(version);
appendLocalEvent('INFO', 'DOWNLOAD_QUEUED', `版本 ${version} 的下载请求已提交`);
MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”');
void loadHistory(true);
} catch (error) {
statusError.value = friendlyError(error, '下载更新包失败');
void loadStatus(true);
} finally {
downloadingUpdate.value = false;
startPolling();
}
}
async function installNow() {
const version = updateStatus.value?.latestVersion;
if (!version || installingUpdate.value || !canInstall.value) return;
installingUpdate.value = true;
updateDialogVisible.value = true;
statusError.value = '';
stopRefreshCountdown();
stopRestartCountdown();
awaitingRefresh.value = true;
installRequestUncertain.value = false;
setPendingUpdate(true);
startPolling();
try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
appendLocalEvent('INFO', 'INSTALL_QUEUED', `已确认安装 ${version},等待更新服务执行`);
MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
void loadHistory(true);
} catch (error) {
if (responseMayHaveBeenLost(error)) {
statusError.value = '';
installRequestUncertain.value = true;
appendLocalEvent('WARN', 'INSTALL_QUEUED', '安装请求连接中断,正在确认服务器是否已受理');
} else {
awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
statusError.value = friendlyError(error, '立即更新并重启失败');
}
void loadStatus(true);
} finally {
installingUpdate.value = false;
startPolling();
}
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => void loadStatus(true), 1000);
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = undefined;
}
function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return;
if (status.state === 'READY') {
awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
return;
}
if (status.state === 'SUCCEEDED') {
statusError.value = '';
awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
startRefreshCountdown();
void loadHistory(true);
return;
}
if (['FAILED', 'RECOVERY_REQUIRED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
stopRefreshCountdown();
}
}
function startRestartCountdown(seconds: number) {
if (restartTimer) return;
restartCountdown.value = Math.max(0, Math.min(300, Math.round(seconds)));
restartTimer = setInterval(() => {
if (restartCountdown.value === null || restartCountdown.value <= 0) return;
restartCountdown.value -= 1;
}, 1000);
}
function stopRestartCountdown() {
if (restartTimer) clearInterval(restartTimer);
restartTimer = undefined;
restartCountdown.value = null;
}
function setPendingUpdate(pending: boolean) {
try {
if (pending) sessionStorage.setItem(UPDATE_PENDING_KEY, 'true');
else sessionStorage.removeItem(UPDATE_PENDING_KEY);
} catch {
// The in-memory state still keeps the current page resilient when storage is unavailable.
}
}
function hasPendingUpdate() {
try {
return sessionStorage.getItem(UPDATE_PENDING_KEY) === 'true';
} catch {
return false;
}
}
function startRefreshCountdown() {
if (refreshTimer) return;
updateRefreshSeconds.value = 10;
refreshTimer = setInterval(() => {
if (updateRefreshSeconds.value === null) return;
if (updateRefreshSeconds.value <= 1) {
stopRefreshCountdown();
window.location.reload();
return;
}
updateRefreshSeconds.value -= 1;
}, 1000);
}
function stopRefreshCountdown() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = undefined;
updateRefreshSeconds.value = null;
}
function appendLocalEvent(level: SystemUpdateEvent['level'], stage: string, message: string) {
const latest = localEvents.value.at(-1);
if (latest?.stage === stage && latest.message === message) return;
localEvents.value = [...localEvents.value, { occurredAt: new Date().toISOString(), level, stage, message }].slice(
-30,
);
}
function formatBytes(value?: number | null) {
if (value === null || value === undefined || !Number.isFinite(value) || value < 0) return '待获取';
if (value < 1024) return `${Math.round(value)} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let amount = value / 1024;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${amount >= 100 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
}
function formatSpeed(value?: number | null) {
if (value === null || value === undefined || value <= 0) return '正在计算网速';
return `${formatBytes(value)}/s`;
}
function formatLogTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function eventTheme(level: SystemUpdateEvent['level']): TagTheme {
if (level === 'ERROR') return 'danger';
if (level === 'WARN') return 'warning';
return 'primary';
}
function eventStageLabel(stage: string) {
return (
{
DOWNLOAD_QUEUED: '排队',
VERIFYING: '校验',
DOWNLOADING: '下载',
READY: '就绪',
INSTALL_QUEUED: '确认',
PRECHECKING: '预检',
BACKING_UP: '备份',
INSTALLING: '安装',
RUNNING: '重启',
SUCCEEDED: '完成',
FAILED: '失败',
RECOVERY_REQUIRED: '恢复',
}[stage] || stage
);
}
function compareHistoryDates(left: string, right: string) {
const leftTime = Date.parse(left);
const rightTime = Date.parse(right);
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) return left.localeCompare(right);
return leftTime - rightTime;
}
function isTerminalHistoryAction(action: string) {
return ['SYSTEM_UPDATE_SUCCEEDED', 'SYSTEM_UPDATE_FAILED', 'SYSTEM_UPDATE_RECOVERY_REQUIRED'].includes(action);
}
function parseHistoryPayload(source?: string | null) {
if (!source) return null;
try {
const parsed: unknown = JSON.parse(source);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source);
const version = parsed?.targetVersion || parsed?.latestVersion || parsed?.currentVersion || parsed?.version;
if (version !== undefined && version !== null && String(version).trim()) return String(version);
}
return '-';
}
function historyReleaseNotes(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source);
for (const key of ['releaseNotes', 'changelog', 'updateLog', 'notes']) {
const value = parsed?.[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (Array.isArray(value)) {
const lines = value
.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
.map((item) => item.trim());
if (lines.length) return lines.join('\n');
}
}
}
return '';
}
function historyPublishedAt(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source);
if (typeof parsed?.publishedAt === 'string' && parsed.publishedAt.trim()) return parsed.publishedAt;
}
return '';
}
function openHistoryDetail(item: HistoryTimelineItem) {
selectedHistory.value = item;
historyDetailVisible.value = true;
}
function resultLabel(result: string) {
return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result;
}
function resultTheme(result: string): TagTheme {
if (result === 'SUCCESS') return 'success';
if (result === 'FAILED' || result === 'BLOCKED') return 'danger';
if (result === 'DENIED') return 'warning';
return 'default';
}
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
}
function friendlyError(error: unknown, fallback: string) {
return (error as Error)?.message || fallback;
}
function responseMayHaveBeenLost(error: unknown) {
return (
(isAxiosError(error) && !error.response) || (error as { transportFailure?: boolean })?.transportFailure === true
);
}
function updateViewportMode() {
isNarrow.value = window.innerWidth <= 640;
}
onMounted(() => {
updateViewportMode();
window.addEventListener('resize', updateViewportMode);
if (hasPendingUpdate()) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
startRestartCountdown(10);
startPolling();
}
void refreshPage();
});
onBeforeUnmount(() => {
stopPolling();
stopRefreshCountdown();
stopRestartCountdown();
window.removeEventListener('resize', updateViewportMode);
});
</script>
<style scoped>
.system-update-page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-width: 0;
}
.update-toolbar,
.update-heading,
.update-actions,
.section-heading,
.alert-content {
display: flex;
align-items: center;
}
.update-panel,
.history-panel {
width: 100%;
min-width: 0;
padding: 20px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.update-panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.update-heading,
.section-heading {
justify-content: space-between;
gap: 20px;
min-width: 0;
}
.update-toolbar {
justify-content: space-between;
gap: 20px;
min-width: 0;
padding-bottom: 2px;
}
.update-toolbar__copy {
min-width: 0;
}
.update-toolbar__title {
margin: 0;
color: var(--td-text-color-primary);
font-size: 18px;
line-height: 26px;
}
.update-toolbar__copy p {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.version-grid {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr));
gap: 24px;
min-width: 0;
}
.version-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.version-item > span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.version-item strong {
overflow: hidden;
color: var(--td-text-color-primary);
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.update-actions {
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.release-notes {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--td-component-border);
}
.update-status-message {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.update-status-message small {
color: var(--td-text-color-secondary);
}
.release-notes span,
.section-heading p,
.section-heading > span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.release-notes p {
min-width: 0;
color: var(--td-text-color-primary);
line-height: 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.section-heading {
margin-bottom: 16px;
}
.section-heading h2 {
margin: 0 0 4px;
font-size: 16px;
line-height: 24px;
}
.history-loading {
display: flex;
justify-content: center;
min-height: 160px;
padding: 32px 0;
}
.update-timeline {
display: flex;
flex-direction: column;
gap: 18px;
}
.update-timeline__item {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 14px;
min-width: 0;
}
.update-timeline__rail {
position: relative;
display: flex;
justify-content: center;
}
.update-timeline__rail::after {
position: absolute;
top: 30px;
bottom: -18px;
width: 1px;
background: var(--td-component-border);
content: '';
}
.update-timeline__item:last-child .update-timeline__rail::after {
display: none;
}
.update-timeline__marker {
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--td-text-color-secondary);
background: var(--td-bg-color-container);
border: 2px solid var(--td-component-border);
border-radius: 50%;
}
.update-timeline__marker--success {
color: #fff;
background: var(--td-success-color);
border-color: var(--td-success-color);
}
.update-timeline__marker--danger {
color: #fff;
background: var(--td-error-color);
border-color: var(--td-error-color);
}
.update-timeline__marker--warning {
color: #fff;
background: var(--td-warning-color);
border-color: var(--td-warning-color);
}
.timeline-card {
min-width: 0;
padding: 16px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.timeline-card__header,
.timeline-card__footer,
.history-detail__header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.timeline-card__header,
.timeline-card__footer,
.history-detail__header {
justify-content: space-between;
}
.timeline-card__identity {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.timeline-card__identity strong {
overflow: hidden;
color: var(--td-text-color-primary);
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-card__identity span,
.timeline-card__footer,
.history-detail__header span,
.history-detail__metadata {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.timeline-card__reason,
.history-detail__reason {
margin: 12px 0 0;
color: var(--td-text-color-primary);
line-height: 22px;
overflow-wrap: anywhere;
}
.timeline-card__notes,
.history-detail__notes {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--td-component-border);
}
.timeline-card__notes > span,
.history-detail__notes > span {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.timeline-card__notes p,
.history-detail__notes p {
margin: 0;
color: var(--td-text-color-primary);
line-height: 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.timeline-card__published,
.history-detail__metadata {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-top: 10px;
color: var(--td-text-color-secondary);
font-size: 12px;
line-height: 20px;
}
.timeline-card__footer {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--td-component-border);
}
.history-detail {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.history-detail__header > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-detail__header strong {
color: var(--td-text-color-primary);
font-size: 18px;
line-height: 26px;
}
.alert-content {
justify-content: space-between;
gap: 12px;
width: 100%;
}
.download-progress {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.download-progress__heading,
.download-progress__meta,
.runtime-log__heading,
.dialog-status-line,
.dialog-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.download-progress__heading strong {
font-variant-numeric: tabular-nums;
}
.download-progress__meta {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.update-dialog {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.dialog-status-line {
justify-content: flex-start;
min-width: 0;
}
.dialog-status-line > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-log {
min-width: 0;
}
.runtime-log__heading {
margin-bottom: 8px;
}
.runtime-log__heading small {
color: var(--td-text-color-secondary);
}
.runtime-log__body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
max-height: 240px;
padding: 12px;
overflow: auto;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.runtime-log__row {
display: grid;
grid-template-columns: 72px 58px minmax(0, 1fr);
align-items: start;
gap: 8px;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 22px;
}
.runtime-log__row time {
color: var(--td-text-color-secondary);
font-variant-numeric: tabular-nums;
}
.runtime-log__row > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.dialog-actions {
justify-content: flex-end;
padding-top: 4px;
}
@media (width <= 900px) {
.update-heading {
align-items: stretch;
flex-direction: column;
}
.update-actions {
justify-content: flex-start;
}
}
@media (width <= 640px) {
.update-panel,
.history-panel {
padding: 14px;
}
.version-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.update-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.update-toolbar :deep(.t-button) {
width: 100%;
}
.update-actions,
.update-actions :deep(.t-button) {
width: 100%;
}
.release-notes {
grid-template-columns: 1fr;
gap: 6px;
}
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.update-timeline__item {
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
}
.update-timeline__marker {
width: 18px;
height: 18px;
}
.update-timeline__rail::after {
top: 24px;
}
.timeline-card {
padding: 12px;
}
.timeline-card__notes,
.history-detail__notes {
grid-template-columns: 1fr;
gap: 4px;
}
.download-progress__meta,
.dialog-status-line,
.dialog-actions {
align-items: flex-start;
flex-direction: column;
}
.dialog-actions,
.dialog-actions :deep(.t-button) {
width: 100%;
}
.runtime-log__row {
grid-template-columns: 64px 52px minmax(0, 1fr);
gap: 6px;
font-size: 12px;
}
}
</style>