diff --git a/frontend-vue/src/pages/home/DesktopHomePage.vue b/frontend-vue/src/pages/home/DesktopHomePage.vue
index 60edd924..da9a62e6 100644
--- a/frontend-vue/src/pages/home/DesktopHomePage.vue
+++ b/frontend-vue/src/pages/home/DesktopHomePage.vue
@@ -23,6 +23,7 @@
{{ hint }}
+
{{ hint }}
+
@@ -98,6 +99,7 @@ import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { loginWithDevice } from '@/shared/api/user'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
+import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
const router = useRouter()
@@ -127,6 +129,7 @@ const {
canDownload,
hint,
checked,
+ progress,
runCheck,
doUpdate,
} = useVersionUpdate()
diff --git a/frontend-vue/src/shared/components/UpdateProgressBar.vue b/frontend-vue/src/shared/components/UpdateProgressBar.vue
new file mode 100644
index 00000000..65f8ed6c
--- /dev/null
+++ b/frontend-vue/src/shared/components/UpdateProgressBar.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
diff --git a/frontend-vue/src/shared/composables/useVersionUpdate.ts b/frontend-vue/src/shared/composables/useVersionUpdate.ts
index f6416582..9c058add 100644
--- a/frontend-vue/src/shared/composables/useVersionUpdate.ts
+++ b/frontend-vue/src/shared/composables/useVersionUpdate.ts
@@ -6,8 +6,31 @@
* 提供);旧客户端无此桥时降级:本机版本为空 → 只要线上有包就允许手动更新。
*/
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
+import {
+ normalizeUpdateProgress,
+ type UpdateProgress,
+} from '@/shared/utils/update-progress'
+
+export type { UpdateProgress, UpdateProgressStatus } from '@/shared/utils/update-progress'
+
+const updateProgress = ref(null)
+let progressListenerBound = false
+
+/**
+ * 绑定一次全局监听:下载进度由 Python 侧主动推事件(同 pywebview-download-progress 约定),
+ * JS 侧不轮询——桥调用虽然并发安全,但轮询会给主进程平白加请求。
+ */
+function bindProgressListener() {
+ if (progressListenerBound || typeof window === 'undefined') return
+ progressListenerBound = true
+ window.addEventListener('pywebview-update-progress', (event) => {
+ const progress = normalizeUpdateProgress((event as CustomEvent).detail)
+ if (!progress) return
+ updateProgress.value = progress
+ })
+}
function normVersion(value: unknown): string {
return String(value ?? '').trim().replace(/^[vV]/, '')
@@ -124,19 +147,57 @@ export function useVersionUpdate() {
updating.value = false
return
}
+ // 先挂监听再发桥调用:进度事件在下载开始后立刻开推,晚绑会丢最前面的进度
+ bindProgressListener()
+ updateProgress.value = {
+ status: 'downloading',
+ message: '正在准备下载更新包...',
+ downloaded: 0,
+ total: 0,
+ percent: -1,
+ }
hint.value = '正在下载并准备更新,程序将自动退出...'
try {
const result = await doUpdateApp!(fileUrl.value)
- hint.value = result?.success
- ? '更新已启动,程序即将退出...'
- : (result?.error || '更新启动失败,请重试')
+ if (result?.success) {
+ const last = updateProgress.value
+ updateProgress.value = {
+ status: 'ready',
+ message: '安装包已就绪,正在启动更新程序,程序即将退出...',
+ downloaded: last?.downloaded || 0,
+ total: last?.total || 0,
+ percent: 100,
+ }
+ hint.value = '更新已启动,程序即将退出...'
+ } else {
+ // 失败:进度条停在断点处,重试将从断点继续(Python 侧保留断点文件)
+ const message = result?.error || '更新启动失败,请重试'
+ updateProgress.value = {
+ status: 'failed',
+ message,
+ downloaded: updateProgress.value?.downloaded || 0,
+ total: updateProgress.value?.total || 0,
+ percent: updateProgress.value?.percent ?? -1,
+ }
+ hint.value = message
+ }
} catch {
+ updateProgress.value = {
+ status: 'failed',
+ message: '请求更新失败,请重试',
+ downloaded: updateProgress.value?.downloaded || 0,
+ total: updateProgress.value?.total || 0,
+ percent: updateProgress.value?.percent ?? -1,
+ }
hint.value = '请求更新失败,请重试'
} finally {
updating.value = false
}
}
+ // 进度条的百分比/文案由 UpdateProgressBar 组件从 progress 自行派生(见 shared/utils/update-progress)
+ const progress = computed(() => updateProgress.value)
+
return {
checking,
updating,
@@ -147,6 +208,7 @@ export function useVersionUpdate() {
canDownload,
hint,
checked,
+ progress,
loadCurrentVersion,
runCheck,
doUpdate,
diff --git a/frontend-vue/src/shared/utils/update-progress.ts b/frontend-vue/src/shared/utils/update-progress.ts
new file mode 100644
index 00000000..63ae9a74
--- /dev/null
+++ b/frontend-vue/src/shared/utils/update-progress.ts
@@ -0,0 +1,88 @@
+/**
+ * 桌面端「更新包下载进度」的解析与文案(纯函数,便于 node --test 直接覆盖)。
+ *
+ * 数据源:Python 侧 do_update_app 通过 evaluate_js 推 pywebview-update-progress 事件
+ * (与 pywebview-download-progress 同一约定),detail 结构见下:
+ * { status, message, downloaded, total, percent, error }
+ * percent = -1 表示总大小未知(服务端未返回 Content-Length),进度条走不确定态。
+ */
+
+export type UpdateProgressStatus = 'downloading' | 'verifying' | 'ready' | 'failed'
+
+export type UpdateProgress = {
+ status: UpdateProgressStatus
+ message: string
+ downloaded: number
+ total: number
+ /** -1 表示总大小未知,进度条走不确定态 */
+ percent: number
+}
+
+export type UpdateProgressDetail = {
+ status?: string
+ message?: string
+ downloaded?: number
+ total?: number
+ percent?: number
+ error?: string
+}
+
+const KNOWN_STATUS: readonly string[] = ['downloading', 'verifying', 'ready', 'failed']
+
+export function isUpdateProgressStatus(value: unknown): value is UpdateProgressStatus {
+ return typeof value === 'string' && KNOWN_STATUS.includes(value)
+}
+
+function toByteCount(value: unknown): number {
+ const num = Number(value)
+ return Number.isFinite(num) && num > 0 ? num : 0
+}
+
+/**
+ * 解析进度事件 detail;状态缺失/非法返回 null,
+ * 让调用方保持原状态而不是被脏事件打回初始态。
+ */
+export function normalizeUpdateProgress(detail: unknown): UpdateProgress | null {
+ if (!detail || typeof detail !== 'object') return null
+ const raw = detail as UpdateProgressDetail
+ if (!isUpdateProgressStatus(raw.status)) return null
+ const percent = Number(raw.percent)
+ return {
+ status: raw.status,
+ message: String(raw.message ?? ''),
+ downloaded: toByteCount(raw.downloaded),
+ total: toByteCount(raw.total),
+ percent: Number.isFinite(percent) ? percent : -1,
+ }
+}
+
+/** 进度条宽度百分比(0-100 取整);总大小未知或非下载态返回 -1(不确定态) */
+export function updateProgressPercent(progress: UpdateProgress | null): number {
+ if (!progress || !Number.isFinite(progress.percent) || progress.percent < 0) return -1
+ return Math.max(0, Math.min(100, Math.round(progress.percent)))
+}
+
+export function formatBytes(value: number): string {
+ if (!Number.isFinite(value) || value <= 0) return '0 MB'
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`
+ return `${(value / 1024 / 1024).toFixed(1)} MB`
+}
+
+/**
+ * 进度条文案:下载中给"百分比 + 已下载/总大小",
+ * 其余状态(校验中/已就绪/失败)直接用 Python 侧的中文说明。
+ */
+export function formatUpdateProgressText(progress: UpdateProgress | null): string {
+ if (!progress) return ''
+ if (progress.status !== 'downloading') return progress.message
+ if (progress.percent < 0) {
+ // 进度帧还没到(刚开始)或总大小未知:能算出已下载量就显示,否则退回阶段文案
+ return progress.downloaded > 0
+ ? `正在下载更新包(已下载 ${formatBytes(progress.downloaded)})`
+ : (progress.message || '正在下载更新包...')
+ }
+ const size = progress.total > 0
+ ? `(${formatBytes(progress.downloaded)}/${formatBytes(progress.total)})`
+ : ''
+ return `正在下载更新包 ${Math.round(progress.percent)}%${size}`
+}
diff --git a/frontend-vue/tests/update-progress.test.ts b/frontend-vue/tests/update-progress.test.ts
new file mode 100644
index 00000000..c7242a39
--- /dev/null
+++ b/frontend-vue/tests/update-progress.test.ts
@@ -0,0 +1,120 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ formatBytes,
+ formatUpdateProgressText,
+ normalizeUpdateProgress,
+ updateProgressPercent,
+ type UpdateProgress,
+} from '../src/shared/utils/update-progress.ts'
+
+function progressOf(partial: Partial): UpdateProgress {
+ return {
+ status: 'downloading',
+ message: '',
+ downloaded: 0,
+ total: 0,
+ percent: -1,
+ ...partial,
+ }
+}
+
+test('更新进度:正常进度帧解析出字节数与百分比', () => {
+ const progress = normalizeUpdateProgress({
+ status: 'downloading',
+ message: '正在下载更新包...',
+ downloaded: 62914560,
+ total: 104857600,
+ percent: 60,
+ error: '',
+ })
+ assert.ok(progress, '合法事件必须解析成功')
+ assert.equal(progress.status, 'downloading')
+ assert.equal(progress.downloaded, 62914560)
+ assert.equal(progress.total, 104857600)
+ assert.equal(progress.percent, 60)
+ assert.equal(updateProgressPercent(progress), 60)
+})
+
+test('更新进度:状态缺失或非法的事件必须丢弃(不得把进度打回初始态)', () => {
+ assert.equal(normalizeUpdateProgress(null), null)
+ assert.equal(normalizeUpdateProgress(undefined), null)
+ assert.equal(normalizeUpdateProgress({}), null)
+ assert.equal(normalizeUpdateProgress({ downloaded: 100 }), null)
+ assert.equal(normalizeUpdateProgress({ status: 'unknown-stage' }), null)
+ assert.equal(normalizeUpdateProgress('downloading'), null)
+})
+
+test('更新进度:缺字段的脏事件按 0 字节/未知总大小兜底,不产生 NaN', () => {
+ const progress = normalizeUpdateProgress({ status: 'failed', message: '下载失败' })
+ assert.ok(progress)
+ assert.equal(progress.downloaded, 0)
+ assert.equal(progress.total, 0)
+ assert.equal(progress.percent, -1, '缺百分比按未知处理')
+ assert.equal(updateProgressPercent(progress), -1, '未知总大小走不确定态')
+ assert.equal(Number.isNaN(progress.downloaded), false)
+})
+
+test('更新进度:百分比越界钳制到 0-100,负值视为不确定态', () => {
+ assert.equal(updateProgressPercent(progressOf({ percent: 120 })), 100)
+ assert.equal(updateProgressPercent(progressOf({ percent: -5 })), -1)
+ assert.equal(updateProgressPercent(progressOf({ percent: 99.6 })), 100, '四舍五入取整')
+ assert.equal(updateProgressPercent(null), -1)
+})
+
+test('更新进度文案:下载中显示百分比与已下载/总大小', () => {
+ const text = formatUpdateProgressText(progressOf({
+ downloaded: 62914560,
+ total: 104857600,
+ percent: 60,
+ }))
+ assert.equal(text, '正在下载更新包 60%(60.0 MB/100.0 MB)')
+})
+
+test('更新进度文案:总大小未知时显示已下载量,刚开始时退回阶段文案', () => {
+ assert.equal(
+ formatUpdateProgressText(progressOf({ downloaded: 3145728, percent: -1 })),
+ '正在下载更新包(已下载 3.0 MB)',
+ )
+ assert.equal(
+ formatUpdateProgressText(progressOf({ message: '正在准备下载更新包...' })),
+ '正在准备下载更新包...',
+ )
+})
+
+test('更新进度文案:校验中/已就绪/失败直接用 Python 侧说明(失败停在断点处)', () => {
+ assert.equal(
+ formatUpdateProgressText(progressOf({
+ status: 'verifying',
+ message: '安装包下载完成,正在校验完整性...',
+ })),
+ '安装包下载完成,正在校验完整性...',
+ )
+ assert.equal(
+ formatUpdateProgressText(progressOf({
+ status: 'ready',
+ message: '安装包已就绪,正在启动更新程序,程序即将退出...',
+ percent: 100,
+ })),
+ '安装包已就绪,正在启动更新程序,程序即将退出...',
+ )
+ const failed = progressOf({
+ status: 'failed',
+ message: '下载中断(65536/307510 字节),断点已保留,请重试续传',
+ downloaded: 65536,
+ total: 307510,
+ percent: 21.3,
+ })
+ assert.equal(formatUpdateProgressText(failed), '下载中断(65536/307510 字节),断点已保留,请重试续传')
+ assert.equal(updateProgressPercent(failed), 21, '失败时进度条停在断点百分比')
+ assert.equal(formatUpdateProgressText(null), '')
+})
+
+test('更新进度文案:字节数格式化覆盖 KB/MB 与异常输入', () => {
+ assert.equal(formatBytes(0), '0 MB')
+ assert.equal(formatBytes(-1), '0 MB')
+ assert.equal(formatBytes(Number.NaN), '0 MB')
+ assert.equal(formatBytes(512 * 1024), '512 KB')
+ assert.equal(formatBytes(3 * 1024 * 1024), '3.0 MB')
+})