feat: 添加在线更新功能

This commit is contained in:
zhiqicloud
2026-05-20 22:02:47 +08:00
parent 754f672ee2
commit 9562295d8b
18 changed files with 1196 additions and 13 deletions

View File

@@ -373,6 +373,22 @@ export interface CheckUpdateResponse {
error: string | null
}
export interface SystemUpdateCapabilityResponse {
enabled: boolean
command_env: string
workdir_env: string
command?: string | null
workdir?: string | null
detail?: string | null
message: string
}
export interface ApplySystemUpdateResponse {
message: string
started: boolean
need_restart: boolean
}
// LDAP 配置响应
export interface LdapConfigResponse {
server_url: string | null
@@ -981,6 +997,30 @@ export const adminApi = {
return response.data
},
// 获取一键更新能力
async getSystemUpdateCapability(): Promise<SystemUpdateCapabilityResponse> {
const response = await apiClient.get<SystemUpdateCapabilityResponse>(
'/api/admin/system/update-capability'
)
return response.data
},
// 准备系统一键更新(拉取最新镜像)
async prepareSystemUpdate(): Promise<ApplySystemUpdateResponse> {
const response = await apiClient.post<ApplySystemUpdateResponse>(
'/api/admin/system/prepare-update'
)
return response.data
},
// 触发系统一键重启(重建 app 容器)
async applySystemUpdate(): Promise<ApplySystemUpdateResponse> {
const response = await apiClient.post<ApplySystemUpdateResponse>(
'/api/admin/system/apply-update'
)
return response.data
},
// LDAP 配置相关
// 获取 LDAP 配置
async getLdapConfig(): Promise<LdapConfigResponse> {

View File

@@ -55,6 +55,13 @@
>
新版本已发布建议更新以获得最新功能和安全修复
</p>
<p
v-if="updatePhase === 'restart'"
class="mt-1 text-xs text-primary"
>
更新包已下载点击立即重启完成安装
</p>
</div>
<template #footer>
@@ -62,16 +69,26 @@
<Button
variant="outline"
class="flex-1"
:disabled="updating"
@click="handleLater"
>
稍后提醒
</Button>
<Button
variant="outline"
class="flex-1"
:disabled="updating"
@click="handleViewRelease"
>
查看更新
</Button>
<Button
class="flex-1"
:disabled="updating"
@click="handleApplyUpdate"
>
{{ actionButtonLabel }}
</Button>
</div>
</template>
</Dialog>
@@ -93,13 +110,24 @@ const props = defineProps<{
releaseUrl: string | null
releaseNotes: string | null
publishedAt: string | null
updatePhase?: 'download' | 'restart'
updating?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
applyUpdate: []
}>()
const isOpen = ref(props.modelValue)
const updating = computed(() => props.updating ?? false)
const updatePhase = computed(() => props.updatePhase ?? 'download')
const actionButtonLabel = computed(() => {
if (updating.value) {
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
}
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
})
watch(() => props.modelValue, (val) => {
isOpen.value = val
@@ -157,4 +185,8 @@ function handleViewRelease() {
}
isOpen.value = false
}
function handleApplyUpdate() {
emit('applyUpdate')
}
</script>

View File

@@ -92,6 +92,19 @@
<ExternalLink class="mr-2 h-3.5 w-3.5" />
查看更新
</Button>
<Button
v-if="status?.has_update"
size="sm"
class="flex-1"
:disabled="updating"
@click="handleApplyUpdate"
>
<RefreshCw
class="mr-2 h-3.5 w-3.5"
:class="updating ? 'animate-spin' : ''"
/>
{{ actionButtonLabel }}
</Button>
</div>
</div>
</div>
@@ -110,16 +123,21 @@ import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
const props = defineProps<{
status: CheckUpdateResponse | null
loading?: boolean
updating?: boolean
updatePhase?: 'download' | 'restart'
}>()
const emit = defineEmits<{
refresh: []
openRelease: []
applyUpdate: []
}>()
const isOpen = ref(false)
const loading = computed(() => props.loading ?? false)
const updating = computed(() => props.updating ?? false)
const updatePhase = computed(() => props.updatePhase ?? 'download')
const buttonClass = computed(() => {
const classes = []
@@ -160,6 +178,12 @@ const buttonTitle = computed(() => {
if (!props.status) return '版本信息'
return `版本信息:${statusLabel.value}`
})
const actionButtonLabel = computed(() => {
if (updating.value) {
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
}
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
})
function handleRefresh() {
emit('refresh')
@@ -169,4 +193,8 @@ function handleOpenRelease() {
isOpen.value = false
emit('openRelease')
}
function handleApplyUpdate() {
emit('applyUpdate')
}
</script>

View File

@@ -118,8 +118,11 @@
v-if="isAdmin"
:status="versionStatus"
:loading="loadingVersionStatus"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
@apply-update="handleApplySystemUpdate"
/>
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
@@ -301,8 +304,11 @@
v-if="isAdmin"
:status="versionStatus"
:loading="loadingVersionStatus"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
@apply-update="handleApplySystemUpdate"
/>
<!-- Theme Toggle -->
<button
@@ -385,6 +391,9 @@
:release-url="updateInfo.release_url"
:release-notes="updateInfo.release_notes"
:published-at="updateInfo.published_at"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
@apply-update="handleApplySystemUpdate"
/>
</AppShell>
</template>
@@ -397,9 +406,11 @@ import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
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 { announcementApi, type Announcement } from '@/api/announcements'
import { parseApiError } from '@/utils/errorParser'
import Button from '@/components/ui/button.vue'
import { Dialog } from '@/components/ui'
import AppShell from '@/components/layout/AppShell.vue'
@@ -449,12 +460,15 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
type SystemUpdatePhase = 'download' | 'restart'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const { success, error: showError } = useToast()
const isDemo = computed(() => isDemoMode())
const isAdmin = computed(() => authStore.user?.role === 'admin')
@@ -475,8 +489,52 @@ const showUpdateDialog = ref(false)
const updateInfo = ref<CheckUpdateResponse | null>(null)
const versionStatus = ref<CheckUpdateResponse | null>(null)
const loadingVersionStatus = ref(false)
const applyingSystemUpdate = ref(false)
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
const preparedUpdateVersion = ref<string | null>(
readSessionStorageItem('aether_prepared_update_version')
)
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
watch(systemUpdatePhase, (val) => {
setSessionStorageItem('aether_update_phase', val)
})
watch(preparedUpdateVersion, (val) => {
if (val) {
setSessionStorageItem('aether_prepared_update_version', val)
} else {
removeSessionStorageItem('aether_prepared_update_version')
}
})
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
return readSessionStorageItem('aether_update_phase') === 'restart' ? 'restart' : 'download'
}
function readSessionStorageItem(key: string): string | null {
try {
return sessionStorage.getItem(key)
} catch {
return null
}
}
function setSessionStorageItem(key: string, value: string) {
try {
sessionStorage.setItem(key, value)
} catch {
// Ignore storage failures; update state still lives in memory for this page.
}
}
function removeSessionStorageItem(key: string) {
try {
sessionStorage.removeItem(key)
} catch {
// Ignore storage failures; update state still lives in memory for this page.
}
}
// 路由变化时自动关闭移动端菜单
watch(() => route.path, () => {
mobileMenuOpen.value = false
@@ -508,6 +566,7 @@ async function loadVersionStatus() {
versionStatusLoadPromise = (async () => {
try {
versionStatus.value = await adminApi.checkUpdate()
syncSystemUpdatePhase(versionStatus.value)
return versionStatus.value
} catch (error) {
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
@@ -521,6 +580,22 @@ async function loadVersionStatus() {
return versionStatusLoadPromise
}
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
if (!status?.has_update) {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
return
}
if (
systemUpdatePhase.value === 'restart' &&
(!preparedUpdateVersion.value || preparedUpdateVersion.value !== status.latest_version)
) {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
}
function handleVersionRefresh() {
void loadVersionStatus()
}
@@ -531,6 +606,38 @@ function openVersionReleasePage() {
}
}
async function handleApplySystemUpdate() {
if (applyingSystemUpdate.value) return
applyingSystemUpdate.value = true
try {
const capability = await adminApi.getSystemUpdateCapability()
if (!capability.enabled) {
showError(
capability.detail || `一键更新未启用,请先在部署环境配置 ${capability.command_env}`,
'无法启动更新'
)
return
}
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 || '更新包已下载完成,请点击“立即重启”完成安装')
return
}
const result = await adminApi.applySystemUpdate()
success(result.message || '一键重启已启动')
showUpdateDialog.value = false
} catch (err) {
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
showError(parseApiError(err, fallback))
} finally {
applyingSystemUpdate.value = false
}
}
function showDebugUpdateDialog() {
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
updateInfo.value = {
@@ -547,6 +654,8 @@ function showDebugUpdateDialog() {
published_at: new Date().toISOString(),
error: null,
}
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
showUpdateDialog.value = true
}
@@ -568,6 +677,8 @@ function showDebugVersionStatus(hasUpdate = true) {
published_at: hasUpdate ? new Date().toISOString() : null,
error: null,
}
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
// 检查更新