Files
kaidi/frontend/src/pages/governance/SystemUpdatePage.vue
T
2026-08-18 09:01:34 +08:00

655 lines
19 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>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<section class="update-panel" aria-label="系统版本与更新操作">
<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"
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>
<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>
<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>记录版本获取、下载和重启安装操作。</p>
</div>
<span>最近 {{ historyRows.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>
<t-table
v-if="historyLoading || historyRows.length"
:columns="historyColumns"
:data="historyRows"
row-key="publicId"
:loading="historyLoading"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
<template #actionCode="{ row }">{{ actionLabel(row.actionCode) }}</template>
<template #version="{ row }">{{ historyVersion(row) }}</template>
<template #resultCode="{ row }">
<t-tag :theme="resultTheme(row.resultCode)" variant="light">{{ resultLabel(row.resultCode) }}</t-tag>
</template>
<template #reason="{ row }">{{ row.reason || '-' }}</template>
</t-table>
<t-empty v-else description="暂无系统更新记录" />
</section>
</div>
</template>
<script setup lang="ts">
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 {
checkSystemUpdate,
downloadSystemUpdate,
getSystemUpdateStatus,
installSystemUpdate,
} from '@/api/system-update';
defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
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 awaitingRefresh = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
const busyStates = new Set([
'QUEUED',
'DOWNLOAD_QUEUED',
'INSTALL_QUEUED',
'VERIFYING',
'DOWNLOADING',
'BACKING_UP',
'INSTALLING',
'RUNNING',
]);
const installStates = new Set(['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING']);
const updateBusy = computed(() => busyStates.has(updateStatus.value?.state || ''));
const stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal'));
const currentIsLatest = computed(
() =>
updateStatus.value?.enabled === true &&
Boolean(updateStatus.value.latestVersion) &&
!updateStatus.value.updateAvailable &&
!updateBusy.value &&
!['FAILED', '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: '等待重启',
VERIFYING: '校验中',
DOWNLOADING: '下载中',
READY: '下载完成',
BACKING_UP: '备份中',
INSTALLING: '安装中',
RUNNING: '重启中',
SUCCEEDED: '更新成功',
FAILED: '更新失败',
UNKNOWN: '状态未知',
}[updateStatus.value?.state || 'IDLE'] ||
updateStatus.value?.state ||
'待获取'
);
});
const updateActionHint = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
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 (updateBusy.value) return 'warning';
return 'default';
});
const historyColumns: PrimaryTableCol[] = [
{ colKey: 'occurredAt', title: '操作时间', width: 180 },
{ colKey: 'version', title: '版本', width: 170, ellipsis: true },
{ colKey: 'actionCode', title: '操作', width: 150 },
{ colKey: 'username', title: '操作人', width: 130, ellipsis: true },
{ colKey: 'resultCode', title: '结果', width: 100 },
{ colKey: 'reason', title: '说明', minWidth: 220, ellipsis: true },
];
async function loadStatus(silent = false) {
if (!silent) {
statusLoading.value = true;
statusError.value = '';
}
try {
const status = await getSystemUpdateStatus();
updateStatus.value = status;
if (installStates.has(status.state)) awaitingRefresh.value = true;
handleCompletion(status);
if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling();
else stopPolling();
} catch (error) {
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({
objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc',
page: 1,
size: 20,
});
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 (!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;
statusError.value = '';
startPolling();
try {
updateStatus.value = await downloadSystemUpdate(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;
statusError.value = '';
stopRefreshCountdown();
awaitingRefresh.value = true;
startPolling();
try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
void loadHistory(true);
} catch (error) {
statusError.value = friendlyError(error, '立即更新并重启失败');
void loadStatus(true);
} finally {
installingUpdate.value = false;
startPolling();
}
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => void loadStatus(true), 3000);
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = undefined;
}
function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return;
if (status.state === 'SUCCEEDED') {
statusError.value = '';
awaitingRefresh.value = false;
startRefreshCountdown();
void loadHistory(true);
return;
}
if (['FAILED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
awaitingRefresh.value = false;
stopRefreshCountdown();
}
}
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 historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue;
try {
const parsed = JSON.parse(source) as Record<string, unknown>;
const version = parsed.targetVersion || parsed.latestVersion || parsed.currentVersion || parsed.version;
if (version) return String(version);
} catch {
continue;
}
}
return '-';
}
function actionLabel(action: string) {
return (
{
SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
}[action] || action
);
}
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 updateViewportMode() {
isNarrow.value = window.innerWidth <= 640;
}
onMounted(() => {
updateViewportMode();
window.addEventListener('resize', updateViewportMode);
void refreshPage();
});
onBeforeUnmount(() => {
stopPolling();
stopRefreshCountdown();
window.removeEventListener('resize', updateViewportMode);
});
</script>
<style scoped>
.system-update-page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-width: 0;
}
.page-header,
.update-heading,
.update-actions,
.section-heading,
.alert-content {
display: flex;
align-items: center;
}
.page-header {
justify-content: flex-end;
}
.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;
}
.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;
}
.alert-content {
justify-content: space-between;
gap: 12px;
width: 100%;
}
@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-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;
}
}
</style>