diff --git a/docs/specs/09-public-version-compatibility.md b/docs/specs/09-public-version-compatibility.md index 12a15e5d..b4a74f79 100644 --- a/docs/specs/09-public-version-compatibility.md +++ b/docs/specs/09-public-version-compatibility.md @@ -43,15 +43,30 @@ 目标逻辑:Java `SoftwareVersionService.latestSoftwareVersion` 使用同一张表和同一次序规则。 +### GET `/api/version/list`(2026-09-14 新增,非旧接口) + +桌面端更新面板「指定版本更新」下拉的数据源:该功能允许用户显式安装某个历史版本(含回退), +列表由服务端给全,前端不拼 OSS 地址(拼地址在版本被删除后会 404)。 + +- 匿名可达(与 `/latest` 同族,未登录也能用;登录页更新面板即匿名态)。 +- 按 `created_at DESC, id DESC` 返回最近 `PublicVersionLookup.DEFAULT_LIST_LIMIT`(50)条, + 上限 `MAX_LIST_LIMIT`(200);不作为则沿用旧 `version/file_url` 字段名。 +- 响应 `{"items":[{"id","version","file_url","created_at"}]}`,字段口径与管理端 + `/api/admin/versions` 一致(同一套前端解析);空表返回 `{"items":[]}`。 +- 同一版本的重复上传记录由前端按版本号去重(保留最新一条),后端不做折叠。 + ## 内部结构 ```text PublicVersionController ├── currentVersion() │ └── 应用版本配置 -└── latestVersion() - └── SoftwareVersionService.latestSoftwareVersion() - └── SoftwareVersionMapper → web_config +├── latestVersion() +│ └── SoftwareVersionService.latestSoftwareVersion() +│ └── SoftwareVersionMapper → web_config +└── listVersions() + └── SoftwareVersionService.listPublicSoftwareVersions() + └── SoftwareVersionMapper → web_config(LIMIT,字段口径同管理端列表) ``` ## 类型映射 diff --git a/frontend-vue/src/pages/home/DesktopHomePage.vue b/frontend-vue/src/pages/home/DesktopHomePage.vue index c2c66db3..457017f4 100644 --- a/frontend-vue/src/pages/home/DesktopHomePage.vue +++ b/frontend-vue/src/pages/home/DesktopHomePage.vue @@ -30,6 +30,15 @@ {{ updating ? '更新中...' : '立即更新' }} + {{ username || '未登录' }} @@ -66,14 +75,31 @@ import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type Permis import { useVersionUpdate } from '@/shared/composables/useVersionUpdate' import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue' import UpdateLogList from '@/shared/components/UpdateLogList.vue' +import VersionPicker from '@/shared/components/VersionPicker.vue' import NotificationBell from '@/shared/components/NotificationBell.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, progress, changelog, runCheck, doUpdate } = - useVersionUpdate() +const { + checking, + updating, + currentVersion, + hasUpdate, + canDownload, + hint, + checked, + progress, + changelog, + versions, + versionListLoading, + versionListError, + loadVersions, + runCheck, + doUpdate, + doUpdateVersion, +} = useVersionUpdate() // 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(/login?logout=1) const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`) @@ -171,6 +197,10 @@ function toggleUpdatePanel() { if (updatePanel.value && !checked.value && !checking.value) { void runCheck() } + // 指定版本更新的可选版本:展开面板时拉一次(模块级缓存,登录页已拉过则不重复请求) + if (updatePanel.value) { + void loadVersions() + } } onMounted(() => { diff --git a/frontend-vue/src/pages/login/DesktopLoginPage.vue b/frontend-vue/src/pages/login/DesktopLoginPage.vue index 9f7b4b58..8c5da842 100644 --- a/frontend-vue/src/pages/login/DesktopLoginPage.vue +++ b/frontend-vue/src/pages/login/DesktopLoginPage.vue @@ -33,6 +33,15 @@
{{ hint }}
+ @@ -106,6 +115,7 @@ import { useVersionUpdate } from '@/shared/composables/useVersionUpdate' import { clearApiSecretCache } from '@/shared/utils/api-secret-store' import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue' import UpdateLogList from '@/shared/components/UpdateLogList.vue' +import VersionPicker from '@/shared/components/VersionPicker.vue' const router = useRouter() @@ -126,7 +136,7 @@ const rememberPassword = ref(false) const autoLogin = ref(false) const updateOpen = ref(false) -// 版本检测 / 更新:登录页与首页共用逻辑 +// 版本检测 / 更新:登录页与首页共用逻辑(指定版本更新见 VersionPicker) const { checking, updating, @@ -138,8 +148,13 @@ const { checked, progress, changelog, + versions, + versionListLoading, + versionListError, + loadVersions, runCheck, doUpdate, + doUpdateVersion, } = useVersionUpdate() function b64Decode(value: string): string { @@ -354,6 +369,10 @@ function toggleUpdate() { if (updateOpen.value && !checked.value && !checking.value) { void runCheck() } + // 指定版本更新的可选版本:首次展开面板时拉一次(模块级缓存,切换页面不重复请求) + if (updateOpen.value) { + void loadVersions() + } } async function submitLogin() { diff --git a/frontend-vue/src/shared/components/VersionPicker.vue b/frontend-vue/src/shared/components/VersionPicker.vue new file mode 100644 index 00000000..49f607f3 --- /dev/null +++ b/frontend-vue/src/shared/components/VersionPicker.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend-vue/src/shared/composables/useVersionUpdate.ts b/frontend-vue/src/shared/composables/useVersionUpdate.ts index fc6c006e..56769be4 100644 --- a/frontend-vue/src/shared/composables/useVersionUpdate.ts +++ b/frontend-vue/src/shared/composables/useVersionUpdate.ts @@ -10,16 +10,29 @@ 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(null) let progressListenerBound = false +// 公开版本列表:登录页与首页共用同一份缓存(SPA 内路由切换不重复拉取) +const versions = ref([]) +const versionListLoading = ref(false) +const versionListError = ref('') +let versionListLoaded = false + /** * 绑定一次全局监听:下载进度由 Python 侧主动推事件(同 pywebview-download-progress 约定), * JS 侧不轮询——桥调用虽然并发安全,但轮询会给主进程平白加请求。 @@ -70,6 +83,30 @@ export async function fetchLatestVersion(): Promise<{ version: string; fileUrl: return { version: normVersion(data.version), fileUrl: String(data.file_url ?? '') } } +/** + * 拉取公开版本列表("指定版本更新"可选项,匿名可达,最近若干条)。 + * 只在首次展开面板/点刷新时请求;失败保留上一次成功的列表,错误交给 UI 提示重试。 + */ +export async function loadVersions(force = false): Promise { + 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) @@ -133,20 +170,15 @@ export function useVersionUpdate() { } } - async function doUpdate() { - if (updating.value || !fileUrl.value) return + /** 下载并安装指定更新包("立即更新"与"指定版本更新"共用;确认框由各自入口负责)。 */ + async function startUpdate(url: string) { + updating.value = true const bridge = getPywebviewApi() const doUpdateApp = bridge?.do_update_app - const desktopUpdate = Boolean(doUpdateApp) - const tip = desktopUpdate - ? `有更新,是否现在更新?${changelogConfirmSuffix()}\n更新将下载安装包并重启程序。` - : `发现新版本,是否下载最新安装包?${changelogConfirmSuffix()}` - if (!window.confirm(tip)) return - updating.value = true - if (!desktopUpdate) { + if (!doUpdateApp) { // 网页/无桌面桥形态:OSS 公开直链直接下载安装包(区别于桌面端自动下载安装) hint.value = '开始下载安装包,若浏览器未响应请再次点击...' - window.location.href = fileUrl.value + window.location.href = url updating.value = false return } @@ -161,7 +193,7 @@ export function useVersionUpdate() { } hint.value = '正在下载并准备更新,程序将自动退出...' try { - const result = await doUpdateApp!(fileUrl.value) + const result = await doUpdateApp(url) if (result?.success) { const last = updateProgress.value updateProgress.value = { @@ -198,6 +230,34 @@ export function useVersionUpdate() { } } + 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) @@ -206,9 +266,16 @@ export function useVersionUpdate() { selectChangelogEntries({ currentVersion: currentVersion.value, latestVersion: latestVersion.value }), ) - /** 更新确认框里的"本次更新内容":只取最新一条、最多 5 行,避免弹窗过长 */ - function changelogConfirmSuffix(): string { - const items = (changelog.value[0]?.items ?? []).slice(0, 5) + /** + * 更新确认框里的"本次更新内容":按"本机版本 → 目标版本"区间取最新一条、最多 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')}` : '' } @@ -224,8 +291,13 @@ export function useVersionUpdate() { checked, progress, changelog, + versions, + versionListLoading, + versionListError, loadCurrentVersion, + loadVersions, runCheck, doUpdate, + doUpdateVersion, } } diff --git a/frontend-vue/src/shared/utils/version-picker.ts b/frontend-vue/src/shared/utils/version-picker.ts new file mode 100644 index 00000000..3f61623c --- /dev/null +++ b/frontend-vue/src/shared/utils/version-picker.ts @@ -0,0 +1,89 @@ +/** + * 桌面端「指定版本更新」纯逻辑:解析公开版本列表、按版本去重排序、 + * 判定目标版本相对本机是升级 / 同版 / 回退。 + * + * 数据源:GET /api/version/list(公开接口,字段口径同管理端 /api/admin/versions)。 + * 回退(older)是本功能允许的显式操作,但**必须给出明确文案**,不得静默降级 + * (背景见 version-compare 的"不引导降级"约定:自动检测不提示降级,手动指定除外)。 + */ + +import { compareVersions, normVersion } from './version-compare.ts' + +/** 公开版本列表项(同一版本重复上传时只保留最新一条记录) */ +export interface ClientVersionItem { + /** web_config 记录 id */ + id?: number | string + /** 版本号(已归一化,不带 v 前缀) */ + version: string + /** 安装包公开下载直链 */ + fileUrl: string + /** 发布记录时间(yyyy-MM-dd HH:mm,接口原样透传) */ + createdAt: string +} + +/** 目标版本相对本机的类型:更新 / 同版本 / 回退 / 本机版本未知 */ +export type TargetVersionKind = 'newer' | 'same' | 'older' | 'unknown' + +function pickString(value: unknown): string { + if (value === null || value === undefined) return '' + return String(value).trim() +} + +/** + * 解析 /api/version/list 响应。 + * + * - 无版本号或无下载直链的行直接丢弃(选了也装不了); + * - 同一版本重复上传会有多行,只保留先出现的一条(接口按 created_at 倒序,即最新记录); + * - 最终按版本号从高到低排序:列表是给人挑的,版本序比发布时间序直观 + * (旧版本重新上传会让它在接口里排到最前,但版本号仍然是低的)。 + */ +export function parseVersionList(data: unknown): ClientVersionItem[] { + const record = (data ?? {}) as Record + const rawItems = Array.isArray(record.items) + ? record.items + : Array.isArray(data) + ? (data as unknown[]) + : [] + const byVersion = new Map() + for (const raw of rawItems) { + if (!raw || typeof raw !== 'object') continue + const row = raw as Record + const version = normVersion(row.version) + const fileUrl = pickString(row.file_url ?? row.fileUrl) + const createdAt = pickString(row.created_at ?? row.createdAt) + if (!version || !fileUrl) continue + if (byVersion.has(version)) continue + byVersion.set(version, { + id: row.id as number | string | undefined, + version, + fileUrl, + createdAt, + }) + } + return [...byVersion.values()].sort((a, b) => compareVersions(b.version, a.version)) +} + +/** 判定目标版本相对本机版本的类型;本机版本拿不到(旧客户端无版本桥)时为 unknown。 */ +export function classifyTargetVersion(current: string, target: string): TargetVersionKind { + const local = normVersion(current) + const remote = normVersion(target) + if (!local || !remote) return 'unknown' + const diff = compareVersions(remote, local) + if (diff > 0) return 'newer' + if (diff < 0) return 'older' + return 'same' +} + +/** 目标版本提示/确认文案(同版与回退必须说清楚,避免用户以为在升级)。 */ +export function describeTargetVersion(kind: TargetVersionKind, current: string, target: string): string { + switch (kind) { + case 'older': + return `v${target} 低于当前版本 v${current},属于回退操作,请确认线上问题确实由新版引起。` + case 'same': + return `与当前版本相同(v${current}),将重新下载并安装。` + case 'newer': + return `将从 v${current} 更新到 v${target}。` + default: + return `将安装 v${target}(未读到本机版本,无法比较新旧)。` + } +} diff --git a/frontend-vue/tests/version-picker.test.ts b/frontend-vue/tests/version-picker.test.ts new file mode 100644 index 00000000..52af805c --- /dev/null +++ b/frontend-vue/tests/version-picker.test.ts @@ -0,0 +1,86 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + classifyTargetVersion, + describeTargetVersion, + parseVersionList, +} from '../src/shared/utils/version-picker.ts' + +/** /api/version/list 响应样例:接口按 created_at DESC 返回(旧版本重传会排最前) */ +function listResponse(items: Array>) { + return { items } +} + +test('版本列表解析:同一版本重复上传只保留先出现(最新)的一条记录', () => { + const items = parseVersionList(listResponse([ + { id: 9, version: '4.0.13', file_url: 'https://oss/4.0.13-new.zip', created_at: '2026-09-14 10:00' }, + { id: 5, version: '4.0.14', file_url: 'https://oss/4.0.14.zip', created_at: '2026-09-14 09:00' }, + { id: 2, version: '4.0.13', file_url: 'https://oss/4.0.13-old.zip', created_at: '2026-09-13 10:00' }, + ])) + assert.equal(items.length, 2) + const reuploaded = items.find((item) => item.version === '4.0.13') + assert.equal(reuploaded?.fileUrl, 'https://oss/4.0.13-new.zip', '同版本应取最新上传的包') +}) + +test('版本列表解析:无下载地址或空版本号的行被丢弃(选了也装不了)', () => { + const items = parseVersionList(listResponse([ + { id: 1, version: '4.0.14', file_url: 'https://oss/4.0.14.zip', created_at: '2026-09-14 10:36' }, + { id: 2, version: '4.0.13', file_url: '', created_at: '2026-09-13 20:00' }, + { id: 3, version: ' ', file_url: 'https://oss/blank.zip', created_at: '2026-09-13 19:00' }, + { id: 4, version: null, file_url: 'https://oss/null.zip', created_at: '2026-09-13 18:00' }, + null, + 'not-an-object', + ])) + assert.deepEqual(items.map((item) => item.version), ['4.0.14']) +}) + +test('版本列表解析:按版本号从高到低排序,旧版本重传不会排到最前', () => { + const items = parseVersionList(listResponse([ + // 旧版本刚重传:created_at 最新,但版本号最低 + { id: 30, version: '4.0.9', file_url: 'https://oss/4.0.9.zip', created_at: '2026-09-14 11:00' }, + { id: 29, version: '4.0.14', file_url: 'https://oss/4.0.14.zip', created_at: '2026-09-14 10:00' }, + { id: 28, version: '4.0.13', file_url: 'https://oss/4.0.13.zip', created_at: '2026-09-13 20:00' }, + ])) + assert.deepEqual(items.map((item) => item.version), ['4.0.14', '4.0.13', '4.0.9']) +}) + +test('版本列表解析:兼容驼峰字段、v 前缀与空/异常响应', () => { + const items = parseVersionList(listResponse([ + { id: 1, version: 'v4.0.14', fileUrl: 'https://oss/4.0.14.zip', createdAt: '2026-09-14 10:36' }, + ])) + assert.deepEqual(items, [ + { id: 1, version: '4.0.14', fileUrl: 'https://oss/4.0.14.zip', createdAt: '2026-09-14 10:36' }, + ]) + assert.deepEqual(parseVersionList(null), []) + assert.deepEqual(parseVersionList({}), []) + assert.deepEqual(parseVersionList({ items: 'oops' }), []) +}) + +test('目标版本判定:高于本机=更新,相等=同版,低于本机=回退', () => { + assert.equal(classifyTargetVersion('4.0.12', '4.0.14'), 'newer') + assert.equal(classifyTargetVersion('4.0.14', '4.0.14'), 'same') + assert.equal(classifyTargetVersion('4.0.14', '4.0.13'), 'older') + // 多段补零后仍相等 + assert.equal(classifyTargetVersion('4.0.14', '4.0.14.0'), 'same') +}) + +test('目标版本判定:本机版本未知时返回 unknown,不误判为升级', () => { + assert.equal(classifyTargetVersion('', '4.0.14'), 'unknown') + assert.equal(classifyTargetVersion(undefined as unknown as string, '4.0.14'), 'unknown') + assert.equal(classifyTargetVersion('4.0.14', ''), 'unknown') +}) + +test('回退文案必须写明低于当前版本(回退是显式操作,不得静默降级)', () => { + const text = describeTargetVersion('older', '4.0.14', '4.0.13') + assert.ok(text.includes('4.0.13'), '文案要带目标版本号') + assert.ok(text.includes('低于当前版本'), '回退必须明确提示低于本机版本') + assert.ok(text.includes('4.0.14'), '文案要带本机版本号') +}) + +test('同版与更新文案各自说明动作,本机版本未知时如实说明无法比较', () => { + assert.ok(describeTargetVersion('same', '4.0.14', '4.0.14').includes('与当前版本相同')) + const newer = describeTargetVersion('newer', '4.0.12', '4.0.14') + assert.ok(newer.includes('4.0.12') && newer.includes('4.0.14')) + assert.ok(describeTargetVersion('unknown', '', '4.0.14').includes('未读到本机版本')) +})