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` 使用同一张表和同一次序规则。
|
目标逻辑: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_config(LIMIT,字段口径同管理端列表)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 类型映射
|
## 类型映射
|
||||||
|
|||||||
@@ -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}(未读到本机版本,无法比较新旧)。`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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