feat(update): 支持 Docker Compose 页面一键升级并发布 v1.3.0

This commit is contained in:
2026-09-27 18:43:34 +08:00
parent 32dfe3b6de
commit 163df0c5b0
29 changed files with 1541 additions and 10 deletions
+3
View File
@@ -36,6 +36,7 @@ import type {
SettingsMap,
SystemLogEntry,
NotifyTestPayload,
UpdateStatus,
} from './types'
export function asList<T>(value: T[] | null | undefined): T[] {
@@ -96,6 +97,8 @@ async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: A
export const api = {
getVersion: () => request<AppVersion>('/api/version'),
getUpdateStatus: () => request<UpdateStatus>('/api/system/update/status'),
applyUpdate: () => request<UpdateStatus>('/api/system/update/apply', { method: 'POST' }),
authStatus: () => request<AuthStatus>('/api/auth/status'),
login: (username: string, password: string) =>
request<{ message: string }>('/api/auth/login', {
+13
View File
@@ -121,6 +121,19 @@ export interface AppVersion {
nginx_https_port: number
}
export interface UpdateStatus {
enabled: boolean
mode: string
current_version?: string
latest_version?: string
update_available: boolean
busy: boolean
phase: 'idle' | 'scheduled' | 'running' | 'success' | 'failed' | string
message: string
log?: string[]
checked_at?: string
}
export interface ApiError {
error: string
}
+257
View File
@@ -0,0 +1,257 @@
<template>
<n-modal v-model:show="show" :mask-closable="false">
<div class="update-modal">
<div class="update-modal__header">
<div>
<h3 class="update-modal__title">升级 Fonu</h3>
<p class="update-modal__subtitle">升级完成后容器会重启,数据目录不会被删除</p>
</div>
<n-button quaternary size="small" :disabled="applying" @click="show = false">关闭</n-button>
</div>
<div class="update-modal__body">
<n-alert :type="alertType" :bordered="false" class="update-modal__alert">
{{ displayMessage }}
</n-alert>
<div class="update-modal__grid">
<div>
<span class="update-modal__label">当前版本</span>
<strong class="mono">{{ status?.current_version || appVersion || '未知' }}</strong>
</div>
<div>
<span class="update-modal__label">目标版本</span>
<strong class="mono">{{ status?.latest_version || '检查中…' }}</strong>
</div>
<div>
<span class="update-modal__label">升级方式</span>
<strong>{{ modeLabel }}</strong>
</div>
<div>
<span class="update-modal__label">服务状态</span>
<strong>{{ enabledLabel }}</strong>
</div>
</div>
<p class="update-modal__hint">
升级会重新构建应用镜像并只重建 <span class="mono">fonu</span> 容器。构建期间管理页面可能短暂无法访问,
浏览器会自动等待服务恢复。
</p>
<pre v-if="logText" class="update-modal__log">{{ logText }}</pre>
</div>
<div class="update-modal__footer">
<n-button :disabled="applying" @click="show = false">取消</n-button>
<n-button type="primary" :loading="applying || loading" :disabled="!canApply" @click="startUpdate">
{{ applying ? '正在升级…' : '确认升级' }}
</n-button>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { NAlert, NButton, NModal, useMessage } from 'naive-ui'
import { api } from '../api/client'
import type { UpdateStatus } from '../api/types'
const show = defineModel<boolean>('show', { required: true })
const emit = defineEmits<{ updated: [] }>()
const message = useMessage()
const status = ref<UpdateStatus | null>(null)
const appVersion = ref(__APP_VERSION__)
const loading = ref(false)
const applying = ref(false)
const localMessage = ref('')
let pollTimer: number | undefined
const modeLabel = computed(() => {
if (status.value?.mode === 'git') return 'Git 仓库构建'
if (status.value?.mode === 'local') return '本机源码构建'
return '未启用'
})
const enabledLabel = computed(() => (status.value?.enabled ? '侧车可用' : '不可用'))
const displayMessage = computed(() => {
if (localMessage.value) return localMessage.value
if (!status.value) return loading.value ? '正在检查更新状态…' : '尚未检查更新状态'
return status.value.message || '未知状态'
})
const alertType = computed(() => {
if (status.value?.phase === 'failed') return 'error'
if (status.value?.phase === 'success') return 'success'
if (applying.value || status.value?.busy) return 'warning'
return status.value?.update_available ? 'info' : 'default'
})
const logText = computed(() => (status.value?.log ?? []).slice(-20).join('\n'))
const canApply = computed(() => Boolean(
status.value?.enabled &&
status.value.update_available &&
!applying.value &&
!status.value.busy
))
async function loadStatus() {
loading.value = true
localMessage.value = ''
try {
status.value = await api.getUpdateStatus()
} catch (error) {
localMessage.value = error instanceof Error ? error.message : '读取升级状态失败'
} finally {
loading.value = false
}
}
async function startUpdate() {
applying.value = true
localMessage.value = '升级任务已启动,正在等待容器重建…'
try {
status.value = await api.applyUpdate()
startPolling()
} catch (error) {
applying.value = false
localMessage.value = error instanceof Error ? error.message : '启动升级失败'
}
}
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => void pollStatus(), 2000)
}
async function pollStatus() {
try {
status.value = await api.getUpdateStatus()
localMessage.value = ''
if (status.value.phase === 'success') {
stopPolling()
applying.value = false
message.success('Fonu 已升级完成')
emit('updated')
window.setTimeout(() => window.location.reload(), 1500)
return
}
if (status.value.phase === 'failed') {
stopPolling()
applying.value = false
message.error(status.value.message || '升级失败')
}
} catch {
localMessage.value = '服务正在重启,等待恢复…'
}
}
function stopPolling() {
if (pollTimer === undefined) return
window.clearInterval(pollTimer)
pollTimer = undefined
}
watch(show, (visible) => {
if (!visible) return
void loadStatus()
})
onBeforeUnmount(stopPolling)
</script>
<style scoped>
.update-modal {
width: 560px;
max-width: 94vw;
background: var(--fonu-surface);
border-radius: var(--fonu-radius);
box-shadow: var(--fonu-shadow-md);
overflow: hidden;
}
.update-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fonu-space-3);
padding: var(--fonu-space-5) var(--fonu-space-5) var(--fonu-space-3);
}
.update-modal__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--fonu-text);
}
.update-modal__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--fonu-text-muted);
}
.update-modal__body {
display: grid;
gap: var(--fonu-space-4);
padding: 0 var(--fonu-space-5) var(--fonu-space-4);
}
.update-modal__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--fonu-space-3);
}
.update-modal__grid > div {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
}
.update-modal__label {
font-size: 12px;
color: var(--fonu-text-muted);
}
.update-modal__hint {
margin: 0;
font-size: 12px;
line-height: 1.6;
color: var(--fonu-text-secondary);
}
.update-modal__log {
max-height: 220px;
margin: 0;
padding: 12px;
overflow: auto;
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg-muted);
color: var(--fonu-text-secondary);
font-family: var(--fonu-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.update-modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--fonu-space-3);
padding: var(--fonu-space-4) var(--fonu-space-5);
border-top: 1px solid var(--fonu-border);
}
@media (max-width: 640px) {
.update-modal__grid {
grid-template-columns: 1fr;
}
}
</style>
+33 -2
View File
@@ -35,7 +35,16 @@
<span class="sider-footer__version">v{{ appVersion }}</span>
</div>
<a
v-if="hasUpdate"
v-if="canOneClickUpdate"
class="sider-footer__update"
href="#"
@click.prevent="showUpdateModal = true"
>
<n-icon :component="ArrowUpOutline" />
一键升级
</a>
<a
v-else-if="hasUpdate"
class="sider-footer__update"
:href="latestReleaseUrl"
target="_blank"
@@ -90,6 +99,7 @@
</n-layout-content>
</n-layout>
</n-layout>
<UpdateModal v-model:show="showUpdateModal" @updated="checkForUpdate" />
</template>
<script setup lang="ts">
@@ -129,7 +139,9 @@ import { api } from '../api/client'
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
import { useTheme } from '../composables/useTheme'
import { fetchLatestRelease, isNewerVersion } from '../utils/version'
import type { UpdateStatus } from '../api/types'
import logoImg from '../assets/brand/logo.png'
import UpdateModal from '../components/UpdateModal.vue'
const route = useRoute()
const router = useRouter()
@@ -141,8 +153,14 @@ const appVersion = ref(__APP_VERSION__)
const latestVersion = ref('')
const latestReleaseUrl = ref('')
const hasUpdate = ref(false)
const showUpdateModal = ref(false)
const updateStatus = ref<UpdateStatus | null>(null)
let clockTimer: number | undefined
const canOneClickUpdate = computed(() => Boolean(
updateStatus.value?.enabled && updateStatus.value.update_available
))
async function checkForUpdate() {
const release = await fetchLatestRelease()
if (!release) {
@@ -164,7 +182,15 @@ async function loadVersion() {
// keep build-time fallback
}
await checkForUpdate()
await Promise.all([checkForUpdate(), loadUpdateStatus()])
}
async function loadUpdateStatus() {
try {
updateStatus.value = await api.getUpdateStatus()
} catch {
updateStatus.value = null
}
}
const menuDefs = [
@@ -363,11 +389,16 @@ onUnmounted(() => {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0;
border: 0;
background: none;
font-size: 11px;
font-weight: 500;
font-family: inherit;
color: var(--fonu-brand);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
}