Files
crawler-plugin/frontend-vue/src/shared/composables/useVersionUpdate.ts
T
huangzd1997 8803e22f39 feat(version): 桌面端更新面板支持指定版本安装
- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表),
  桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404)
- 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版,
  可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认
- 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
2026-09-14 11:05:30 +08:00

304 lines
11 KiB
TypeScript
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.
/**
* 版本检测 / 更新共享逻辑(登录页、首页共用)
*
* 服务端化后桌面页面直载线上 Web:检测"是否有新版"需把线上 /api/version/latest
* 与"本机 exe 版本"比较。本机版本经 pywebview 桥 get_app_version() 获取(新版客户端
* 提供);旧客户端无此桥时降级:本机版本为空 → 只要线上有包就允许手动更新。
*/
import { computed, ref } from 'vue'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { selectChangelogEntries, type ClientChangelogEntry } from '@/shared/client-changelog'
import { compareVersions, normVersion } from '@/shared/utils/version-compare'
import {
classifyTargetVersion,
describeTargetVersion,
parseVersionList,
type ClientVersionItem,
} from '@/shared/utils/version-picker'
import {
normalizeUpdateProgress,
type UpdateProgress,
} from '@/shared/utils/update-progress'
export type { UpdateProgress, UpdateProgressStatus } from '@/shared/utils/update-progress'
export type { ClientVersionItem } from '@/shared/utils/version-picker'
const updateProgress = ref<UpdateProgress | null>(null)
let progressListenerBound = false
// 公开版本列表:登录页与首页共用同一份缓存(SPA 内路由切换不重复拉取)
const versions = ref<ClientVersionItem[]>([])
const versionListLoading = ref(false)
const versionListError = ref('')
let versionListLoaded = 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
})
}
async function fetchJson(url: string): Promise<Record<string, unknown>> {
const resp = await window.fetch(url, { credentials: 'same-origin' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const text = await resp.text()
if (!text) return {}
try {
return JSON.parse(text) as Record<string, unknown>
} catch {
return {}
}
}
/**
* 读取本机客户端版本号:优先桌面桥;无桥(旧版 exe / 浏览器环境)返回空串。
*/
export async function resolveLocalVersion(): Promise<string> {
const bridge = getPywebviewApi()
if (bridge?.get_app_version) {
try {
const res = await bridge.get_app_version()
if (res?.success && res.version) {
return normVersion(res.version)
}
} catch {
/* 忽略,走空版本降级 */
}
}
return ''
}
/** 拉取线上最新客户端版本(公开接口,无需登录;web_config 无记录时 version 为空)。 */
export async function fetchLatestVersion(): Promise<{ version: string; fileUrl: string }> {
const data = await fetchJson('/api/version/latest')
return { version: normVersion(data.version), fileUrl: String(data.file_url ?? '') }
}
/**
* 拉取公开版本列表("指定版本更新"可选项,匿名可达,最近若干条)。
* 只在首次展开面板/点刷新时请求;失败保留上一次成功的列表,错误交给 UI 提示重试。
*/
export async function loadVersions(force = false): Promise<void> {
if (versionListLoading.value) return
if (versionListLoaded && !force) return
versionListLoading.value = true
versionListError.value = ''
try {
const data = await fetchJson('/api/version/list')
const items = parseVersionList(data)
versions.value = items
versionListLoaded = true
if (!items.length) {
versionListError.value = '暂无可选版本'
}
} catch {
versionListError.value = '版本列表加载失败,点击"刷新列表"重试'
} finally {
versionListLoading.value = false
}
}
export function useVersionUpdate() {
const checking = ref(false)
const updating = ref(false)
const currentVersion = ref('')
const latestVersion = ref('')
const fileUrl = ref('')
const hasUpdate = ref(false)
/** 本地版本未知但线上有包时也允许"立即更新",此标志标记可下载 */
const canDownload = ref(false)
const hint = ref('')
const checked = ref(false)
async function loadCurrentVersion() {
if (currentVersion.value) return
currentVersion.value = await resolveLocalVersion()
}
async function runCheck() {
if (checking.value) return
checking.value = true
hint.value = '正在检测更新...'
try {
const local = await resolveLocalVersion()
currentVersion.value = local
const latest = await fetchLatestVersion()
latestVersion.value = latest.version
fileUrl.value = latest.fileUrl
checked.value = true
if (!latest.version) {
hasUpdate.value = false
canDownload.value = false
hint.value = '暂无版本更新信息'
return
}
if (!local) {
// 旧客户端无本机版本桥:无法精确比较,线上有包即可手动更新
hasUpdate.value = false
canDownload.value = Boolean(latest.fileUrl)
hint.value = latest.fileUrl
? `线上最新版本 v${latest.version},可点击下方按钮手动更新`
: `线上最新版本 v${latest.version},暂无下载地址`
return
}
// 只有线上版本严格高于本机版本才算"有更新":本机领先(灰度/回滚/漏发版)
// 时不能反过来提示更新,否则会把用户往低版本上引导。
const diff = compareVersions(latest.version, local)
hasUpdate.value = diff > 0
canDownload.value = hasUpdate.value && Boolean(latest.fileUrl)
hint.value = hasUpdate.value
? `发现新版本 v${latest.version}${latest.fileUrl ? '' : ',暂无下载地址'}`
: diff < 0
? `当前版本 v${local} 高于线上发布版本 v${latest.version},无需更新`
: `已是最新版本 v${local}`
} catch {
hasUpdate.value = false
canDownload.value = false
hint.value = '检测更新失败,请稍后重试'
} finally {
checking.value = false
}
}
/** 下载并安装指定更新包("立即更新"与"指定版本更新"共用;确认框由各自入口负责)。 */
async function startUpdate(url: string) {
updating.value = true
const bridge = getPywebviewApi()
const doUpdateApp = bridge?.do_update_app
if (!doUpdateApp) {
// 网页/无桌面桥形态:OSS 公开直链直接下载安装包(区别于桌面端自动下载安装)
hint.value = '开始下载安装包,若浏览器未响应请再次点击...'
window.location.href = url
updating.value = false
return
}
// 先挂监听再发桥调用:进度事件在下载开始后立刻开推,晚绑会丢最前面的进度
bindProgressListener()
updateProgress.value = {
status: 'downloading',
message: '正在准备下载更新包...',
downloaded: 0,
total: 0,
percent: -1,
}
hint.value = '正在下载并准备更新,程序将自动退出...'
try {
const result = await doUpdateApp(url)
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
}
}
async function doUpdate() {
if (updating.value || !fileUrl.value) return
const desktopUpdate = Boolean(getPywebviewApi()?.do_update_app)
const tip = desktopUpdate
? `有更新,是否现在更新?${changelogConfirmSuffix(latestVersion.value)}\n更新将下载安装包并重启程序。`
: `发现新版本,是否下载最新安装包?${changelogConfirmSuffix(latestVersion.value)}`
if (!window.confirm(tip)) return
await startUpdate(fileUrl.value)
}
/**
* 指定版本更新:安装列表里挑中的那个版本(可高于、等于或低于本机)。
* 确认框必须写明目标版本与升级/回退性质——回退是用户显式选择,不能静默降级。
*/
async function doUpdateVersion(item: ClientVersionItem) {
if (updating.value || !item?.fileUrl) return
await loadCurrentVersion()
const kind = classifyTargetVersion(currentVersion.value, item.version)
const desktopUpdate = Boolean(getPywebviewApi()?.do_update_app)
const head = desktopUpdate ? `确认更新到 v${item.version}` : `确认下载 v${item.version} 安装包?`
const tip =
`${head}\n${describeTargetVersion(kind, currentVersion.value, item.version)}` +
`${changelogConfirmSuffix(item.version)}` +
(desktopUpdate ? '\n更新将下载安装包并重启程序。' : '')
if (!window.confirm(tip)) return
await startUpdate(item.fileUrl)
}
// 进度条的百分比/文案由 UpdateProgressBar 组件从 progress 自行派生(见 shared/utils/update-progress
const progress = computed(() => updateProgress.value)
// 更新日志:本机版本之后到最新版本之间的条目(文案见 shared/client-changelog
const changelog = computed<ClientChangelogEntry[]>(() =>
selectChangelogEntries({ currentVersion: currentVersion.value, latestVersion: latestVersion.value }),
)
/**
* 更新确认框里的"本次更新内容":按"本机版本 → 目标版本"区间取最新一条、最多 5 行,
* 避免弹窗过长;目标版本不高于本机(同版/回退)时区间为空,不带这段文案。
*/
function changelogConfirmSuffix(targetVersion: string): string {
const entries = selectChangelogEntries({
currentVersion: currentVersion.value,
latestVersion: targetVersion,
})
const items = (entries[0]?.items ?? []).slice(0, 5)
return items.length ? `\n本次更新内容:\n${items.map((item) => ${item}`).join('\n')}` : ''
}
return {
checking,
updating,
currentVersion,
latestVersion,
fileUrl,
hasUpdate,
canDownload,
hint,
checked,
progress,
changelog,
versions,
versionListLoading,
versionListError,
loadCurrentVersion,
loadVersions,
runCheck,
doUpdate,
doUpdateVersion,
}
}