feat(version): 桌面端更新面板支持指定版本安装

- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表),
  桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404)
- 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版,
  可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认
- 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
This commit is contained in:
2026-09-14 11:05:30 +08:00
parent 375b89154b
commit 8803e22f39
7 changed files with 515 additions and 20 deletions
+18 -3
View File
@@ -43,15 +43,30 @@
目标逻辑:Java `SoftwareVersionService.latestSoftwareVersion` 使用同一张表和同一次序规则。 目标逻辑: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 ```text
PublicVersionController PublicVersionController
├── currentVersion() ├── currentVersion()
│ └── 应用版本配置 │ └── 应用版本配置
── latestVersion() ── latestVersion()
└── SoftwareVersionService.latestSoftwareVersion() └── SoftwareVersionService.latestSoftwareVersion()
└── SoftwareVersionMapper → web_config └── SoftwareVersionMapper → web_config
└── listVersions()
└── SoftwareVersionService.listPublicSoftwareVersions()
└── SoftwareVersionMapper → web_configLIMIT,字段口径同管理端列表)
``` ```
## 类型映射 ## 类型映射
@@ -30,6 +30,15 @@
{{ updating ? '更新中...' : '立即更新' }} {{ updating ? '更新中...' : '立即更新' }}
</button> </button>
</div> </div>
<VersionPicker
:versions="versions"
:current-version="currentVersion"
:loading="versionListLoading"
:error="versionListError"
:updating="updating"
@reload="loadVersions(true)"
@update="doUpdateVersion"
/>
</div> </div>
<NotificationBell theme="light" /> <NotificationBell theme="light" />
<span class="username-text">{{ username || '未登录' }}</span> <span class="username-text">{{ username || '未登录' }}</span>
@@ -66,14 +75,31 @@ import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type Permis
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate' import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue' import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
import UpdateLogList from '@/shared/components/UpdateLogList.vue' import UpdateLogList from '@/shared/components/UpdateLogList.vue'
import VersionPicker from '@/shared/components/VersionPicker.vue'
import NotificationBell from '@/shared/components/NotificationBell.vue' import NotificationBell from '@/shared/components/NotificationBell.vue'
import { resolvePageHref } from '@/shared/page-prefix' import { resolvePageHref } from '@/shared/page-prefix'
const username = ref('') const username = ref('')
const updatePanel = ref(false) const updatePanel = ref(false)
const toastText = ref('') const toastText = ref('')
const { checking, updating, currentVersion, hasUpdate, canDownload, hint, checked, progress, changelog, runCheck, doUpdate } = const {
useVersionUpdate() checking,
updating,
currentVersion,
hasUpdate,
canDownload,
hint,
checked,
progress,
changelog,
versions,
versionListLoading,
versionListError,
loadVersions,
runCheck,
doUpdate,
doUpdateVersion,
} = useVersionUpdate()
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token/login?logout=1 // 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token/login?logout=1
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?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) { if (updatePanel.value && !checked.value && !checking.value) {
void runCheck() void runCheck()
} }
// 指定版本更新的可选版本:展开面板时拉一次(模块级缓存,登录页已拉过则不重复请求)
if (updatePanel.value) {
void loadVersions()
}
} }
onMounted(() => { onMounted(() => {
@@ -33,6 +33,15 @@
<div class="update-hint">{{ hint }}</div> <div class="update-hint">{{ hint }}</div>
<UpdateLogList :entries="changelog" /> <UpdateLogList :entries="changelog" />
<UpdateProgressBar :progress="progress" /> <UpdateProgressBar :progress="progress" />
<VersionPicker
:versions="versions"
:current-version="currentVersion"
:loading="versionListLoading"
:error="versionListError"
:updating="updating"
@reload="loadVersions(true)"
@update="doUpdateVersion"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -106,6 +115,7 @@ import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import { clearApiSecretCache } from '@/shared/utils/api-secret-store' import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue' import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
import UpdateLogList from '@/shared/components/UpdateLogList.vue' import UpdateLogList from '@/shared/components/UpdateLogList.vue'
import VersionPicker from '@/shared/components/VersionPicker.vue'
const router = useRouter() const router = useRouter()
@@ -126,7 +136,7 @@ const rememberPassword = ref(false)
const autoLogin = ref(false) const autoLogin = ref(false)
const updateOpen = ref(false) const updateOpen = ref(false)
// 版本检测 / 更新:登录页与首页共用逻辑 // 版本检测 / 更新:登录页与首页共用逻辑(指定版本更新见 VersionPicker
const { const {
checking, checking,
updating, updating,
@@ -138,8 +148,13 @@ const {
checked, checked,
progress, progress,
changelog, changelog,
versions,
versionListLoading,
versionListError,
loadVersions,
runCheck, runCheck,
doUpdate, doUpdate,
doUpdateVersion,
} = useVersionUpdate() } = useVersionUpdate()
function b64Decode(value: string): string { function b64Decode(value: string): string {
@@ -354,6 +369,10 @@ function toggleUpdate() {
if (updateOpen.value && !checked.value && !checking.value) { if (updateOpen.value && !checked.value && !checking.value) {
void runCheck() void runCheck()
} }
// 指定版本更新的可选版本:首次展开面板时拉一次(模块级缓存,切换页面不重复请求)
if (updateOpen.value) {
void loadVersions()
}
} }
async function submitLogin() { async function submitLogin() {
@@ -0,0 +1,184 @@
<template>
<div class="version-picker">
<div class="version-picker-head">
<span class="version-picker-title">指定版本更新</span>
<button
type="button"
class="version-picker-refresh"
:disabled="loading"
@click="emit('reload')"
>{{ loading ? '加载中...' : '刷新列表' }}</button>
</div>
<div v-if="error" class="version-picker-error">{{ error }}</div>
<template v-else>
<select v-model="selected" class="version-picker-select" :disabled="loading || !versions.length">
<option value="">{{ versions.length ? '请选择要安装的版本' : '暂无可选版本' }}</option>
<option v-for="item in versions" :key="item.version" :value="item.version">
v{{ item.version }}{{ item.createdAt ? ` · ${item.createdAt}` : '' }}
</option>
</select>
<button
type="button"
class="version-picker-btn"
:disabled="!selected || updating"
@click="submit"
>{{ updating ? '更新中...' : '更新到此版本' }}</button>
<div v-if="tip" class="version-picker-tip" :class="{ 'is-warn': isFallback }">{{ tip }}</div>
</template>
</div>
</template>
<script setup lang="ts">
/**
* 「指定版本更新」选择器(登录页 / 首页更新面板共用)。
*
* 只负责选择与提示;版本列表加载、下载安装流程都在 useVersionUpdate 里
* (列表项与判定文案见 shared/utils/version-picker)。
* 选到低于本机的版本时给出"回退操作"提示,实际确认框由 doUpdateVersion 再提示一次。
*/
import { computed, ref, watch } from 'vue'
import {
classifyTargetVersion,
describeTargetVersion,
type ClientVersionItem,
} from '@/shared/utils/version-picker'
const props = defineProps<{
versions: ClientVersionItem[]
/** 本机当前版本(空串=未读到,如旧客户端无版本桥) */
currentVersion?: string
loading?: boolean
error?: string
updating?: boolean
}>()
const emit = defineEmits<{
(e: 'update', item: ClientVersionItem): void
(e: 'reload'): void
}>()
const selected = ref('')
const selectedItem = computed(() =>
props.versions.find((item) => item.version === selected.value),
)
const kind = computed(() => classifyTargetVersion(props.currentVersion || '', selected.value))
const isFallback = computed(() => kind.value === 'older')
const tip = computed(() =>
selected.value ? describeTargetVersion(kind.value, props.currentVersion || '', selected.value) : '',
)
// 列表刷新后选中项可能已不存在(版本被删除):清空选择,避免误装;
// 无选中时默认选列表第一条(列表按版本号倒序,第一条即最新版)
watch(
() => props.versions,
(list) => {
if (selected.value && !list.some((item) => item.version === selected.value)) {
selected.value = ''
}
if (!selected.value && list.length) {
selected.value = list[0].version
}
},
{ immediate: true },
)
function submit() {
const item = selectedItem.value
if (!item) return
emit('update', item)
}
</script>
<style scoped>
.version-picker {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.version-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.version-picker-title {
font-size: 12.5px;
font-weight: 600;
color: #4e6479;
}
.version-picker-refresh {
padding: 0;
font-size: 11.5px;
color: #71808d;
background: transparent;
border: none;
cursor: pointer;
}
.version-picker-refresh:hover:not(:disabled) {
color: #3498db;
}
.version-picker-refresh:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.version-picker-error {
font-size: 12px;
color: #b8860b;
line-height: 1.6;
}
.version-picker-select {
width: 100%;
padding: 5px 6px;
font-size: 12.5px;
color: #333;
background: #fff;
border: 1px solid #d5e6f2;
border-radius: 6px;
outline: none;
}
.version-picker-btn {
width: 100%;
margin-top: 6px;
padding: 5px 12px;
font-size: 12.5px;
color: #fff;
background: #3498db;
border: none;
border-radius: 6px;
cursor: pointer;
}
.version-picker-btn:hover:not(:disabled) {
opacity: 0.9;
}
.version-picker-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.version-picker-tip {
margin-top: 6px;
font-size: 11.5px;
line-height: 1.6;
color: #8293a5;
word-break: break-word;
}
/* 回退提示用醒目色:低于本机的版本必须让用户看清"这是回退" */
.version-picker-tip.is-warn {
color: #b8860b;
}
</style>
@@ -10,16 +10,29 @@ import { computed, ref } from 'vue'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { selectChangelogEntries, type ClientChangelogEntry } from '@/shared/client-changelog' import { selectChangelogEntries, type ClientChangelogEntry } from '@/shared/client-changelog'
import { compareVersions, normVersion } from '@/shared/utils/version-compare' import { compareVersions, normVersion } from '@/shared/utils/version-compare'
import {
classifyTargetVersion,
describeTargetVersion,
parseVersionList,
type ClientVersionItem,
} from '@/shared/utils/version-picker'
import { import {
normalizeUpdateProgress, normalizeUpdateProgress,
type UpdateProgress, type UpdateProgress,
} from '@/shared/utils/update-progress' } from '@/shared/utils/update-progress'
export type { UpdateProgress, UpdateProgressStatus } 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) const updateProgress = ref<UpdateProgress | null>(null)
let progressListenerBound = false let progressListenerBound = false
// 公开版本列表:登录页与首页共用同一份缓存(SPA 内路由切换不重复拉取)
const versions = ref<ClientVersionItem[]>([])
const versionListLoading = ref(false)
const versionListError = ref('')
let versionListLoaded = false
/** /**
* 绑定一次全局监听:下载进度由 Python 侧主动推事件(同 pywebview-download-progress 约定), * 绑定一次全局监听:下载进度由 Python 侧主动推事件(同 pywebview-download-progress 约定),
* JS 侧不轮询——桥调用虽然并发安全,但轮询会给主进程平白加请求。 * JS 侧不轮询——桥调用虽然并发安全,但轮询会给主进程平白加请求。
@@ -70,6 +83,30 @@ export async function fetchLatestVersion(): Promise<{ version: string; fileUrl:
return { version: normVersion(data.version), fileUrl: String(data.file_url ?? '') } 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() { export function useVersionUpdate() {
const checking = ref(false) const checking = ref(false)
const updating = 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 bridge = getPywebviewApi()
const doUpdateApp = bridge?.do_update_app const doUpdateApp = bridge?.do_update_app
const desktopUpdate = Boolean(doUpdateApp) if (!doUpdateApp) {
const tip = desktopUpdate
? `有更新,是否现在更新?${changelogConfirmSuffix()}\n更新将下载安装包并重启程序。`
: `发现新版本,是否下载最新安装包?${changelogConfirmSuffix()}`
if (!window.confirm(tip)) return
updating.value = true
if (!desktopUpdate) {
// 网页/无桌面桥形态:OSS 公开直链直接下载安装包(区别于桌面端自动下载安装) // 网页/无桌面桥形态:OSS 公开直链直接下载安装包(区别于桌面端自动下载安装)
hint.value = '开始下载安装包,若浏览器未响应请再次点击...' hint.value = '开始下载安装包,若浏览器未响应请再次点击...'
window.location.href = fileUrl.value window.location.href = url
updating.value = false updating.value = false
return return
} }
@@ -161,7 +193,7 @@ export function useVersionUpdate() {
} }
hint.value = '正在下载并准备更新,程序将自动退出...' hint.value = '正在下载并准备更新,程序将自动退出...'
try { try {
const result = await doUpdateApp!(fileUrl.value) const result = await doUpdateApp(url)
if (result?.success) { if (result?.success) {
const last = updateProgress.value const last = updateProgress.value
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 // 进度条的百分比/文案由 UpdateProgressBar 组件从 progress 自行派生(见 shared/utils/update-progress
const progress = computed(() => updateProgress.value) const progress = computed(() => updateProgress.value)
@@ -206,9 +266,16 @@ export function useVersionUpdate() {
selectChangelogEntries({ currentVersion: currentVersion.value, latestVersion: latestVersion.value }), selectChangelogEntries({ currentVersion: currentVersion.value, latestVersion: latestVersion.value }),
) )
/** 更新确认框里的"本次更新内容":只取最新一条、最多 5 行,避免弹窗过长 */ /**
function changelogConfirmSuffix(): string { * 更新确认框里的"本次更新内容":按"本机版本 → 目标版本"区间取最新一条、最多 5 行,
const items = (changelog.value[0]?.items ?? []).slice(0, 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 items.length ? `\n本次更新内容:\n${items.map((item) => `· ${item}`).join('\n')}` : ''
} }
@@ -224,8 +291,13 @@ export function useVersionUpdate() {
checked, checked,
progress, progress,
changelog, changelog,
versions,
versionListLoading,
versionListError,
loadCurrentVersion, loadCurrentVersion,
loadVersions,
runCheck, runCheck,
doUpdate, doUpdate,
doUpdateVersion,
} }
} }
@@ -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<string, unknown>
const rawItems = Array.isArray(record.items)
? record.items
: Array.isArray(data)
? (data as unknown[])
: []
const byVersion = new Map<string, ClientVersionItem>()
for (const raw of rawItems) {
if (!raw || typeof raw !== 'object') continue
const row = raw as Record<string, unknown>
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}(未读到本机版本,无法比较新旧)。`
}
}
+86
View File
@@ -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<Record<string, unknown>>) {
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('未读到本机版本'))
})