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 @@
+
+
+
+ 指定版本更新
+
+
+
{{ error }}
+
+
+
+ {{ tip }}
+
+
+
+
+
+
+
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('未读到本机版本'))
+})