1432 lines
43 KiB
Vue
1432 lines
43 KiB
Vue
<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>
|