feat: add managed online updates
Signed Release / release (push) Successful in 9m24s

This commit is contained in:
Qiufeng
2026-08-04 20:05:50 +08:00
parent 4f2ea26e8e
commit d3892320dd
20 changed files with 1365 additions and 111 deletions
@@ -49,6 +49,7 @@ export type OaMenuItem = {
/** page archetype the builder implements: list | form | detail | portal | tree | calendar | board | settings | report */
kind: 'list' | 'form' | 'detail' | 'portal' | 'tree' | 'calendar' | 'board' | 'settings' | 'report'
path: string
adminOnly?: boolean
}
export type OaModule = {
@@ -98,7 +99,7 @@ export const oaModules: OaModule[] = [
{ key: 'workbench', label: '工作台', kind: 'portal', path: '/appdev/workbench' },
{ key: 'appmgr', label: '应用管理中心', kind: 'list', path: '/appdev/appmgr' },
{ key: 'ops', label: '运维中心', kind: 'settings', path: '/appdev/ops' },
{ key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update' },
{ key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update', adminOnly: true },
{ key: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' },
{ key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' },
{ key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' }
@@ -26,13 +26,20 @@ import {
userShortcuts
} from './shortcuts'
import { useSession, currentUserName, currentDeptName } from './session'
import { oaApi, type OaMessage } from './api'
import { oaApi, type OaMessage, type SystemUpdateStatus } from './api'
const route = useRoute()
const router = useRouter()
const session = useSession()
const userInitial = computed(() => currentUserName.value.slice(0, 1) || '我')
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const visibleModules = computed<OaModule[]>(() => oaModules
.map((module) => ({
...module,
children: module.children.filter((child) => !child.adminOnly || isAdmin.value)
}))
.filter((module) => module.children.length > 0))
async function handleLogout() {
await session.logout()
ElMessage.success('已退出登录')
@@ -42,6 +49,10 @@ async function handleLogout() {
const unreadCount = ref(0)
const messages = ref<OaMessage[]>([])
let unreadTimer: ReturnType<typeof setInterval> | undefined
let updateTimer: ReturnType<typeof setInterval> | undefined
const updateStatus = ref<SystemUpdateStatus | null>(null)
const updateAvailable = computed(() => updateStatus.value?.updateAvailable === true)
const updateVersion = computed(() => updateStatus.value?.latestVersion || '')
async function loadUnread() {
try {
@@ -58,6 +69,14 @@ async function loadMessages() {
messages.value = []
}
}
async function loadUpdateStatus() {
if (!isAdmin.value) return
try {
updateStatus.value = await oaApi.getSystemUpdateStatus()
} catch {
// The updater must never block the application shell.
}
}
async function readAllMessages() {
try {
await oaApi.markAllMessagesRead()
@@ -108,11 +127,14 @@ function msgTagType(type: string): 'warning' | 'danger' | 'success' | 'info' {
onMounted(() => {
void loadUnread()
void loadUpdateStatus()
// 轻量轮询未读数(30s),让铃铛徽标随新待办/退回/办结即时更新。
unreadTimer = setInterval(() => void loadUnread(), 30000)
updateTimer = setInterval(() => void loadUpdateStatus(), 10 * 60 * 1000)
})
onBeforeUnmount(() => {
if (unreadTimer) clearInterval(unreadTimer)
if (updateTimer) clearInterval(updateTimer)
})
const globalQuery = ref('')
@@ -157,14 +179,14 @@ const currentSpaceLabel = computed(
// The active top module is whichever module owns the current route path.
const activeModuleId = computed(() => {
const match = oaModules.find((module) => route.path.startsWith(module.path))
const match = visibleModules.value.find((module) => route.path.startsWith(module.path))
return match?.id || ''
})
// ----- 侧边导航(el-menu-----
// 当前高亮项:优先精确命中某子页路径,否则取最长前缀匹配(兼容 /collab/handle?id= 等子路由)。
const activeMenu = computed(() => {
const paths = oaModules.flatMap((module) => module.children.map((child) => child.path))
const paths = visibleModules.value.flatMap((module) => module.children.map((child) => child.path))
if (paths.includes(route.path)) return route.path
const prefixHit = paths
.filter((p) => route.path.startsWith(p))
@@ -253,7 +275,7 @@ function runGlobalSearch() {
<div class="oa-appcenter__head">应用中心</div>
<div class="oa-appcenter__grid">
<button
v-for="module in oaModules"
v-for="module in visibleModules"
:key="module.id"
type="button"
class="oa-appcenter__item"
@@ -315,9 +337,18 @@ function runGlobalSearch() {
</el-scrollbar>
</div>
</el-popover>
<el-tooltip content="设置" placement="bottom">
<el-button class="oa-tools__btn" :icon="Setting" text circle @click="navigate('/hr/worktime')" />
</el-tooltip>
<el-badge
v-if="isAdmin"
:is-dot="updateAvailable"
:hidden="!updateAvailable"
class="oa-tools__update-badge"
>
<el-tooltip :content="updateAvailable ? `发现新版本 ${updateVersion}` : '系统更新'" placement="bottom">
<el-button class="oa-tools__btn oa-update-entry" :icon="Setting" text @click="navigate('/appdev/update')">
系统更新
</el-button>
</el-tooltip>
</el-badge>
<el-dropdown trigger="click">
<span class="oa-avatar">
<el-avatar :size="30" class="oa-avatar__img">{{ userInitial }}</el-avatar>
@@ -327,6 +358,9 @@ function runGlobalSearch() {
<el-dropdown-item disabled>{{ currentUserName }}{{ currentDeptName ? ' · ' + currentDeptName : '' }}</el-dropdown-item>
<el-dropdown-item divided @click="navigate('/')">个人空间</el-dropdown-item>
<el-dropdown-item @click="navigate('/contacts')">通讯录</el-dropdown-item>
<el-dropdown-item v-if="isAdmin" @click="navigate('/appdev/update')">
{{ updateAvailable ? `系统更新 · ${updateVersion}` : '系统更新' }}
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -346,7 +380,7 @@ function runGlobalSearch() {
unique-opened
@select="onMenuSelect"
>
<el-sub-menu v-for="module in oaModules" :key="module.id" :index="module.id">
<el-sub-menu v-for="module in visibleModules" :key="module.id" :index="module.id">
<template #title>
<el-icon class="oa-menu__icon"><component :is="module.icon" /></el-icon>
<span class="oa-menu__label">{{ module.label }}</span>
@@ -457,8 +491,19 @@ function runGlobalSearch() {
</button>
</div>
<button
v-if="isAdmin"
type="button"
class="oa-mobilenav__update"
@click="goMobile('/appdev/update')"
>
<el-icon><Setting /></el-icon>
<span>系统更新</span>
<el-tag v-if="updateAvailable" type="warning" effect="plain" size="small">{{ updateVersion }}</el-tag>
</button>
<el-collapse class="oa-mobilenav__modules" accordion>
<el-collapse-item v-for="module in oaModules" :key="module.id" :name="module.id">
<el-collapse-item v-for="module in visibleModules" :key="module.id" :name="module.id">
<template #title>
<el-icon class="oa-mobilenav__mod-icon"><component :is="module.icon" /></el-icon>
<span class="oa-mobilenav__mod-label">{{ module.label }}</span>
@@ -569,6 +614,16 @@ function runGlobalSearch() {
right: 10px;
}
.oa-tools__update-badge :deep(.el-badge__content.is-dot) {
top: 6px;
right: 8px;
}
.oa-update-entry {
padding-inline: var(--erp-space-2);
font-size: var(--erp-font-size-sm);
}
.oa-search__hint {
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-text-subtle);
@@ -977,6 +1032,27 @@ function runGlobalSearch() {
border-top: 0;
}
.oa-mobilenav__update {
display: flex;
align-items: center;
gap: var(--erp-space-2);
width: 100%;
min-height: 44px;
padding: var(--erp-space-2) var(--erp-space-3);
margin: var(--erp-space-3) 0;
color: var(--erp-color-text);
font-size: var(--erp-font-size-sm);
font-weight: 650;
text-align: left;
background: var(--erp-color-surface-muted);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-sm);
}
.oa-mobilenav__update span {
flex: 1;
}
.oa-mobilenav__mod-icon {
margin-right: var(--erp-space-2);
color: var(--erp-color-primary);
@@ -1036,6 +1112,14 @@ function runGlobalSearch() {
.oa-tools {
gap: 0;
}
.oa-update-entry {
width: 36px;
padding: 0;
font-size: 0;
}
.oa-update-entry :deep(.el-icon) {
font-size: var(--erp-font-size-lg);
}
.oa-avatar {
margin-left: var(--erp-space-1);
}
@@ -11,6 +11,7 @@ export interface OaSession {
deptId: number | null
title: string | null
email: string | null
roles: string[]
}
/** POST /auth/login -> stores the token and returns the session. */
@@ -189,6 +189,8 @@ export const oaApi = {
// 统一预警(全平台期限/异常聚合)
listAlerts: alertsApi.listAlerts,
// 系统更新
getSystemUpdateConfig: updateApi.getSystemUpdateConfig,
saveSystemUpdateConfig: updateApi.saveSystemUpdateConfig,
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate
@@ -216,7 +218,9 @@ export type {
ReportRunRow, ReportRunResult
} from './reportdefs'
export type { Alert } from './alerts'
export type { SystemUpdateStatus, UpdatePhase, ReleaseAsset } from './update'
export type {
SystemUpdateStatus, SystemUpdateConfig, SystemUpdateConfigInput, UpdatePhase, ReleaseAsset
} from './update'
export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
} from './masterdata'
@@ -36,6 +36,33 @@ export interface SystemUpdateStatus {
error: string | null
}
export interface SystemUpdateConfig {
enabled: boolean
giteaBaseUrl: string
repository: string
channel: 'stable' | 'preview'
tokenConfigured: boolean
allowInsecureHttp: boolean
}
export interface SystemUpdateConfigInput {
enabled: boolean
giteaBaseUrl: string
repository: string
channel: 'stable' | 'preview'
token: string
clearToken: boolean
allowInsecureHttp: boolean
}
export function getSystemUpdateConfig() {
return http.get<SystemUpdateConfig>('/system-update/config')
}
export function saveSystemUpdateConfig(input: SystemUpdateConfigInput) {
return http.put<SystemUpdateConfig>('/system-update/config', input)
}
export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status')
}
@@ -1,15 +1,49 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Refresh, Warning } from '@element-plus/icons-vue'
import {
Check,
Connection,
Download,
Refresh,
Setting,
Warning
} from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import { oaApi, OaApiError, type SystemUpdateStatus, type UpdatePhase } from '../../api'
import {
oaApi,
OaApiError,
type SystemUpdateConfig,
type SystemUpdateConfigInput,
type SystemUpdateStatus,
type UpdatePhase
} from '../../api'
import { useSession } from '../../session'
const router = useRouter()
const session = useSession()
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const status = ref<SystemUpdateStatus | null>(null)
const config = ref<SystemUpdateConfig | null>(null)
const sourceForm = reactive<SystemUpdateConfigInput>({
enabled: true,
giteaBaseUrl: '',
repository: 'awaioi/ERP',
channel: 'stable',
token: '',
clearToken: false,
allowInsecureHttp: false
})
const loading = ref(false)
const checking = ref(false)
const saving = ref(false)
const installing = ref(false)
const reconnecting = ref(false)
const pollFailures = ref(0)
let pollTimer: number | undefined
let pollRequestRunning = false
const activePhases = new Set<UpdatePhase>([
'STARTING', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'ROLLING_BACK'
@@ -27,27 +61,36 @@ const phaseLabels: Record<UpdatePhase, string> = {
RESTARTING: '正在重启',
SUCCEEDED: '更新完成',
ROLLING_BACK: '正在回滚',
ROLLED_BACK: '已回滚',
ROLLED_BACK: '已自动回滚',
FAILED: '更新失败'
}
const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
const configLocked = computed(() => busy.value || reconnecting.value)
const canCheck = computed(() => Boolean(
sourceForm.enabled && sourceForm.giteaBaseUrl && !configLocked.value
))
const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
))
const phaseTone = computed(() => {
const phaseTone = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
const phase = status.value?.phase
if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger'
if (phase === 'AVAILABLE') return 'warning'
if (phase === 'SUCCEEDED' || phase === 'UP_TO_DATE') return 'success'
return 'info'
})
const progressStatus = computed<'success' | 'exception' | undefined>(() => {
if (status.value?.phase === 'SUCCEEDED') return 'success'
if (status.value?.phase === 'FAILED' || status.value?.phase === 'ROLLED_BACK') return 'exception'
return undefined
})
function apiMessage(error: unknown, fallback: string) {
return error instanceof OaApiError ? error.message : fallback
}
function formatDate(value: string | null) {
function formatDate(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
@@ -59,16 +102,103 @@ function formatBytes(size: number) {
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
function verificationLabel(name: string) {
if (name === 'SHA256SUMS.sig') return 'Ed25519 签名'
if (name === 'SHA256SUMS') return 'SHA-256 清单'
if (name.endsWith('.tar.gz') || name.endsWith('.jar')) return '签名清单校验'
return '-'
}
function applyConfig(value: SystemUpdateConfig) {
config.value = value
sourceForm.enabled = value.enabled
sourceForm.giteaBaseUrl = value.giteaBaseUrl
sourceForm.repository = value.repository
sourceForm.channel = value.channel
sourceForm.token = ''
sourceForm.clearToken = false
sourceForm.allowInsecureHttp = value.allowInsecureHttp
}
async function loadConfig(silent = false) {
try {
applyConfig(await oaApi.getSystemUpdateConfig())
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '更新源加载失败'))
}
}
async function loadStatus(silent = false) {
if (!silent) loading.value = true
try {
status.value = await oaApi.getSystemUpdateStatus()
reconnecting.value = false
pollFailures.value = 0
if (busy.value) startPolling()
else stopPolling()
} catch (error) {
if (busy.value || status.value?.phase === 'RESTARTING' || reconnecting.value) {
reconnecting.value = true
pollFailures.value += 1
startPolling()
return
}
if (!silent) ElMessage.error(apiMessage(error, '更新状态加载失败'))
}
}
async function loadPage() {
loading.value = true
try {
await Promise.all([loadConfig(), loadStatus()])
} finally {
if (!silent) loading.value = false
loading.value = false
}
}
function validateSource() {
const baseUrl = sourceForm.giteaBaseUrl.trim()
const repository = sourceForm.repository.trim()
if (sourceForm.enabled && !baseUrl) {
ElMessage.warning('请填写 Gitea 地址')
return false
}
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
ElMessage.warning('Gitea 地址必须以 https:// 或 http:// 开头')
return false
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository)) {
ElMessage.warning('仓库格式必须为 owner/repository')
return false
}
if (baseUrl.startsWith('http://') && !sourceForm.allowInsecureHttp) {
ElMessage.warning('HTTP 更新源必须显式开启允许 HTTP')
return false
}
return true
}
async function saveSource(checkAfterSave: boolean) {
if (configLocked.value) {
ElMessage.warning('更新任务执行期间不能修改更新源')
return
}
if (!validateSource()) return
saving.value = true
try {
const saved = await oaApi.saveSystemUpdateConfig({
...sourceForm,
giteaBaseUrl: sourceForm.giteaBaseUrl.trim(),
repository: sourceForm.repository.trim(),
token: sourceForm.token.trim()
})
applyConfig(saved)
ElMessage.success('更新源已保存')
await loadStatus(true)
if (checkAfterSave && saved.enabled) await checkUpdate()
} catch (error) {
ElMessage.error(apiMessage(error, '更新源保存失败'))
} finally {
saving.value = false
}
}
@@ -90,8 +220,8 @@ async function installUpdate() {
if (!version) return
try {
await ElMessageBox.confirm(
`确认安装 ${version} 并重启服务?`,
'安装更新',
`确认安装 ${version}?服务将自动重启,健康检查失败会切回上一版本。`,
'安装正式更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
)
} catch {
@@ -100,6 +230,8 @@ async function installUpdate() {
installing.value = true
try {
status.value = await oaApi.installSystemUpdate(version)
reconnecting.value = false
pollFailures.value = 0
ElMessage.success('更新任务已启动')
startPolling()
} catch (error) {
@@ -111,7 +243,15 @@ async function installUpdate() {
function startPolling() {
if (pollTimer !== undefined) return
pollTimer = window.setInterval(() => loadStatus(true), 3000)
pollTimer = window.setInterval(async () => {
if (pollRequestRunning) return
pollRequestRunning = true
try {
await loadStatus(true)
} finally {
pollRequestRunning = false
}
}, 2500)
}
function stopPolling() {
@@ -120,19 +260,35 @@ function stopPolling() {
pollTimer = undefined
}
onMounted(() => loadStatus())
async function initializePage() {
if (!isAdmin.value) {
ElMessage.error('系统更新仅限管理员访问')
await router.replace('/')
return
}
await loadPage()
}
onMounted(initializePage)
onBeforeUnmount(stopPolling)
</script>
<template>
<div class="update-page" v-loading="loading">
<div v-if="isAdmin" class="update-page" v-loading="loading">
<ErpPageHeader
title="系统更新"
:crumbs="['应用定制平台', '系统更新']"
description="正式版本"
:description="`当前版本 ${status?.currentVersion || '-'}`"
>
<template #actions>
<el-button :icon="Refresh" :loading="checking" @click="checkUpdate">检查更新</el-button>
<el-button
:icon="Refresh"
:loading="checking"
:disabled="!canCheck || saving"
@click="checkUpdate"
>
检查更新
</el-button>
<el-button
type="primary"
:icon="Download"
@@ -146,14 +302,33 @@ onBeforeUnmount(stopPolling)
</ErpPageHeader>
<el-alert
v-if="status && !status.configured"
type="warning"
v-if="reconnecting"
type="info"
:closable="false"
show-icon
title="在线更新尚未配置"
title="正式服务正在重启,页面会自动重新连接"
:description="`已重试 ${pollFailures} 次`"
class="update-alert"
/>
<el-alert
v-else-if="status?.phase === 'SUCCEEDED'"
type="success"
:closable="false"
show-icon
title="新版本已启动并通过健康检查"
class="update-alert"
/>
<el-alert
v-else-if="status?.phase === 'ROLLED_BACK'"
type="error"
:closable="false"
show-icon
title="新版本健康检查失败,系统已自动切回上一版本"
:description="status.error || undefined"
class="update-alert"
/>
<section class="update-summary">
<section class="update-summary" aria-label="版本状态">
<div class="version-block">
<span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong>
@@ -170,42 +345,113 @@ onBeforeUnmount(stopPolling)
</div>
<div class="version-block">
<span class="field-label">检查时间</span>
<span>{{ formatDate(status?.checkedAt || null) }}</span>
<span>{{ formatDate(status?.checkedAt) }}</span>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK')" class="update-progress">
<section class="source-section">
<div class="section-heading">
<h3>{{ status.message }}</h3>
<div class="heading-title"><el-icon><Setting /></el-icon><h2>更新源设置</h2></div>
<el-tag v-if="config?.tokenConfigured" type="success" effect="plain" size="small">Token 已配置</el-tag>
<el-tag v-else type="info" effect="plain" size="small">公开仓库</el-tag>
</div>
<el-form :disabled="configLocked" label-position="top" class="source-form" @submit.prevent>
<el-form-item label="在线更新">
<el-switch v-model="sourceForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item label="Gitea 地址" class="source-form__wide">
<el-input v-model="sourceForm.giteaBaseUrl" placeholder="https://git.example.com" clearable />
</el-form-item>
<el-form-item label="仓库">
<el-input v-model="sourceForm.repository" placeholder="owner/repository" />
</el-form-item>
<el-form-item label="更新通道">
<el-select v-model="sourceForm.channel" style="width: 100%">
<el-option label="正式版" value="stable" />
<el-option label="预览版" value="preview" />
</el-select>
</el-form-item>
<el-form-item label="Gitea Token" class="source-form__wide">
<el-input
v-model="sourceForm.token"
type="password"
show-password
autocomplete="new-password"
:disabled="configLocked || sourceForm.clearToken"
:placeholder="config?.tokenConfigured ? '已配置,留空保持不变' : '公开仓库可留空'"
/>
</el-form-item>
<el-form-item label="HTTP 更新源">
<el-switch v-model="sourceForm.allowInsecureHttp" active-text="允许" inactive-text="禁止" />
</el-form-item>
<el-form-item v-if="config?.tokenConfigured" label="凭据操作">
<el-checkbox
v-model="sourceForm.clearToken"
:disabled="configLocked || Boolean(sourceForm.token)"
>
清除现有 Token
</el-checkbox>
</el-form-item>
</el-form>
<div class="source-actions">
<el-button :loading="saving" :disabled="configLocked || checking" @click="saveSource(false)">保存设置</el-button>
<el-button
type="primary"
:icon="Connection"
:loading="saving || checking"
:disabled="configLocked"
@click="saveSource(true)"
>
保存并检查
</el-button>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')" class="update-progress">
<div class="section-heading">
<div class="heading-title">
<el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon>
<el-icon v-else><Check /></el-icon>
<h2>执行结果</h2>
</div>
<span>{{ status.progress }}%</span>
</div>
<el-progress
:percentage="status.progress"
:status="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' ? 'exception' : undefined"
:status="progressStatus"
:stroke-width="10"
/>
<p class="progress-message">{{ status.message }}</p>
<p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p>
</section>
<section class="release-section">
<div class="section-heading">
<h3>版本信息</h3>
<span>{{ formatDate(status?.publishedAt || null) }}</span>
<div class="heading-title"><h2>版本更新日志</h2></div>
<span>发布日期 {{ formatDate(status?.publishedAt) }}</span>
</div>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
</section>
<section class="release-section">
<div class="section-heading"><h3>发布文件</h3></div>
<div class="section-heading"><div class="heading-title"><h2>发布文件与校验</h2></div></div>
<el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件">
<el-table-column prop="name" label="文件" min-width="280" />
<el-table-column label="大小" width="120">
<template #default="{ row }">{{ formatBytes(row.size) }}</template>
</el-table-column>
<el-table-column label="校验" width="140">
<el-table-column label="校验方式" min-width="160">
<template #default="{ row }">
<el-tag v-if="row.name === 'SHA256SUMS.sig'" type="success" effect="plain" size="small">Ed25519</el-tag>
<el-tag v-else-if="row.name === 'SHA256SUMS'" type="info" effect="plain" size="small">SHA-256</el-tag>
<el-tag
v-if="verificationLabel(row.name) !== '-'"
:type="row.name === 'SHA256SUMS.sig' ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ verificationLabel(row.name) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
@@ -220,6 +466,10 @@ onBeforeUnmount(stopPolling)
margin: 0 auto;
}
.update-alert {
margin-bottom: var(--erp-space-4);
}
.update-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -238,8 +488,11 @@ onBeforeUnmount(stopPolling)
}
.version-block strong,
.version-block > span:last-child {
.version-block > span:last-child,
.version-block > .el-tag {
display: block;
width: fit-content;
max-width: 100%;
margin-top: var(--erp-space-2);
overflow-wrap: anywhere;
color: var(--erp-color-text);
@@ -251,40 +504,73 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-xs);
}
.source-section,
.update-progress,
.release-section {
padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.section-heading {
.section-heading,
.heading-title,
.source-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-3);
}
.section-heading h3 {
margin: 0;
.section-heading {
justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-4);
}
.heading-title {
gap: var(--erp-space-2);
min-width: 0;
color: var(--erp-color-text);
}
.heading-title h2 {
margin: 0;
font-size: var(--erp-font-size-base);
}
.section-heading span {
.section-heading > span {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.source-form {
display: grid;
grid-template-columns: 140px minmax(260px, 2fr) minmax(200px, 1fr) 160px;
gap: 0 var(--erp-space-4);
}
.source-form__wide {
min-width: 0;
grid-column: span 2;
}
.source-actions {
justify-content: flex-end;
gap: var(--erp-space-2);
}
.release-notes {
min-height: 80px;
min-height: 96px;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
line-height: 1.7;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.progress-message {
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
}
.error-line {
display: flex;
align-items: center;
@@ -294,6 +580,16 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-sm);
}
@media (max-width: 1080px) {
.source-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.source-form__wide {
grid-column: span 1;
}
}
@media (max-width: 900px) {
.update-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -309,7 +605,8 @@ onBeforeUnmount(stopPolling)
}
@media (max-width: 560px) {
.update-summary {
.update-summary,
.source-form {
grid-template-columns: 1fr;
}
@@ -322,5 +619,20 @@ onBeforeUnmount(stopPolling)
.version-block:last-child {
border-bottom: 0;
}
.section-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.source-actions {
align-items: stretch;
flex-direction: column;
}
.source-actions .el-button {
width: 100%;
margin-left: 0;
}
}
</style>