655 lines
19 KiB
Vue
655 lines
19 KiB
Vue
<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>
|