Files
codex-proxy-rs/frontend/src/layout/components/SystemUpdateModal.vue
T
2026-07-08 13:58:28 +08:00

601 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
import {
ArrowUpCircle,
CheckCircle2,
Circle,
ExternalLink,
Power,
RefreshCw,
Terminal,
XCircle,
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
import { useSystemUpdate, type SystemUpdateLogLevel } from '@/composables/useSystemUpdate'
import { renderMarkdown } from '@/utils/markdown'
import type { SystemUpdateInfo } from '@/api'
const open = defineModel<boolean>({ default: false })
const {
version,
updateInfo,
loading,
checking,
updating,
restarting,
restartCountdown,
updateError,
updateSuccess,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
hasUpdate,
canUpdate,
loadSystem,
checkUpdates,
updateNow,
restartNow,
clearRestartTimer,
disconnectUpdateEvents,
} = useSystemUpdate()
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<SystemUpdateInfo | null>(null)
const updateConfirmPreviousTarget = shallowRef('')
const preparingUpdate = shallowRef(false)
const statusView = computed(() => {
if (updating.value) {
return {
label: '更新中',
icon: RefreshCw,
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
iconClass: 'text-(--cp-info)',
}
}
if (updateError.value || updateInfo.value?.warning) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
iconClass: 'text-(--cp-danger)',
}
}
if (updateSuccess.value) {
return {
label: '已更新',
icon: CheckCircle2,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
if (hasUpdate.value) {
return {
label: '有新版本',
icon: ArrowUpCircle,
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
iconClass: 'text-(--cp-success)',
}
}
return {
label: updateInfo.value ? '已是最新' : '未检查',
icon: CheckCircle2,
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
iconClass: 'text-(--cp-text-muted)',
}
})
const summaryItems = computed(() => [
{
key: 'current',
label: '当前版本',
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
title: version.value?.version,
},
{
key: 'latest',
label: '最新版本',
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
title: updateInfo.value?.latestVersion,
releaseUrl: updateInfo.value?.releaseUrl,
},
{
key: 'build',
label: '构建',
value: displayValue(updateInfo.value?.buildTypeLabel),
title: updateInfo.value?.buildType,
},
{
key: 'deployment',
label: '部署',
value: displayValue(version.value?.deploymentModeLabel),
title: version.value?.deploymentMode,
},
])
const updateLogRows = computed(() =>
updateLogs.value.map((item) => ({
...item,
time: formatLogTime(item.at),
})),
)
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
)
const updateConfirmRows = computed(() => [
{
key: 'current',
label: '当前版本',
value: version.value?.version ? `v${version.value.version}` : '-',
},
{
key: 'previous',
label: '已显示目标',
value: updateConfirmPreviousTarget.value ? `v${updateConfirmPreviousTarget.value}` : '-',
},
{
key: 'target',
label: '远端最新目标',
value: updateConfirmInfo.value?.latestVersion
? `v${updateConfirmInfo.value.latestVersion}`
: '-',
},
])
const streamStatusLabel = computed(() => {
if (updateStreaming.value) return '实时'
if (updateStreamError.value) return '断开'
return '待连接'
})
const restartButtonLabel = computed(() => {
if (!restarting.value) return '立即重启'
if (restartCountdown.value > 0) return `重启中 ${restartCountdown.value}s`
return '等待恢复'
})
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
function formatLogTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--:--:--'
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
function logMarkerClass(level: SystemUpdateLogLevel) {
if (level === 'success') return 'text-(--cp-success)'
if (level === 'warning') return 'text-(--cp-warning)'
if (level === 'error') return 'text-(--cp-danger)'
return 'text-(--cp-info)'
}
function logTextClass(level: SystemUpdateLogLevel) {
if (level === 'success') return 'text-(--cp-success)'
if (level === 'warning') return 'text-(--cp-warning)'
if (level === 'error') return 'text-(--cp-danger)'
return 'text-(--cp-text-primary)'
}
function normalizeUpdateVersion(value: unknown) {
return String(value ?? '')
.trim()
.replace(/^v/i, '')
}
async function scrollUpdateLogsToBottom() {
await nextTick()
await updateLogScrollbar.value?.scrollToBottom()
}
function pinUpdateLogsToBottom() {
window.requestAnimationFrame(() => {
void scrollUpdateLogsToBottom()
})
}
async function handleCheckUpdates(force = true) {
try {
const data = await checkUpdates(force)
toast.success(data?.hasUpdate ? '发现可用更新' : '当前已是最新版本')
} catch (error: any) {
toast.error(error.message || '检查更新失败')
}
}
async function handleUpdateRequest() {
if (preparingUpdate.value || updating.value) return
const previousTargetVersion = normalizeUpdateVersion(updateInfo.value?.latestVersion)
preparingUpdate.value = true
try {
const data = await checkUpdates(true)
if (!data?.hasUpdate) {
toast.success('当前已是最新版本')
return
}
const remoteTargetVersion = normalizeUpdateVersion(data.latestVersion)
if (!remoteTargetVersion) {
toast.error('远端目标版本为空')
return
}
if (previousTargetVersion && previousTargetVersion !== remoteTargetVersion) {
updateConfirmPreviousTarget.value = previousTargetVersion
updateConfirmInfo.value = data
updateConfirmOpen.value = true
return
}
await runConfirmedUpdate(remoteTargetVersion)
} catch (error: any) {
toast.error(error.message || '检查更新失败')
} finally {
preparingUpdate.value = false
}
}
async function runConfirmedUpdate(targetVersion: string) {
try {
const result = await updateNow(targetVersion)
if (result?.needRestart) {
toast.success('更新完成,请重启服务')
}
} catch (error: any) {
toast.error(error.message || '更新失败')
}
}
async function handleConfirmUpdate() {
const targetVersion = normalizeUpdateVersion(updateConfirmInfo.value?.latestVersion)
if (!targetVersion) return
updateConfirmOpen.value = false
await nextTick()
await runConfirmedUpdate(targetVersion)
}
async function handleRestart() {
try {
await restartNow()
if (restarting.value) {
toast.success('正在重启服务')
}
} catch (error: any) {
toast.error(error.message || '重启失败')
}
}
watch(open, (visible) => {
if (visible && !loadedOnce.value) {
void loadSystem(false).catch((error: any) => {
toast.error(error.message || '加载系统更新信息失败')
})
}
})
watch(
() => updateLogs.value.at(-1)?.id,
(logId, previousLogId) => {
if (!logId || logId === previousLogId) return
pinUpdateLogsToBottom()
},
{ flush: 'post' },
)
onUnmounted(() => {
clearRestartTimer()
disconnectUpdateEvents()
})
</script>
<template>
<BaseModal
v-model="open"
title="系统更新"
description="版本、更新状态与实时进度。"
variant="success"
width="820px"
scrollable
body-max-height="72dvh"
body-view-class="pr-3"
:close-disabled="updating || restarting"
>
<div class="grid gap-3.5">
<section class="grid gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
Codex Proxy RS
</p>
<p class="mt-2 mb-0 text-lg leading-none font-[780] text-(--cp-text-primary)">
应用包更新
</p>
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-[760]"
:class="statusView.badge"
>
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
{{ statusView.label }}
</span>
</div>
<div class="grid gap-2.5 sm:grid-cols-4">
<div
v-for="item in summaryItems"
:key="item.key"
class="min-w-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
>
<div class="flex min-w-0 items-center justify-between gap-2">
<p class="m-0 truncate text-[11px] leading-none font-[760] text-(--cp-text-muted)">
{{ item.label }}
</p>
<a
v-if="item.releaseUrl"
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-[720] text-(--cp-info-text) transition-colors hover:text-(--cp-info)"
>
发布页
<ExternalLink class="size-3" />
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-[720] text-(--cp-text-primary)"
:title="item.title || item.value"
>
{{ item.value }}
</p>
</div>
</div>
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-danger-bg) px-3 py-2 text-[12px] leading-normal font-[720] text-(--cp-danger-text)"
>
{{ updateError || updateInfo?.warning }}
</p>
</section>
<section
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
{{ displayValue(updateInfo?.latestVersion) }}
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
<div class="release-notes" v-html="renderedReleaseNotes" />
</BaseScrollbar>
</section>
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
>
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
<p class="m-0 text-[13px] leading-none font-[760] text-(--cp-text-primary)">更新进度</p>
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-(--cp-bg-subtle) px-2 text-[11px] leading-none font-[720] text-(--cp-text-secondary)"
:title="updateStreamError || streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-(--cp-success)' : 'bg-(--cp-text-muted)'"
/>
{{ streamStatusLabel }}
</span>
</header>
<BaseScrollbar
v-if="updateLogRows.length"
ref="updateLogScrollbar"
height="260px"
view-class="min-h-full px-4 pb-4"
>
<div class="grid gap-2">
<div
v-for="log in updateLogRows"
:key="log.id"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2 font-mono text-[11px] leading-[1.55]"
>
<span class="tabular-nums text-(--cp-text-muted)">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="logMarkerClass(log.level)"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
<span v-if="log.step" class="mr-1 text-(--cp-text-muted)">[{{ log.step }}]</span>
{{ log.message }}
</p>
</div>
</div>
</BaseScrollbar>
<div v-else class="grid h-30 place-items-center px-4 pb-4">
<BaseEmpty title="暂无进度" :icon="Terminal" compact plain />
</div>
</section>
</div>
<template #footer>
<BaseButton
variant="default"
:loading="checking"
:disabled="loading || updating || restarting"
@click="handleCheckUpdates(true)"
>
<template #icon>
<RefreshCw class="size-3.5" />
</template>
检查更新
</BaseButton>
<BaseButton
v-if="updateSuccess && needRestart"
variant="success"
:loading="restarting"
:disabled="updating"
@click="handleRestart"
>
<template #icon>
<Power class="size-4" />
</template>
{{ restartButtonLabel }}
</BaseButton>
<BaseButton
v-else
variant="success"
:loading="preparingUpdate || updating"
:disabled="!canUpdate || preparingUpdate"
@click="handleUpdateRequest"
>
<template #icon>
<ArrowUpCircle class="size-4" />
</template>
立即更新
</BaseButton>
</template>
</BaseModal>
<BaseConfirmModal
v-model="updateConfirmOpen"
title="远端目标版本已变化"
description="检测到远端 latest 与当前显示的目标版本不一致。"
variant="warning"
confirm-text="确认更新"
:loading="updating"
:confirm-disabled="!updateConfirmInfo?.latestVersion"
@confirm="handleConfirmUpdate"
>
<div class="grid gap-3">
<div class="grid gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) p-3">
<div
v-for="item in updateConfirmRows"
:key="item.key"
class="flex min-w-0 items-center justify-between gap-3"
>
<span class="text-[12px] leading-none font-[720] text-(--cp-text-muted)">
{{ item.label }}
</span>
<span
class="truncate font-mono text-[13px] leading-none font-[760] text-(--cp-text-primary)"
>
{{ item.value }}
</span>
</div>
</div>
<p class="m-0 text-[12px] leading-relaxed font-[650] text-(--cp-text-muted)">
点击确认后弹窗会关闭,并按远端最新目标版本开始更新。
</p>
</div>
</BaseConfirmModal>
</template>
<style scoped>
.release-notes {
color: var(--cp-text-primary);
font-size: 12px;
font-weight: 620;
line-height: 1.65;
overflow-wrap: anywhere;
}
.release-notes :deep(*) {
max-width: 100%;
}
.release-notes :deep(:first-child) {
margin-top: 0;
}
.release-notes :deep(:last-child) {
margin-bottom: 0;
}
.release-notes :deep(h1),
.release-notes :deep(h2),
.release-notes :deep(h3),
.release-notes :deep(h4) {
margin: 12px 0 6px;
color: var(--cp-text-primary);
font-size: 12px;
font-weight: 780;
line-height: 1.4;
}
.release-notes :deep(p) {
margin: 0 0 8px;
}
.release-notes :deep(ul),
.release-notes :deep(ol) {
margin: 0 0 8px;
padding-left: 18px;
}
.release-notes :deep(li) {
margin: 3px 0;
}
.release-notes :deep(a) {
color: var(--cp-info-text);
font-weight: 720;
text-decoration: none;
}
.release-notes :deep(a:hover) {
color: var(--cp-info);
}
.release-notes :deep(code) {
border-radius: 5px;
background: var(--cp-bg-muted);
color: var(--cp-text-primary);
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 5px;
}
.release-notes :deep(pre) {
margin: 8px 0;
overflow-x: auto;
border-radius: var(--cp-input-radius-base);
background: var(--cp-bg-surface);
padding: 8px 10px;
}
.release-notes :deep(pre code) {
background: transparent;
padding: 0;
}
.release-notes :deep(blockquote) {
margin: 8px 0;
border-left: 3px solid var(--cp-divider-subtle);
color: var(--cp-text-secondary);
padding-left: 10px;
}
</style>