feat: support admin online update and deploy flow

This commit is contained in:
zhiqicloud
2026-05-22 15:15:17 +08:00
parent 9562295d8b
commit b59c3a9e3b
37 changed files with 3593 additions and 632 deletions
+302 -23
View File
@@ -120,9 +120,16 @@
:loading="loadingVersionStatus"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
:update-supported="updateSupported"
:rollback-available="rollbackAvailable"
:rolling-back="rollingBack"
:download-progress-text="updateProgressText"
:download-progress-percent="updateProgressPercent"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
@preview-release="openReleaseUpdateDialog"
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
@@ -306,9 +313,16 @@
:loading="loadingVersionStatus"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
:update-supported="updateSupported"
:rollback-available="rollbackAvailable"
:rolling-back="rollingBack"
:download-progress-text="updateProgressText"
:download-progress-percent="updateProgressPercent"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
@preview-release="openReleaseUpdateDialog"
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
<!-- Theme Toggle -->
<button
@@ -391,9 +405,21 @@
:release-url="updateInfo.release_url"
:release-notes="updateInfo.release_notes"
:published-at="updateInfo.published_at"
:dialog-title="updateDialogTitle"
:version-label="updateDialogVersionLabel"
:release-link-label="updateDialogReleaseLinkLabel"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
:update-supported="updateSupported"
:updatable="updateInfo.updatable"
:update-blocker="updateInfo.update_blocker"
:reconnect-message="reconnectMessage"
:rollback-available="rollbackAvailable"
:rolling-back="rollingBack"
:download-progress-text="updateProgressText"
:download-progress-percent="updateProgressPercent"
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
</AppShell>
</template>
@@ -408,7 +434,7 @@ import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { useToast } from '@/composables/useToast'
import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type UpdateTaskStatusResponse } from '@/api/admin'
import { announcementApi, type Announcement } from '@/api/announcements'
import { parseApiError } from '@/utils/errorParser'
import Button from '@/components/ui/button.vue'
@@ -460,7 +486,7 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
type SystemUpdatePhase = 'download' | 'restart'
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
const router = useRouter()
const route = useRoute()
@@ -490,11 +516,38 @@ const updateInfo = ref<CheckUpdateResponse | null>(null)
const versionStatus = ref<CheckUpdateResponse | null>(null)
const loadingVersionStatus = ref(false)
const applyingSystemUpdate = ref(false)
const updateSupported = ref(true)
const reconnectMessage = ref('等待服务恢复...')
const rollbackAvailable = ref(false)
const rollingBack = ref(false)
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
const updateDialogMode = ref<'latest' | 'selected'>('latest')
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
const preparedUpdateVersion = ref<string | null>(
readSessionStorageItem('aether_prepared_update_version')
)
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
let updateStatusPollTimer: number | null = null
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
const updateDialogTitle = computed(() => {
if (updateDialogMode.value === 'selected') {
return updateSupported.value ? '切换版本' : '版本详情'
}
return '发现新版本'
})
const updateDialogVersionLabel = computed(() => {
if (updateDialogMode.value === 'selected') {
return updateSupported.value ? '目标版本' : '版本标签'
}
return '最新版本'
})
const updateDialogReleaseLinkLabel = computed(() => {
if (updateDialogMode.value === 'selected') return '查看标签页'
return updateSupported.value ? '查看更新' : '查看发布'
})
watch(systemUpdatePhase, (val) => {
setSessionStorageItem('aether_update_phase', val)
@@ -508,7 +561,9 @@ watch(preparedUpdateVersion, (val) => {
})
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
return readSessionStorageItem('aether_update_phase') === 'restart' ? 'restart' : 'download'
const stored = readSessionStorageItem('aether_update_phase')
if (stored === 'restart' || stored === 'reconnecting') return stored
return 'download'
}
function readSessionStorageItem(key: string): string | null {
@@ -535,6 +590,80 @@ function removeSessionStorageItem(key: string) {
}
}
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
if (!status) return '正在下载更新包...'
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
const downloaded = status.downloaded_bytes
const total = status.total_bytes
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
return `${label} ${formatFileSize(downloaded)} / ${formatFileSize(total)}`
}
if (typeof downloaded === 'number' && downloaded > 0) {
return `${label} ${formatFileSize(downloaded)}`
}
return label
}
function formatUpdateTaskPhase(phase: string): string {
switch (phase) {
case 'downloading':
return '正在下载更新包'
case 'downloading_checksum':
return '正在下载校验文件'
case 'verifying':
return '正在校验更新包'
case 'extracting':
return '正在解压更新包'
case 'prepared':
return '更新包已准备完成'
default:
return '正在准备更新'
}
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${bytes} B`
}
async function refreshUpdateTaskStatus() {
try {
updateTaskStatus.value = await adminApi.getUpdateStatus()
} catch {
// Keep the last progress snapshot while the request is in flight or the service restarts.
}
}
async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
const deadline = Date.now() + 10 * 60 * 1000
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1000))
await refreshUpdateTaskStatus()
const status = updateTaskStatus.value
if (status?.phase === 'prepared') return status
if (status?.phase === 'failed') {
throw new Error(status.error || '下载更新失败')
}
}
throw new Error('下载更新超时')
}
function startUpdateStatusPolling() {
stopUpdateStatusPolling()
void refreshUpdateTaskStatus()
updateStatusPollTimer = window.setInterval(() => {
void refreshUpdateTaskStatus()
}, 1000)
}
function stopUpdateStatusPolling() {
if (updateStatusPollTimer !== null) {
window.clearInterval(updateStatusPollTimer)
updateStatusPollTimer = null
}
}
// 路由变化时自动关闭移动端菜单
watch(() => route.path, () => {
mobileMenuOpen.value = false
@@ -558,14 +687,28 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
return true
}
async function loadVersionStatus() {
async function loadVersionStatus(force = false) {
if (!isAdmin.value) return null
if (versionStatusLoadPromise) return versionStatusLoadPromise
loadingVersionStatus.value = true
versionStatusLoadPromise = (async () => {
try {
versionStatus.value = await adminApi.checkUpdate()
const [status, capability] = await Promise.all([
adminApi.checkUpdate(force),
adminApi.getSystemUpdateCapability().catch(() => null),
])
if (capability) {
rollbackAvailable.value = capability.rollback_available
updateSupported.value = capability.supported
}
versionStatus.value = capability?.supported === false && status.has_update
? {
...status,
updatable: false,
update_blocker: SOURCE_BUILD_UPDATE_HINT,
}
: status
syncSystemUpdatePhase(versionStatus.value)
return versionStatus.value
} catch (error) {
@@ -581,23 +724,21 @@ async function loadVersionStatus() {
}
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
if (!status?.has_update) {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
if (systemUpdatePhase.value === 'reconnecting') return
if (systemUpdatePhase.value === 'restart') {
if (!preparedUpdateVersion.value) {
systemUpdatePhase.value = 'download'
}
return
}
if (
systemUpdatePhase.value === 'restart' &&
(!preparedUpdateVersion.value || preparedUpdateVersion.value !== status.latest_version)
) {
if (!status?.has_update) {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
}
function handleVersionRefresh() {
void loadVersionStatus()
void loadVersionStatus(true)
}
function openVersionReleasePage() {
@@ -606,30 +747,88 @@ function openVersionReleasePage() {
}
}
function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse {
const currentVersion =
versionStatus.value?.current_version ||
updateInfo.value?.current_version ||
__APP_VERSION__ ||
''
const sourceBuild = !updateSupported.value
return {
current_version: currentVersion,
latest_version: release.version,
has_update: !release.is_current,
updatable: !sourceBuild && !release.is_current && release.updatable,
update_blocker: release.is_current
? '当前已是这个版本'
: sourceBuild
? SOURCE_BUILD_RELEASE_HINT
: release.update_blocker,
release_url: release.release_url,
release_notes: release.release_notes,
published_at: release.published_at,
error: null,
}
}
function openReleaseUpdateDialog(release: ReleaseEntry) {
updateDialogMode.value = 'selected'
updateInfo.value = buildUpdateInfoFromRelease(release)
if (systemUpdatePhase.value !== 'reconnecting') {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
showUpdateDialog.value = true
}
async function handleApplySystemUpdate() {
if (applyingSystemUpdate.value) return
applyingSystemUpdate.value = true
try {
const capability = await adminApi.getSystemUpdateCapability()
if (!capability.enabled) {
rollbackAvailable.value = capability.rollback_available
if (!capability.supported) {
updateSupported.value = false
showError(
capability.detail || `一键更新未启用,请先在部署环境配置 ${capability.command_env}`,
'无法启动更新'
SOURCE_BUILD_UPDATE_HINT,
'不支持在线更新'
)
return
}
updateSupported.value = true
if (systemUpdatePhase.value === 'download') {
const result = await adminApi.prepareSystemUpdate()
preparedUpdateVersion.value = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
systemUpdatePhase.value = 'restart'
success(result.message || '更新包已下载完成,请点击“立即重启”完成安装')
const targetStatus = updateInfo.value || versionStatus.value
if (targetStatus?.has_update && targetStatus.updatable === false) {
showError(
targetStatus.update_blocker || '当前版本暂不支持在线更新',
'无法在线更新'
)
return
}
const targetVersion = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
updateTaskStatus.value = null
startUpdateStatusPolling()
try {
const result = await adminApi.prepareSystemUpdate(targetVersion)
const finalStatus = await waitForPreparedUpdate()
preparedUpdateVersion.value = targetVersion
systemUpdatePhase.value = 'restart'
success(finalStatus.output || result.message || '更新包已下载完成,请点击“立即重启”完成安装')
} finally {
stopUpdateStatusPolling()
void refreshUpdateTaskStatus()
}
return
}
const result = await adminApi.applySystemUpdate()
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
success(result.message || '一键重启已启动')
showUpdateDialog.value = false
systemUpdatePhase.value = 'reconnecting'
reconnectMessage.value = '服务正在重启...'
showUpdateDialog.value = true
applyingSystemUpdate.value = false
await pollHealthUntilReady()
} catch (err) {
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
showError(parseApiError(err, fallback))
@@ -638,8 +837,82 @@ async function handleApplySystemUpdate() {
}
}
async function handleRollback() {
if (rollingBack.value) return
rollingBack.value = true
try {
const result = await adminApi.rollbackSystemUpdate()
success(result.message || '回滚已启动')
systemUpdatePhase.value = 'reconnecting'
reconnectMessage.value = '正在回滚到上一版本...'
showUpdateDialog.value = true
rollingBack.value = false
await pollHealthUntilReady()
} catch (err) {
showError(parseApiError(err, '回滚失败'))
} finally {
rollingBack.value = false
}
}
async function pollHealthUntilReady() {
const maxAttempts = 60
const intervalMs = 2000
for (let i = 0; i < maxAttempts; i++) {
if (i < 3) {
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
await new Promise(r => setTimeout(r, intervalMs))
continue
}
const elapsed = i * 2
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
try {
const resp = await fetch('/_gateway/health', {
method: 'GET',
signal: AbortSignal.timeout(3000),
})
if (resp.ok) {
reconnectMessage.value = '服务已恢复,正在刷新...'
await new Promise(r => setTimeout(r, 500))
window.location.replace(buildFreshReloadUrl())
return
}
} catch {
// expected while service is down
}
// After 30 seconds, start checking if the task actually failed
if (i > 15) {
try {
const status = await adminApi.getUpdateStatus()
if (status.phase === 'failed' && status.error) {
reconnectMessage.value = `更新失败: ${status.error}`
systemUpdatePhase.value = 'download'
return
}
} catch {
// service still down, continue polling
}
}
await new Promise(r => setTimeout(r, intervalMs))
}
reconnectMessage.value = '等待超时,请手动刷新页面'
systemUpdatePhase.value = 'download'
}
function buildFreshReloadUrl(): string {
const url = new URL(window.location.href)
url.searchParams.set('__aether_reload', Date.now().toString())
return url.toString()
}
function showDebugUpdateDialog() {
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
updateDialogMode.value = 'latest'
updateInfo.value = {
current_version: currentVersion,
latest_version: 'v0.7.0-rc99',
@@ -652,6 +925,8 @@ function showDebugUpdateDialog() {
'- 统一版本号显示格式',
].join('\n'),
published_at: new Date().toISOString(),
updatable: true,
update_blocker: null,
error: null,
}
systemUpdatePhase.value = 'download'
@@ -675,6 +950,8 @@ function showDebugVersionStatus(hasUpdate = true) {
].join('\n')
: null,
published_at: hasUpdate ? new Date().toISOString() : null,
updatable: hasUpdate,
update_blocker: null,
error: null,
}
systemUpdatePhase.value = 'download'
@@ -694,6 +971,7 @@ async function checkForUpdate() {
const result = versionStatus.value ?? await loadVersionStatus()
if (result?.has_update && result.latest_version) {
if (shouldShowUpdatePrompt(result.latest_version)) {
updateDialogMode.value = 'latest'
updateInfo.value = result
showUpdateDialog.value = true
}
@@ -786,6 +1064,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange)
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopUpdateStatusPolling()
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
delete window.__aetherShowUpdateDialog
}