feat(客户端更新): 登录页/首页更新面板显示更新包下载进度条

- 新增 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 个既存失败用例(隔离复现),
与本次改动无关;全仓库仅新增测试引用本次新增模块。
This commit is contained in:
2026-09-11 01:59:42 +08:00
parent 47b2758fe8
commit 94a0c287e0
6 changed files with 361 additions and 5 deletions
@@ -23,6 +23,7 @@
</button>
</div>
<div class="update-hint">{{ hint }}</div>
<UpdateProgressBar :progress="progress" />
<div v-if="hasUpdate || canDownload" style="margin-top: 4px;">
<button type="button" class="btn-download-update" :disabled="updating" @click="doUpdate">
{{ updating ? '更新中...' : '立即更新' }}
@@ -61,12 +62,13 @@ import { computed, onMounted, ref } from 'vue'
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type PermissionMenuItem } from '@/shared/api/permission'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
import { resolvePageHref } from '@/shared/page-prefix'
const username = ref('')
const updatePanel = ref(false)
const toastText = ref('')
const { checking, updating, currentVersion, hasUpdate, canDownload, hint, checked, runCheck, doUpdate } =
const { checking, updating, currentVersion, hasUpdate, canDownload, hint, checked, progress, runCheck, doUpdate } =
useVersionUpdate()
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token/login?logout=1
@@ -87,6 +87,7 @@
</button>
</div>
<div class="update-hint">{{ hint }}</div>
<UpdateProgressBar :progress="progress" />
</div>
</div>
</div>
@@ -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()
@@ -0,0 +1,81 @@
<template>
<div
v-if="progress"
class="update-progress"
:class="{ 'is-error': progress.status === 'failed', 'is-indeterminate': percent < 0 }"
>
<div class="update-progress-track">
<div
class="update-progress-fill"
:style="percent >= 0 ? { width: percent + '%' } : undefined"
></div>
</div>
<div class="update-progress-text">{{ text }}</div>
</div>
</template>
<script setup lang="ts">
/**
* 客户端更新包下载进度条(登录页 / 首页更新面板共用)。
*
* 数据来自 Python 侧 do_update_app 推的 pywebview-update-progress 事件,
* 经 useVersionUpdate 的 progress 传入;总大小未知时走不确定态(滑动条纹),
* 失败时进度条停在断点处并用琥珀色区分"可重试续传"。
*/
import { computed } from 'vue'
import {
formatUpdateProgressText,
updateProgressPercent,
type UpdateProgress,
} from '@/shared/utils/update-progress'
const props = defineProps<{ progress: UpdateProgress | null }>()
const percent = computed(() => updateProgressPercent(props.progress))
const text = computed(() => formatUpdateProgressText(props.progress))
</script>
<style scoped>
.update-progress {
margin-top: 8px;
}
.update-progress-track {
height: 6px;
border-radius: 3px;
background: #e6e6e6;
overflow: hidden;
}
.update-progress-fill {
height: 100%;
width: 0;
border-radius: 3px;
background: #28a745;
transition: width 0.2s ease;
}
/* 总大小未知:用滑动条纹表示"在下载但算不出百分比" */
.update-progress.is-indeterminate .update-progress-fill {
width: 40%;
animation: update-progress-slide 1.2s ease-in-out infinite;
}
/* 失败:进度条停在断点处,用琥珀色区分"已暂停、可重试续传" */
.update-progress.is-error .update-progress-fill {
background: #d48806;
}
.update-progress-text {
font-size: 12px;
color: #666;
margin-top: 4px;
word-break: break-all;
}
@keyframes update-progress-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
</style>
@@ -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<UpdateProgress | null>(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<unknown>).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,
@@ -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}`
}
+120
View File
@@ -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>): 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')
})