feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
@@ -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>