zjk
2026-09-18 f74638260f82ddaa6921b907aa115b600092752c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
import request from '@/utils/request.js'
 
let hasCheckedVersionThisLaunch = false
const IOS_APP_STORE_URL = 'https://apps.apple.com/cn/app/%E6%B5%B7%E8%AF%8A%E5%BA%B7%E9%81%93/id6762694057'
 
/**
 * 版本检查接口要求的平台字段:iOS 传 ios,Android 传 Android。
 */
export function resolveVersionCheckPlatform() {
    const platform = String(uni.getSystemInfoSync().platform || '').toLowerCase()
    if (platform === 'android') {
        return 'Android'
    }
    return 'ios'
}
 
/**
 * 去掉展示文案里的 V 前缀,统一版本号格式。
 */
export function normalizeVersionText(value) {
    if (typeof value !== 'string') {
        return ''
    }
    const trimmedValue = value.trim()
    if (!trimmedValue) {
        return ''
    }
    return trimmedValue.replace(/^[vV]/, '')
}
 
/**
 * 检查 APP 最新版本信息。
 */
export function checkAppVersion(showLoading = true) {
    return request.get('/api/app/version/check', { platform: resolveVersionCheckPlatform() }, false, showLoading)
}
 
/**
 * 按真实接口结构归一化版本检查数据。
 */
export function normalizeVersionCheckData(data) {
    const source = data && typeof data === 'object' ? data : {}
    const rawDownloadUrl = typeof source.downloadUrl === 'string' ? source.downloadUrl.trim() : ''
    return {
        hasUpdate: Boolean(source.hasUpdate),
        latestVersionNo: normalizeVersionText(source.latestVersionNo),
        latestVersionCode: Number.isFinite(Number(source.latestVersionCode)) ? Number(source.latestVersionCode) : null,
        forceUpdate: Boolean(source.forceUpdate),
        updateNotice: typeof source.updateNotice === 'string' ? source.updateNotice.trim() : '',
        downloadUrl: rawDownloadUrl.replace(/^`|`$/g, ''),
    }
}
 
/**
 * 比较当前版本与最新版本是否真的存在升级。
 * 即使后端误返回 hasUpdate=true,只要版本号相同,前端也不再弹更新提示。
 */
export function shouldShowVersionUpdate(currentVersion, latestVersion) {
    const safeCurrentVersion = normalizeVersionText(currentVersion)
    const safeLatestVersion = normalizeVersionText(latestVersion)
    if (!safeCurrentVersion || !safeLatestVersion) {
        return false
    }
    if (safeCurrentVersion === safeLatestVersion) {
        return false
    }
    return compareVersionText(safeCurrentVersion, safeLatestVersion) < 0
}
 
/**
 * 优先读取运行时版本号,取不到时回退 manifest 配置版本。
 */
export function resolveRuntimeVersion(fallbackVersion = '') {
    // #ifdef APP-PLUS
    try {
        const runtimeVersion = typeof plus !== 'undefined' && plus.runtime ? normalizeVersionText(plus.runtime.version) : ''
        if (runtimeVersion) {
            return runtimeVersion
        }
    } catch (e) {
        console.warn('[app-version] 读取运行时版本失败:', e)
    }
    // #endif
    return normalizeVersionText(fallbackVersion)
}
 
/**
 * 当前 App 运行周期内是否还需要自动检查版本。
 * 返回 true 时会顺便占用这次检查机会,确保只触发一次。
 */
export function shouldAutoCheckVersionOnLaunch() {
    if (hasCheckedVersionThisLaunch) {
        return false
    }
    hasCheckedVersionThisLaunch = true
    return true
}
 
/**
 * 统一处理版本更新动作。
 * - Android:下载 APK 后调用系统安装
 * - iOS:优先跳转 App Store
 */
export function startVersionUpdate(downloadUrl, onProgress) {
    const safeDownloadUrl = typeof downloadUrl === 'string' ? downloadUrl.trim() : ''
    if (!safeDownloadUrl) {
        uni.showToast({
            title: '下载地址缺失',
            icon: 'none',
        })
        return Promise.reject(new Error('downloadUrl is empty'))
    }
 
    // #ifdef APP-PLUS
    const platform = String(uni.getSystemInfoSync().platform || '').toLowerCase()
    if (platform !== 'android') {
        notifyVersionUpdateProgress(onProgress, {
            status: 'redirecting',
            percent: 0,
            text: '前往 App Store',
        })
        plus.runtime.openURL(resolveIosUpdateUrl(safeDownloadUrl))
        return Promise.resolve()
    }
    return downloadAndInstallAndroidApk(safeDownloadUrl, onProgress)
    // #endif
 
    // #ifndef APP-PLUS
    uni.showToast({
        title: '请在APP内执行更新',
        icon: 'none',
    })
    return Promise.resolve()
    // #endif
}
 
/**
 * iOS 更新地址统一转成 App Store 可识别的链接。
 * 优先使用 itms-apps:// 协议直接拉起 App Store。
 */
function resolveIosUpdateUrl(downloadUrl) {
    const candidateUrl = IOS_APP_STORE_URL || downloadUrl
    if (typeof candidateUrl !== 'string') {
        return ''
    }
    const safeDownloadUrl = candidateUrl.trim()
    if (!safeDownloadUrl) {
        return ''
    }
    if (safeDownloadUrl.startsWith('itms-apps://')) {
        return safeDownloadUrl
    }
    if (safeDownloadUrl.includes('apps.apple.com') || safeDownloadUrl.includes('itunes.apple.com')) {
        return safeDownloadUrl.replace(/^https?:\/\//i, 'itms-apps://')
    }
    return safeDownloadUrl
}
 
/**
 * Android 端下载 APK 后拉起系统安装。
 */
function downloadAndInstallAndroidApk(downloadUrl, onProgress) {
    return new Promise((resolve, reject) => {
        try {
            const downloadTarget = `_doc/update/app-release-${Date.now()}.apk`
            notifyVersionUpdateProgress(onProgress, {
                status: 'downloading',
                percent: 0,
                text: '正在下载 0%',
            })
            const downloadTask = plus.downloader.createDownload(
                downloadUrl,
                {
                    filename: downloadTarget,
                },
                (download, status) => {
                    if (status !== 200 || !download || !download.filename) {
                        uni.showToast({
                            title: '下载更新失败',
                            icon: 'none',
                        })
                        notifyVersionUpdateProgress(onProgress, {
                            status: 'failed',
                            percent: 0,
                            text: '立即更新',
                        })
                        reject(new Error(`download failed: ${status}`))
                        return
                    }
                    notifyVersionUpdateProgress(onProgress, {
                        status: 'downloaded',
                        percent: 100,
                        text: '安装中...',
                    })
                    installDownloadedApk(download.filename, resolve, reject, onProgress)
                }
            )
            // 监听下载进度,把百分比回传给页面,直接展示到弹窗按钮文案。
            downloadTask.addEventListener('statechanged', (task) => {
                const totalSize = Number(task && task.totalSize) || 0
                const downloadedSize = Number(task && task.downloadedSize) || 0
                if (totalSize <= 0 || downloadedSize < 0) {
                    return
                }
                const percent = Math.min(99, Math.max(0, Math.floor((downloadedSize / totalSize) * 100)))
                notifyVersionUpdateProgress(onProgress, {
                    status: 'downloading',
                    percent,
                    text: `正在下载 ${percent}%`,
                })
            })
            downloadTask.start()
        } catch (e) {
            uni.showToast({
                title: '启动下载失败',
                icon: 'none',
            })
            notifyVersionUpdateProgress(onProgress, {
                status: 'failed',
                percent: 0,
                text: '立即更新',
            })
            reject(e)
        }
    })
}
 
/**
 * APK 下载完成后,交给系统安装器处理。
 */
function installDownloadedApk(filePath, resolve, reject, onProgress) {
    try {
        notifyVersionUpdateProgress(onProgress, {
            status: 'installing',
            percent: 100,
            text: '安装中...',
        })
        // 这里直接交给系统安装器处理,用户确认后即可安装新版本。
        plus.runtime.install(
            filePath,
            {},
            () => {
                notifyVersionUpdateProgress(onProgress, {
                    status: 'completed',
                    percent: 100,
                    text: '立即更新',
                })
                resolve(filePath)
            },
            (error) => {
                console.error('[app-version] 安装 APK 失败:', error)
                uni.showToast({
                    title: '拉起安装失败',
                    icon: 'none',
                })
                notifyVersionUpdateProgress(onProgress, {
                    status: 'failed',
                    percent: 100,
                    text: '立即更新',
                })
                reject(error)
            }
        )
    } catch (e) {
        uni.showToast({
            title: '拉起安装失败',
            icon: 'none',
        })
        notifyVersionUpdateProgress(onProgress, {
            status: 'failed',
            percent: 100,
            text: '立即更新',
        })
        reject(e)
    }
}
 
/**
 * 通知页面当前更新进度,页面可把文案直接展示到弹窗按钮。
 */
function notifyVersionUpdateProgress(onProgress, progressInfo) {
    if (typeof onProgress !== 'function') {
        return
    }
    try {
        onProgress(progressInfo)
    } catch (e) {
        console.warn('[app-version] 更新进度回调失败:', e)
    }
}
 
/**
 * 逐段比较版本号,返回值:
 * -1:current < latest
 *  0:current = latest
 *  1:current > latest
 */
function compareVersionText(currentVersion, latestVersion) {
    const currentParts = splitVersionText(currentVersion)
    const latestParts = splitVersionText(latestVersion)
    const maxLength = Math.max(currentParts.length, latestParts.length)
    for (let index = 0; index < maxLength; index += 1) {
        const currentPart = currentParts[index] || 0
        const latestPart = latestParts[index] || 0
        if (currentPart < latestPart) {
            return -1
        }
        if (currentPart > latestPart) {
            return 1
        }
    }
    return 0
}
 
/**
 * 把版本号拆成数字数组,便于逐段比较。
 */
function splitVersionText(versionText) {
    return String(versionText || '')
        .split('.')
        .map((item) => Number.parseInt(item, 10))
        .filter((item) => Number.isFinite(item))
}