feat: add signed PostgreSQL release updates
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user