feat(version): 桌面端更新面板支持指定版本安装
- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表), 桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404) - 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版, 可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认 - 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
This commit is contained in:
@@ -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,字段口径同管理端列表)
|
||||
```
|
||||
|
||||
## 类型映射
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
{{ updating ? '更新中...' : '立即更新' }}
|
||||
</button>
|
||||
</div>
|
||||
<VersionPicker
|
||||
:versions="versions"
|
||||
:current-version="currentVersion"
|
||||
:loading="versionListLoading"
|
||||
:error="versionListError"
|
||||
:updating="updating"
|
||||
@reload="loadVersions(true)"
|
||||
@update="doUpdateVersion"
|
||||
/>
|
||||
</div>
|
||||
<NotificationBell theme="light" />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
<div class="update-hint">{{ hint }}</div>
|
||||
<UpdateLogList :entries="changelog" />
|
||||
<UpdateProgressBar :progress="progress" />
|
||||
<VersionPicker
|
||||
:versions="versions"
|
||||
:current-version="currentVersion"
|
||||
:loading="versionListLoading"
|
||||
:error="versionListError"
|
||||
:updating="updating"
|
||||
@reload="loadVersions(true)"
|
||||
@update="doUpdateVersion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 { 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 侧不轮询——桥调用虽然并发安全,但轮询会给主进程平白加请求。
|
||||
@@ -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<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)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}(未读到本机版本,无法比较新旧)。`
|
||||
}
|
||||
}
|
||||
@@ -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('未读到本机版本'))
|
||||
})
|
||||
Reference in New Issue
Block a user