feat: add signed PostgreSQL release updates

This commit is contained in:
Qiufeng
2026-08-03 23:45:32 +08:00
parent 2524a37a07
commit f6e22cb670
99 changed files with 44671 additions and 284 deletions
@@ -98,6 +98,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: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' },
{ key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' },
{ key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' }
@@ -75,7 +75,15 @@ export interface RequestOptions {
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
const base = path.startsWith('http') ? path : OA_API_BASE + (path.startsWith('/') ? path : '/' + path)
// Older generated pages sometimes pass the already-prefixed /api/oa path.
// Normalize it so both call styles resolve to the same backend endpoint.
const relativePath = OA_API_BASE.endsWith('/api/oa')
&& (path === '/api/oa' || path.startsWith('/api/oa/'))
? path.slice('/api/oa'.length) || '/'
: path
const base = relativePath.startsWith('http')
? relativePath
: OA_API_BASE + (relativePath.startsWith('/') ? relativePath : '/' + relativePath)
if (!query) return base
const usp = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
@@ -54,13 +54,14 @@ import * as designApi from './design'
import * as engExtApi from './engext'
import * as governanceApi from './governance'
import * as alertsApi from './alerts'
import * as updateApi from './update'
export {
authApi, templateApi, instanceApi, meetingApi, scheduleApi, announcementApi, docApi, orgApi,
projectApi, collabDocApi, communityApi, tripApi, reportApi, reportDefApi, masterApi, paymentApi, sealUseApi,
archiveApi, crmApi, ehsApi, budgetApi, bidApi, intelApi, rdApi, contractCenterApi, costingApi, crawlApi,
searchApi, fundPoolApi, rdExtraApi, qhseApi, opsApi, mfgApi, financeApi, labApi, designApi, engExtApi,
governanceApi, alertsApi
governanceApi, alertsApi, updateApi
}
export const oaApi = {
@@ -186,7 +187,11 @@ export const oaApi = {
// 监测中心(应用定制平台运行聚合)
getAppMonitoring: reportApi.getAppMonitoring,
// 统一预警(全平台期限/异常聚合)
listAlerts: alertsApi.listAlerts
listAlerts: alertsApi.listAlerts,
// 系统更新
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate
}
export type { OaSession } from './auth'
@@ -211,6 +216,7 @@ export type {
ReportRunRow, ReportRunResult
} from './reportdefs'
export type { Alert } from './alerts'
export type { SystemUpdateStatus, UpdatePhase, ReleaseAsset } from './update'
export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
} from './masterdata'
@@ -0,0 +1,49 @@
import { http } from './http'
export type UpdatePhase =
| 'IDLE'
| 'CHECKING'
| 'AVAILABLE'
| 'UP_TO_DATE'
| 'STARTING'
| 'DOWNLOADING'
| 'VERIFYING'
| 'INSTALLING'
| 'RESTARTING'
| 'SUCCEEDED'
| 'ROLLING_BACK'
| 'ROLLED_BACK'
| 'FAILED'
export interface ReleaseAsset {
name: string
downloadUrl: string
size: number
}
export interface SystemUpdateStatus {
configured: boolean
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
phase: UpdatePhase
progress: number
message: string
checkedAt: string | null
publishedAt: string | null
releaseNotes: string
assets: ReleaseAsset[]
error: string | null
}
export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status')
}
export function checkSystemUpdate() {
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
}
export function installSystemUpdate(version: string) {
return http.post<SystemUpdateStatus>('/system-update/install', { version }, { timeoutMs: 30000 })
}
@@ -0,0 +1,326 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Refresh, Warning } from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import { oaApi, OaApiError, type SystemUpdateStatus, type UpdatePhase } from '../../api'
const status = ref<SystemUpdateStatus | null>(null)
const loading = ref(false)
const checking = ref(false)
const installing = ref(false)
let pollTimer: number | undefined
const activePhases = new Set<UpdatePhase>([
'STARTING', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'ROLLING_BACK'
])
const phaseLabels: Record<UpdatePhase, string> = {
IDLE: '等待检查',
CHECKING: '正在检查',
AVAILABLE: '可更新',
UP_TO_DATE: '已是最新',
STARTING: '准备更新',
DOWNLOADING: '正在下载',
VERIFYING: '正在验签',
INSTALLING: '正在安装',
RESTARTING: '正在重启',
SUCCEEDED: '更新完成',
ROLLING_BACK: '正在回滚',
ROLLED_BACK: '已回滚',
FAILED: '更新失败'
}
const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
))
const phaseTone = computed(() => {
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'
})
function apiMessage(error: unknown, fallback: string) {
return error instanceof OaApiError ? error.message : fallback
}
function formatDate(value: string | null) {
if (!value) return '-'
const date = new Date(value)
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`
}
async function loadStatus(silent = false) {
if (!silent) loading.value = true
try {
status.value = await oaApi.getSystemUpdateStatus()
if (busy.value) startPolling()
else stopPolling()
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '更新状态加载失败'))
} finally {
if (!silent) loading.value = false
}
}
async function checkUpdate() {
checking.value = true
try {
status.value = await oaApi.checkSystemUpdate()
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
} catch (error) {
ElMessage.error(apiMessage(error, '检查更新失败'))
await loadStatus(true)
} finally {
checking.value = false
}
}
async function installUpdate() {
const version = status.value?.latestVersion
if (!version) return
try {
await ElMessageBox.confirm(
`确认安装 ${version} 并重启服务?`,
'安装更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
)
} catch {
return
}
installing.value = true
try {
status.value = await oaApi.installSystemUpdate(version)
ElMessage.success('更新任务已启动')
startPolling()
} catch (error) {
ElMessage.error(apiMessage(error, '启动更新失败'))
} finally {
installing.value = false
}
}
function startPolling() {
if (pollTimer !== undefined) return
pollTimer = window.setInterval(() => loadStatus(true), 3000)
}
function stopPolling() {
if (pollTimer === undefined) return
window.clearInterval(pollTimer)
pollTimer = undefined
}
onMounted(() => loadStatus())
onBeforeUnmount(stopPolling)
</script>
<template>
<div class="update-page" v-loading="loading">
<ErpPageHeader
title="系统更新"
:crumbs="['应用定制平台', '系统更新']"
description="正式版本"
>
<template #actions>
<el-button :icon="Refresh" :loading="checking" @click="checkUpdate">检查更新</el-button>
<el-button
type="primary"
:icon="Download"
:disabled="!canInstall"
:loading="installing"
@click="installUpdate"
>
安装并重启
</el-button>
</template>
</ErpPageHeader>
<el-alert
v-if="status && !status.configured"
type="warning"
:closable="false"
show-icon
title="在线更新尚未配置"
/>
<section class="update-summary">
<div class="version-block">
<span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong>
</div>
<div class="version-block">
<span class="field-label">最新版本</span>
<strong>{{ status?.latestVersion || '-' }}</strong>
</div>
<div class="version-block">
<span class="field-label">更新状态</span>
<el-tag :type="phaseTone" effect="plain">
{{ status ? phaseLabels[status.phase] : '-' }}
</el-tag>
</div>
<div class="version-block">
<span class="field-label">检查时间</span>
<span>{{ formatDate(status?.checkedAt || null) }}</span>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK')" class="update-progress">
<div class="section-heading">
<h3>{{ status.message }}</h3>
<span>{{ status.progress }}%</span>
</div>
<el-progress
:percentage="status.progress"
:status="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' ? 'exception' : undefined"
:stroke-width="10"
/>
<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>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
</section>
<section class="release-section">
<div class="section-heading"><h3>发布文件</h3></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">
<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>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<style scoped>
.update-page {
max-width: 1240px;
margin: 0 auto;
}
.update-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 1px solid var(--erp-color-border-soft);
border-bottom: 1px solid var(--erp-color-border-soft);
}
.version-block {
min-width: 0;
padding: var(--erp-space-4);
border-right: 1px solid var(--erp-color-border-soft);
}
.version-block:last-child {
border-right: 0;
}
.version-block strong,
.version-block > span:last-child {
display: block;
margin-top: var(--erp-space-2);
overflow-wrap: anywhere;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.field-label {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.update-progress,
.release-section {
padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.section-heading {
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;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.section-heading span {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.release-notes {
min-height: 80px;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.error-line {
display: flex;
align-items: center;
gap: var(--erp-space-1);
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-danger);
font-size: var(--erp-font-size-sm);
}
@media (max-width: 900px) {
.update-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.version-block:nth-child(2) {
border-right: 0;
}
.version-block:nth-child(-n + 2) {
border-bottom: 1px solid var(--erp-color-border-soft);
}
}
@media (max-width: 560px) {
.update-summary {
grid-template-columns: 1fr;
}
.version-block,
.version-block:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.version-block:last-child {
border-bottom: 0;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { http } from '../../api/http'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Warning, BellFilled, Document, DataLine } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Money, List, Document } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Document, DataLine, Refresh } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Setting, DataLine, Plus, Delete } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Document, Setting, Refresh } from '@element-plus/icons-vue'
@@ -220,7 +220,7 @@
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, Refresh } from '@element-plus/icons-vue'
import { http } from '../../../../utils/http'
import { http } from '../../api/http'
interface SupervisionProject {
id: number
@@ -220,7 +220,7 @@
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, List, Delete, Refresh } from '@element-plus/icons-vue'
import { http } from '../../../../utils/http'
import { http } from '../../api/http'
interface SupervisionProject {
id: number