This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
<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="fetchingUpdate"
|
||||
:disabled="!canFetchUpdate || updateBusy || restarting"
|
||||
@click="fetchUpdate"
|
||||
>
|
||||
<template #icon><t-icon name="cloud-download" /></template>
|
||||
获取更新
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canRestart"
|
||||
theme="primary"
|
||||
:loading="restarting"
|
||||
:disabled="updateBusy || fetchingUpdate"
|
||||
@click="restartNow"
|
||||
>
|
||||
<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">
|
||||
{{ updateStatus?.message || '正在读取系统版本状态' }}
|
||||
</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 fetchingUpdate = ref(false);
|
||||
const restarting = 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 canFetchUpdate = computed(
|
||||
() =>
|
||||
updateStatus.value?.enabled !== false &&
|
||||
updateStatus.value?.state !== 'READY' &&
|
||||
(updateStatus.value?.allowedActions.includes('CHECK') === true ||
|
||||
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true),
|
||||
);
|
||||
const canRestart = computed(() => 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 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)) 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 fetchUpdate() {
|
||||
if (fetchingUpdate.value || updateBusy.value || !canFetchUpdate.value) return;
|
||||
fetchingUpdate.value = true;
|
||||
statusError.value = '';
|
||||
try {
|
||||
const checked = await checkSystemUpdate();
|
||||
updateStatus.value = checked;
|
||||
await loadHistory(true);
|
||||
if (!checked.updateAvailable) {
|
||||
MessagePlugin.info('当前已是最新版本');
|
||||
return;
|
||||
}
|
||||
if (!checked.latestVersion || !checked.allowedActions.includes('DOWNLOAD')) {
|
||||
throw new Error('发现新版本,但当前账号没有下载更新包的权限');
|
||||
}
|
||||
updateStatus.value = await downloadSystemUpdate(checked.latestVersion);
|
||||
await loadHistory(true);
|
||||
MessagePlugin.success('更新包已开始下载,下载和签名校验完成后可立即重启');
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
statusError.value = friendlyError(error, '获取更新失败');
|
||||
} finally {
|
||||
fetchingUpdate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restartNow() {
|
||||
const version = updateStatus.value?.latestVersion;
|
||||
if (!version || restarting.value || !canRestart.value) return;
|
||||
restarting.value = true;
|
||||
statusError.value = '';
|
||||
stopRefreshCountdown();
|
||||
try {
|
||||
updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启');
|
||||
awaitingRefresh.value = true;
|
||||
MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新');
|
||||
await loadHistory(true);
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
statusError.value = friendlyError(error, '立即重启失败');
|
||||
} finally {
|
||||
restarting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user