From 94a0c287e0c28a688b6ce059b8b09dd34a4b2697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 11 Sep 2026 01:59:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9B=B4?= =?UTF-8?q?=E6=96=B0):=20=E7=99=BB=E5=BD=95=E9=A1=B5/=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=9D=A2=E6=9D=BF=E6=98=BE=E7=A4=BA=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=8C=85=E4=B8=8B=E8=BD=BD=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 shared/utils/update-progress.ts:进度事件解析/百分比钳制/文案格式化(纯函数) - 新增 shared/components/UpdateProgressBar.vue:两页共用,失败态琥珀色并停在断点 百分比、总大小未知走不确定态 - useVersionUpdate:先挂监听再发桥调用(避免丢首帧),监听 pywebview-update-progress (Python 侧 evaluate_js 推送,同 pywebview-download-progress 约定);失败保留上次进度 - 测试:tests/update-progress.test.ts(8 用例) 兼容:老客户端没有该事件源,进度条不出现,其余行为不变(优雅降级)。 注:本仓库 modules-withdraw / modules-image-video 有 3 个既存失败用例(隔离复现), 与本次改动无关;全仓库仅新增测试引用本次新增模块。 --- .../src/pages/home/DesktopHomePage.vue | 4 +- .../src/pages/login/DesktopLoginPage.vue | 3 + .../shared/components/UpdateProgressBar.vue | 81 ++++++++++++ .../shared/composables/useVersionUpdate.ts | 70 +++++++++- .../src/shared/utils/update-progress.ts | 88 +++++++++++++ frontend-vue/tests/update-progress.test.ts | 120 ++++++++++++++++++ 6 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 frontend-vue/src/shared/components/UpdateProgressBar.vue create mode 100644 frontend-vue/src/shared/utils/update-progress.ts create mode 100644 frontend-vue/tests/update-progress.test.ts 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') +})