This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user