This commit is contained in:
@@ -28,6 +28,7 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
|
||||
'QUEUED',
|
||||
'DOWNLOAD_QUEUED',
|
||||
'INSTALL_QUEUED',
|
||||
'PRECHECKING',
|
||||
'VERIFYING',
|
||||
'DOWNLOADING',
|
||||
'BACKING_UP',
|
||||
@@ -59,6 +60,14 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
|
||||
publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
|
||||
checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
|
||||
statusUpdatedAt: null,
|
||||
downloadedBytes: state === 'READY' ? 10_485_760 : state === 'DOWNLOADING' ? 5_242_880 : 0,
|
||||
totalBytes: ['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(state) ? 10_485_760 : null,
|
||||
bytesPerSecond: state === 'DOWNLOADING' ? 1_048_576 : 0,
|
||||
downloadPercent: state === 'READY' ? 100 : state === 'DOWNLOADING' ? 50 : 0,
|
||||
restartExpectedSeconds: state === 'RUNNING' ? 10 : null,
|
||||
events: checked
|
||||
? [{ occurredAt: '2026-08-16T00:01:00Z', level: 'INFO', stage: state, message: `状态进入 ${state}` }]
|
||||
: [],
|
||||
allowedActions:
|
||||
!enabled || recoveryPending || busy
|
||||
? []
|
||||
@@ -72,8 +81,11 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
|
||||
|
||||
interface FixtureOptions {
|
||||
canExecute?: boolean;
|
||||
checkResponseState?: string;
|
||||
downloadProgressReads?: number;
|
||||
failDownloadResponse?: boolean;
|
||||
failInstallResponse?: boolean;
|
||||
restartOfflineReads?: number;
|
||||
}
|
||||
|
||||
async function installFixture(
|
||||
@@ -87,9 +99,18 @@ async function installFixture(
|
||||
completeBusyAfterFirstRead = false,
|
||||
options: FixtureOptions = {},
|
||||
) {
|
||||
const { canExecute = true, failDownloadResponse = false, failInstallResponse = false } = options;
|
||||
const {
|
||||
canExecute = true,
|
||||
checkResponseState,
|
||||
downloadProgressReads = 0,
|
||||
failDownloadResponse = false,
|
||||
failInstallResponse = false,
|
||||
restartOfflineReads = 0,
|
||||
} = options;
|
||||
let checked = recoveryPending || Boolean(initialState);
|
||||
let state = recoveryPending ? 'FAILED' : initialState || 'IDLE';
|
||||
let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
|
||||
let progressDeadline = 0;
|
||||
let restartDeadline = 0;
|
||||
const history: Array<Record<string, unknown>> = [];
|
||||
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => {
|
||||
history.unshift({
|
||||
@@ -131,13 +152,19 @@ async function installFixture(
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') {
|
||||
if (state === 'RUNNING' && Date.now() < restartDeadline) {
|
||||
return route.abort('connectionrefused');
|
||||
}
|
||||
if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED';
|
||||
const response = updateView(state, checked, enabled, recoveryPending);
|
||||
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
|
||||
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
|
||||
return route.fulfill({ json: envelope(response) });
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
|
||||
checked = true;
|
||||
recordHistory('SYSTEM_UPDATE_CHECK');
|
||||
if (checkResponseState) state = checkResponseState;
|
||||
const response = updateView(state, checked, enabled, recoveryPending);
|
||||
if (!canExecute) response.allowedActions = ['CHECK'];
|
||||
return route.fulfill({ json: envelope(response) });
|
||||
@@ -145,7 +172,8 @@ async function installFixture(
|
||||
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
|
||||
downloadBodies.push(request.postDataJSON());
|
||||
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
|
||||
state = 'READY';
|
||||
state = downloadProgressReads > 0 ? 'DOWNLOADING' : 'READY';
|
||||
progressDeadline = Date.now() + downloadProgressReads * 1000;
|
||||
if (failDownloadResponse) return route.abort('connectionreset');
|
||||
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
|
||||
}
|
||||
@@ -153,7 +181,13 @@ async function installFixture(
|
||||
installBodies.push(request.postDataJSON());
|
||||
recordHistory('SYSTEM_UPDATE_REQUEST');
|
||||
const queued = updateView('INSTALL_QUEUED', true, enabled);
|
||||
state = failInstallResponse || completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED';
|
||||
restartDeadline = Date.now() + restartOfflineReads * 1000;
|
||||
state =
|
||||
restartOfflineReads > 0
|
||||
? 'RUNNING'
|
||||
: failInstallResponse || completeAfterInstall
|
||||
? 'SUCCEEDED'
|
||||
: 'INSTALL_QUEUED';
|
||||
if (failInstallResponse) return route.abort('connectionreset');
|
||||
return route.fulfill({ json: envelope(queued) });
|
||||
}
|
||||
@@ -189,6 +223,7 @@ test('system administrator checks, downloads, and explicitly installs a signed o
|
||||
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
|
||||
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
|
||||
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
||||
await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
|
||||
|
||||
@@ -198,6 +233,38 @@ test('system administrator checks, downloads, and explicitly installs a signed o
|
||||
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即更新并重启' }]);
|
||||
});
|
||||
|
||||
test('download dialog shows real byte progress and transfer speed without a false permission warning', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixture(page, [], [], true, false, false, undefined, false, { downloadProgressReads: 8 });
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||
await page.getByRole('button', { name: '立即更新', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '系统更新' });
|
||||
await expect(dialog.getByText('50%', { exact: true })).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog.getByText('1.0 MB/s', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
||||
await expect(dialog.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible({ timeout: 12_000 });
|
||||
});
|
||||
|
||||
test('restart dialog keeps counting down and reconnects while the backend is temporarily offline', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, 'READY', false, { restartOfflineReads: 8 });
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '系统更新' });
|
||||
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(dialog.getByText('应用服务连接中断,正在等待进程重启', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, 'READY');
|
||||
await page.goto('/governance/update');
|
||||
@@ -220,6 +287,19 @@ test('view-only administrators see a download permission message', async ({ page
|
||||
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a check response that is already queued is treated as busy instead of missing permission', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, undefined, false, {
|
||||
checkResponseState: 'DOWNLOAD_QUEUED',
|
||||
});
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.getByRole('button', { name: '获取版本', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('dialog', { name: '系统更新' })).toBeVisible();
|
||||
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText('下载请求已排队', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('download and install continue polling after an accepted command loses its response', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, undefined, false, {
|
||||
failDownloadResponse: true,
|
||||
@@ -252,6 +332,25 @@ test('accepted install continues to the refresh countdown after its response is
|
||||
});
|
||||
});
|
||||
|
||||
test('a lost install response stays pending while the application restarts', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, 'READY', false, {
|
||||
failInstallResponse: true,
|
||||
restartOfflineReads: 4,
|
||||
});
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '系统更新' });
|
||||
await expect(dialog.getByText('立即更新并重启失败', { exact: false })).toHaveCount(0);
|
||||
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('brand logo returns the active identity to its workbench', async ({ page }) => {
|
||||
await installFixture(page, [], []);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
@@ -2,6 +2,13 @@ import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface SystemUpdateEvent {
|
||||
occurredAt: string;
|
||||
level: 'INFO' | 'WARN' | 'ERROR';
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SystemUpdateView {
|
||||
enabled: boolean;
|
||||
currentVersion: string;
|
||||
@@ -13,6 +20,12 @@ export interface SystemUpdateView {
|
||||
publishedAt?: string | null;
|
||||
checkedAt?: string | null;
|
||||
statusUpdatedAt?: string | null;
|
||||
downloadedBytes?: number | null;
|
||||
totalBytes?: number | null;
|
||||
bytesPerSecond?: number | null;
|
||||
downloadPercent?: number | null;
|
||||
restartExpectedSeconds?: number | null;
|
||||
events: SystemUpdateEvent[];
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
@@ -21,6 +34,7 @@ function normalize(value: SystemUpdateView): SystemUpdateView {
|
||||
...value,
|
||||
enabled: Boolean(value?.enabled),
|
||||
updateAvailable: Boolean(value?.updateAvailable),
|
||||
events: Array.isArray(value?.events) ? value.events : [],
|
||||
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
立即更新
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canInstall"
|
||||
v-if="canInstall && !updateDialogVisible"
|
||||
theme="primary"
|
||||
:loading="installingUpdate"
|
||||
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
|
||||
@@ -66,22 +66,36 @@
|
||||
<t-step-item title="完成更新" content="健康检查后刷新" />
|
||||
</t-steps>
|
||||
|
||||
<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 v-if="showDownloadProgress && !updateDialogVisible" class="download-progress" aria-live="polite">
|
||||
<div class="download-progress__heading">
|
||||
<span>更新包下载进度</span>
|
||||
<strong>{{ updateProgress }}%</strong>
|
||||
</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>
|
||||
<t-progress :percentage="updateProgress" :label="false" />
|
||||
<div class="download-progress__meta">
|
||||
<span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
|
||||
<span>{{ downloadSpeedLabel }}</span>
|
||||
</div>
|
||||
</t-alert>
|
||||
</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>
|
||||
@@ -123,16 +137,99 @@
|
||||
</t-table>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isAxiosError } from 'axios';
|
||||
import type { PrimaryTableCol } from 'tdesign-vue-next';
|
||||
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 { SystemUpdateView } from '@/api/system-update';
|
||||
import type { SystemUpdateEvent, SystemUpdateView } from '@/api/system-update';
|
||||
import {
|
||||
checkSystemUpdate,
|
||||
downloadSystemUpdate,
|
||||
@@ -143,6 +240,7 @@ import {
|
||||
defineOptions({ name: 'SystemUpdatePage' });
|
||||
|
||||
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
||||
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
|
||||
|
||||
const updateStatus = ref<SystemUpdateView | null>(null);
|
||||
const isNarrow = ref(false);
|
||||
@@ -155,24 +253,50 @@ 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 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', '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(
|
||||
() =>
|
||||
@@ -180,7 +304,7 @@ const currentIsLatest = computed(
|
||||
Boolean(updateStatus.value.latestVersion) &&
|
||||
!updateStatus.value.updateAvailable &&
|
||||
!updateBusy.value &&
|
||||
!['FAILED', 'UNKNOWN'].includes(updateStatus.value.state),
|
||||
!['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(updateStatus.value.state),
|
||||
);
|
||||
const canCheckUpdate = computed(
|
||||
() =>
|
||||
@@ -220,6 +344,7 @@ const updateStateLabel = computed(() => {
|
||||
QUEUED: '已排队',
|
||||
DOWNLOAD_QUEUED: '下载已排队',
|
||||
INSTALL_QUEUED: '等待重启',
|
||||
PRECHECKING: '安装预检',
|
||||
VERIFYING: '校验中',
|
||||
DOWNLOADING: '下载中',
|
||||
READY: '下载完成',
|
||||
@@ -228,6 +353,7 @@ const updateStateLabel = computed(() => {
|
||||
RUNNING: '重启中',
|
||||
SUCCEEDED: '更新成功',
|
||||
FAILED: '更新失败',
|
||||
RECOVERY_REQUIRED: '需要人工恢复',
|
||||
UNKNOWN: '状态未知',
|
||||
}[updateStatus.value?.state || 'IDLE'] ||
|
||||
updateStatus.value?.state ||
|
||||
@@ -236,22 +362,23 @@ const updateStateLabel = computed(() => {
|
||||
});
|
||||
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 (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
|
||||
if (['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
|
||||
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', 'UNKNOWN'].includes(state)) return 'danger';
|
||||
if (['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(state)) return 'danger';
|
||||
if (updateBusy.value) return 'warning';
|
||||
return 'default';
|
||||
});
|
||||
@@ -272,13 +399,37 @@ async function loadStatus(silent = false) {
|
||||
}
|
||||
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;
|
||||
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) {
|
||||
if (!silent) statusError.value = friendlyError(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;
|
||||
}
|
||||
@@ -326,6 +477,10 @@ async function checkLatest() {
|
||||
? '更新包已下载完成,请点击“立即更新并重启”安装新版本'
|
||||
: '更新包已下载完成,但当前账号没有安装权限',
|
||||
);
|
||||
} else if (busyStates.has(checked.state)) {
|
||||
updateDialogVisible.value = true;
|
||||
startPolling();
|
||||
MessagePlugin.info(checked.message || '更新任务正在处理中,请查看当前进度');
|
||||
} else if (!checked.allowedActions.includes('DOWNLOAD')) {
|
||||
MessagePlugin.info('发现新版本,但当前账号没有下载权限');
|
||||
} else {
|
||||
@@ -342,10 +497,13 @@ 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) {
|
||||
@@ -361,16 +519,30 @@ 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) {
|
||||
statusError.value = friendlyError(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;
|
||||
@@ -380,7 +552,7 @@ async function installNow() {
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(() => void loadStatus(true), 3000);
|
||||
pollTimer = setInterval(() => void loadStatus(true), 1000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
@@ -390,19 +562,66 @@ function stopPolling() {
|
||||
|
||||
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', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
|
||||
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;
|
||||
@@ -423,6 +642,64 @@ function stopRefreshCountdown() {
|
||||
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 historyVersion(row: AuditLog) {
|
||||
for (const source of [row.afterJson, row.beforeJson]) {
|
||||
if (!source) continue;
|
||||
@@ -468,6 +745,12 @@ 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;
|
||||
}
|
||||
@@ -475,11 +758,18 @@ function updateViewportMode() {
|
||||
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>
|
||||
@@ -613,6 +903,103 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
@@ -650,5 +1037,23 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -325,6 +325,7 @@ export class VAxios {
|
||||
status: e.response?.status,
|
||||
requestId,
|
||||
fieldErrors: problem?.fieldErrors,
|
||||
transportFailure: !e.response,
|
||||
});
|
||||
if (e.response?.status === 401 && window.location.pathname !== '/login') {
|
||||
window.dispatchEvent(new CustomEvent('kaidi:session-expired'));
|
||||
|
||||
Reference in New Issue
Block a user