feat: simplify online update center
Signed Release / release (push) Successful in 9m41s

This commit is contained in:
Qiufeng
2026-08-04 21:25:43 +08:00
parent d3892320dd
commit abba079dde
8 changed files with 382 additions and 302 deletions
@@ -189,9 +189,8 @@ export const oaApi = {
// 统一预警(全平台期限/异常聚合)
listAlerts: alertsApi.listAlerts,
// 系统更新
getSystemUpdateConfig: updateApi.getSystemUpdateConfig,
saveSystemUpdateConfig: updateApi.saveSystemUpdateConfig,
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
getSystemUpdateReleases: updateApi.getSystemUpdateReleases,
checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate
}
@@ -219,7 +218,7 @@ export type {
} from './reportdefs'
export type { Alert } from './alerts'
export type {
SystemUpdateStatus, SystemUpdateConfig, SystemUpdateConfigInput, UpdatePhase, ReleaseAsset
SystemUpdateStatus, ReleaseHistoryItem, UpdatePhase, ReleaseAsset
} from './update'
export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
@@ -36,37 +36,22 @@ 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 interface ReleaseHistoryItem {
version: string
publishedAt: string | null
releaseNotes: string
current: boolean
latest: boolean
}
export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status')
}
export function getSystemUpdateReleases() {
return http.get<ReleaseHistoryItem[]>('/system-update/releases', undefined, { timeoutMs: 30000 })
}
export function checkSystemUpdate() {
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
}
@@ -1,21 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Check,
Connection,
Download,
Refresh,
Setting,
Warning
} from '@element-plus/icons-vue'
import { Check, Download, Refresh, Warning } from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import {
oaApi,
OaApiError,
type SystemUpdateConfig,
type SystemUpdateConfigInput,
type ReleaseHistoryItem,
type SystemUpdateStatus,
type UpdatePhase
} from '../../api'
@@ -26,19 +18,10 @@ 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 releases = ref<ReleaseHistoryItem[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const checking = ref(false)
const saving = ref(false)
const installing = ref(false)
const reconnecting = ref(false)
const pollFailures = ref(0)
@@ -66,13 +49,33 @@ const phaseLabels: Record<UpdatePhase, string> = {
}
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
status.value?.configured
&& !busy.value
&& !reconnecting.value
&& !checking.value
&& !installing.value
))
const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
status.value?.configured
&& status.value.updateAvailable
&& status.value.latestVersion
&& !busy.value
&& !reconnecting.value
&& !checking.value
&& !installing.value
))
const remoteLatest = computed(() => releases.value.find(release => release.latest) || releases.value[0] || null)
const latestVersion = computed(() => status.value?.latestVersion || remoteLatest.value?.version || null)
const latestRelease = computed(() => {
const checkedVersion = status.value?.latestVersion
if (checkedVersion) {
return releases.value.find(release => release.version === checkedVersion) || remoteLatest.value
}
return remoteLatest.value
})
const latestPublishedAt = computed(() => status.value?.publishedAt || latestRelease.value?.publishedAt || null)
const latestReleaseNotes = computed(() => status.value?.releaseNotes || latestRelease.value?.releaseNotes || '')
const phaseTone = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
const phase = status.value?.phase
if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger'
@@ -96,36 +99,9 @@ function formatDate(value: string | null | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatBytes(size: number) {
if (size <= 0) return '-'
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB`
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, '更新源加载失败'))
}
function formatVersion(value: string | null | undefined) {
if (!value) return '-'
return /^v/i.test(value) ? value : `v${value}`
}
async function loadStatus(silent = false) {
@@ -146,66 +122,33 @@ async function loadStatus(silent = false) {
}
}
async function loadReleases(silent = false) {
historyLoading.value = true
try {
releases.value = await oaApi.getSystemUpdateReleases()
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '历史版本加载失败'))
} finally {
historyLoading.value = false
}
}
async function loadPage() {
loading.value = true
try {
await Promise.all([loadConfig(), loadStatus()])
await loadStatus()
if (status.value?.configured) await loadReleases()
} finally {
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
}
}
async function checkUpdate() {
if (!canCheck.value) return
checking.value = true
try {
status.value = await oaApi.checkSystemUpdate()
await loadReleases(true)
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
} catch (error) {
ElMessage.error(apiMessage(error, '检查更新失败'))
@@ -216,18 +159,20 @@ async function checkUpdate() {
}
async function installUpdate() {
if (!canInstall.value) return
const version = status.value?.latestVersion
if (!version) return
installing.value = true
try {
await ElMessageBox.confirm(
`确认安装 ${version}?服务将自动重启,健康检查失败会切回上一版本。`,
`确认安装 ${formatVersion(version)}?服务将自动重启,健康检查失败会切回上一版本。`,
'安装正式更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
)
} catch {
installing.value = false
return
}
installing.value = true
try {
status.value = await oaApi.installSystemUpdate(version)
reconnecting.value = false
@@ -278,13 +223,13 @@ onBeforeUnmount(stopPolling)
<ErpPageHeader
title="系统更新"
:crumbs="['应用定制平台', '系统更新']"
:description="`当前版本 ${status?.currentVersion || '-'}`"
:description="`当前版本 ${formatVersion(status?.currentVersion)}`"
>
<template #actions>
<el-button
:icon="Refresh"
:loading="checking"
:disabled="!canCheck || saving"
:disabled="!canCheck"
@click="checkUpdate"
>
检查更新
@@ -302,7 +247,16 @@ onBeforeUnmount(stopPolling)
</ErpPageHeader>
<el-alert
v-if="reconnecting"
v-if="status && !status.configured"
type="warning"
:closable="false"
show-icon
title="服务器尚未启用在线更新"
description="请通过安装器完成正式服务配置。"
class="update-alert"
/>
<el-alert
v-else-if="reconnecting"
type="info"
:closable="false"
show-icon
@@ -331,11 +285,11 @@ onBeforeUnmount(stopPolling)
<section class="update-summary" aria-label="版本状态">
<div class="version-block">
<span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong>
<strong>{{ formatVersion(status?.currentVersion) }}</strong>
</div>
<div class="version-block">
<span class="field-label">最新版本</span>
<strong>{{ status?.latestVersion || '-' }}</strong>
<span class="field-label">在线最新版本</span>
<strong>{{ formatVersion(latestVersion) }}</strong>
</div>
<div class="version-block">
<span class="field-label">更新状态</span>
@@ -344,77 +298,20 @@ onBeforeUnmount(stopPolling)
</el-tag>
</div>
<div class="version-block">
<span class="field-label">检查时间</span>
<span class="field-label">最后检查时间</span>
<span>{{ formatDate(status?.checkedAt) }}</span>
</div>
</section>
<section class="source-section">
<div class="section-heading">
<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">
<section
v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')"
class="update-section"
>
<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>
<h2>更新进度</h2>
</div>
<span>{{ status.progress }}%</span>
</div>
@@ -427,35 +324,36 @@ onBeforeUnmount(stopPolling)
<p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p>
</section>
<section class="release-section">
<section class="update-section">
<div class="section-heading">
<div class="heading-title"><h2>版本更新日志</h2></div>
<span>发布日期 {{ formatDate(status?.publishedAt) }}</span>
<div class="heading-title"><h2>最新版本更新日志</h2></div>
<span>发布日期 {{ formatDate(latestPublishedAt) }}</span>
</div>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
<div class="release-notes">{{ latestReleaseNotes || '暂无发布说明' }}</div>
</section>
<section class="release-section">
<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="校验方式" min-width="160">
<template #default="{ row }">
<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>
</el-table>
<section class="update-section history-section">
<div class="section-heading">
<div class="heading-title"><h2>历史版本</h2></div>
<span>{{ releases.length ? `${releases.length} 个正式版本` : '暂无记录' }}</span>
</div>
<el-skeleton v-if="historyLoading" :rows="4" animated />
<div v-else-if="releases.length" class="release-history">
<article v-for="release in releases" :key="release.version" class="release-entry">
<div class="release-version">
<strong>{{ formatVersion(release.version) }}</strong>
<div class="release-tags">
<el-tag v-if="release.current" type="primary" effect="plain" size="small">当前版本</el-tag>
<el-tag v-if="release.latest" type="success" effect="plain" size="small">最新版本</el-tag>
</div>
<time>{{ formatDate(release.publishedAt) }}</time>
</div>
<div class="release-notes release-notes--history">
{{ release.releaseNotes || '暂无发布说明' }}
</div>
</article>
</div>
<el-empty v-else description="暂无历史版本" :image-size="72" />
</section>
</div>
</template>
@@ -504,16 +402,14 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-xs);
}
.source-section,
.update-progress,
.release-section {
.update-section {
padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.section-heading,
.heading-title,
.source-actions {
.release-tags {
display: flex;
align-items: center;
}
@@ -540,24 +436,8 @@ onBeforeUnmount(stopPolling)
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: 96px;
min-height: 72px;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
line-height: 1.75;
@@ -580,14 +460,47 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-sm);
}
@media (max-width: 1080px) {
.source-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.release-history {
border-top: 1px solid var(--erp-color-border-soft);
}
.source-form__wide {
grid-column: span 1;
}
.release-entry {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
gap: var(--erp-space-5);
padding: var(--erp-space-4) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.release-entry:last-child {
border-bottom: 0;
}
.release-version {
min-width: 0;
}
.release-version > strong {
display: block;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.release-tags {
flex-wrap: wrap;
gap: var(--erp-space-1);
margin-top: var(--erp-space-2);
}
.release-version time {
display: block;
margin-top: var(--erp-space-2);
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.release-notes--history {
min-height: 0;
}
@media (max-width: 900px) {
@@ -605,8 +518,7 @@ onBeforeUnmount(stopPolling)
}
@media (max-width: 560px) {
.update-summary,
.source-form {
.update-summary {
grid-template-columns: 1fr;
}
@@ -625,14 +537,9 @@ onBeforeUnmount(stopPolling)
flex-wrap: wrap;
}
.source-actions {
align-items: stretch;
flex-direction: column;
}
.source-actions .el-button {
width: 100%;
margin-left: 0;
.release-entry {
grid-template-columns: 1fr;
gap: var(--erp-space-3);
}
}
</style>