zjk
2026-08-31 1462d4c13e2d8af5ac1ba857fcbbf886d10debed
feat(traditionalApp): 新增应用版本更新功能,修复邀请码选填处理

新增完整的应用版本检查与更新流程,包括更新弹窗组件、版本检查工具函数及首页集成逻辑
优化注册页面邀请码字段:改为选填,仅用户填写时才向后端传递参数,避免空字符串触发后端必填校验
添加本地开发环境BASE_URL注释,方便本地调试
统一多个文件的代码缩进格式,提升代码可读性
1个文件已添加
7个文件已修改
769 ■■■■■ 已修改文件
traditionalApp/App.vue 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/components/updatePopup.vue 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/config/index.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/service.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/register.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/app-version.js 274 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/App.vue
traditionalApp/components/updatePopup.vue
@@ -1,14 +1,19 @@
<template>
    <view v-if="show" class="update-popup-mask" @tap="handleClose">
        <view class="update-popup-content" @tap.stop>
            <image src="/static/image/home/popBg.png" class="update-header-img" mode="widthFix"></image>
            <view class="text-content">
                <view class="update-title">更新提示</view>
                <scroll-view scroll-y class="desc-scroll">
                    <text class="update-desc">更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容更新内容</text>
    <view v-if="show" class="update-popup-mask" @tap="handleMaskTap">
        <view class="update-popup-panel" @tap.stop>
            <view v-if="!forceUpdate && !submitting" class="update-popup-close" @tap="handleClose">
                <view class="update-popup-close-icon"></view>
            </view>
            <view class="update-popup-header">{{ title }}</view>
            <view class="update-popup-body">
                <scroll-view scroll-y class="update-desc-scroll">
                    <text class="update-desc">{{ content }}</text>
                </scroll-view>
                <view class="update-btn" @tap="handleUpdate">立即更新</view>
                <view class="cancel-btn" @tap="handleClose">暂不更新</view>
                <view v-if="!forceUpdate" class="update-action-row">
                    <view class="action-btn action-btn-cancel" :class="{ 'action-btn-disabled': submitting }" @tap="handleClose">暂不更新</view>
                    <view class="action-btn action-btn-confirm" :class="{ 'action-btn-disabled': submitting }" @tap="handleUpdate">{{ confirmText }}</view>
                </view>
                <view v-else class="action-btn action-btn-primary" :class="{ 'action-btn-disabled': submitting }" @tap="handleUpdate">{{ confirmText }}</view>
            </view>
        </view>
    </view>
@@ -16,26 +21,48 @@
<script setup lang="ts">
interface Props {
    show: boolean;
    show: boolean
    title?: string
    content?: string
    forceUpdate?: boolean
    confirmText?: string
    submitting?: boolean
}
withDefaults(defineProps<Props>(), {
    show: false
});
const props = withDefaults(defineProps<Props>(), {
    show: false,
    title: '更新提示',
    content: '',
    forceUpdate: false,
    confirmText: '立即更新',
    submitting: false,
})
const emit = defineEmits<{
    (e: 'close'): void;
    (e: 'submit'): void;
}>();
    (e: 'close'): void
    (e: 'submit'): void
}>()
const handleClose = () => {
    emit('close');
};
function handleMaskTap() {
    if (props.forceUpdate || props.submitting) {
        return
    }
    emit('close')
}
const handleUpdate = () => {
    emit('submit');
    emit('close');
};
function handleClose() {
    if (props.forceUpdate || props.submitting) {
        return
    }
    emit('close')
}
function handleUpdate() {
    if (props.submitting) {
        return
    }
    emit('submit')
}
</script>
<style scoped lang="scss">
@@ -48,81 +75,133 @@
    justify-content: center;
    padding: 40rpx;
    box-sizing: border-box;
    background: rgba(0, 0, 0, 0.68);
    background: rgba(0, 0, 0, 0.5);
}
.update-popup-content {
    width: 580rpx;
.update-popup-panel {
    width: 560rpx;
    background: #ffffff;
    border-radius: 4rpx;
    overflow: hidden;
    position: relative;
    animation: modalShow 0.25s ease-out;
}
.update-popup-close {
    position: absolute;
    top: 20rpx;
    right: 20rpx;
    width: 48rpx;
    height: 48rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    animation: modalShow 0.3s ease-out;
}
.update-header-img {
    width: 100%;
    height: auto;
    z-index: 1;
    display: block;
}
.text-content {
    width: 100%;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 410rpx 40rpx 40rpx;
    position: absolute;
    inset: 0;
    border-radius: 24rpx;
    background: rgba(134, 144, 156, 0.08);
    z-index: 2;
}
.update-title {
    font-size: 44rpx;
    font-weight: 700;
    color: #333333;
    margin-bottom: 30rpx;
.update-popup-close-icon {
    position: relative;
    width: 20rpx;
    height: 20rpx;
}
.desc-scroll {
    width: 100%;
    height: 200rpx;
    margin-bottom: 40rpx;
.update-popup-close-icon::before,
.update-popup-close-icon::after {
    content: '';
    position: absolute;
    top: 9rpx;
    left: 0;
    width: 20rpx;
    height: 2rpx;
    border-radius: 2rpx;
    background: #86909c;
}
.update-desc {
    font-size: 28rpx;
    color: #666666;
    line-height: 1.6;
    text-align: left;
    display: block;
.update-popup-close-icon::before {
    transform: rotate(45deg);
}
.update-btn {
    width: 100%;
    height: 96rpx;
    background: #c63520;
    color: #ffffff;
    font-size: 34rpx;
    font-weight: 500;
.update-popup-close-icon::after {
    transform: rotate(-45deg);
}
.update-popup-header {
    height: 74rpx;
    border-bottom: 1px solid #e5e6eb;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 16rpx;
    margin-bottom: 24rpx;
    font-size: 26rpx;
    color: #4e5969;
}
.cancel-btn {
.update-popup-body {
    padding: 34rpx 26rpx 30rpx;
}
.update-desc-scroll {
    max-height: 240rpx;
    min-height: 180rpx;
}
.update-desc {
    display: block;
    font-size: 28rpx;
    color: #999999;
    padding: 20rpx;
    color: #1d2129;
    line-height: 1.7;
    word-break: break-all;
    white-space: pre-wrap;
}
.update-action-row {
    display: flex;
    justify-content: center;
    gap: 30rpx;
    margin-top: 34rpx;
}
.action-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 8rpx;
    font-size: 26rpx;
    font-weight: 500;
}
.action-btn-disabled {
    opacity: 0.7;
}
.action-btn-cancel,
.action-btn-confirm {
    width: 148rpx;
    height: 56rpx;
}
.action-btn-cancel {
    color: #2f6bff;
    border: 1px solid #2f6bff;
    background: #ffffff;
}
.action-btn-confirm,
.action-btn-primary {
    color: #ffffff;
    background: linear-gradient(180deg, #4c8dff 0%, #2f6bff 100%);
}
.action-btn-primary {
    width: 100%;
    height: 64rpx;
    margin-top: 26rpx;
}
@keyframes modalShow {
    from {
        transform: scale(0.92);
        transform: scale(0.96);
        opacity: 0;
    }
traditionalApp/config/index.js
@@ -1,5 +1,6 @@
export default {
//     BASE_URL: 'http://221.182.45.100:48080',
    // BASE_URL: 'http://192.168.110.22:48080',
    BASE_URL: 'https://haizhentong.com',
    OPENINSTALL_URL: 'https://m.malink.cn/s/7bIzeu',
    uploadUrl: "https://haizhentong.com/api/open/file/upload",
traditionalApp/pages/home/home.vue
@@ -135,13 +135,25 @@
                </view>
            </view>
        </view>
        <update-popup
            :show="versionPopupVisible"
            :title="versionPopupTitle"
            :content="versionPopupContent"
            :force-update="versionPopupForceUpdate"
            :confirm-text="versionUpdateButtonText"
            :submitting="versionUpdating"
            @close="closeVersionPopup"
            @submit="handleUpdateNow">
        </update-popup>
    </view>
</template>
<script setup lang="ts">
    import { computed, ref, onMounted } from "vue";
    import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
    import UpdatePopup from '@/components/updatePopup.vue';
    import { createLiveRoomEntry, getBannerList, getHomeFeaturedContent, getHomeLiveCourseList, getLiveRoomSetting } from "./service";
    import { checkAppVersion, normalizeVersionCheckData, shouldAutoCheckVersionOnLaunch, startVersionUpdate } from '@/utils/app-version.js';
    import { HardwareAPI } from '@/utils/visitorDevice.js';
    import { resolveCurrentAppUserId } from "@/utils/app-user.js";
    import { jumpToMiniProgramPay } from '@/utils/weizan-pay-bridge.js';
@@ -207,6 +219,13 @@
    const weiZanLiveH5Url = ref('');
    const weiZanLiveBanner = ref('');
    const weiZanLiveOpening = ref(false);
        const versionPopupVisible = ref(false);
        const versionPopupTitle = ref('更新提示');
        const versionPopupContent = ref('');
        const versionPopupForceUpdate = ref(false);
        const versionDownloadUrl = ref('');
        const versionUpdating = ref(false);
        const versionUpdateButtonText = ref('立即更新');
    const operatorTrainingLiveCourse = computed(() => {
        const list = liveCourseList.value;
@@ -243,6 +262,7 @@
     */
    onShow(() => {
        refreshHomeData();
                triggerAutoVersionCheck();
    });
    /**
@@ -271,6 +291,82 @@
            weiZanLiveH5Url.value = '';
        }
    };
        /**
         * 应用本次启动后仅在首页自动检查一次版本,避免重复弹窗。
         */
        const triggerAutoVersionCheck = () => {
                if (!shouldAutoCheckVersionOnLaunch()) {
                        return;
                }
                checkLatestVersionOnLaunch();
        };
        /**
         * 首页首次展示时检查版本,有新版本时弹出更新提示。
         */
        const checkLatestVersionOnLaunch = async () => {
                try {
                        const response = await checkAppVersion(false);
                        const versionInfo = normalizeVersionCheckData(response?.data);
                        if (!versionInfo.hasUpdate) {
                                return;
                        }
                        openVersionPopup(
                                '更新提示',
                                versionInfo.updateNotice || '发现新版本,请及时更新',
                                versionInfo.forceUpdate,
                                versionInfo.downloadUrl
                        );
                } catch (e) {
                        console.warn('[home] 自动检查更新失败:', e);
                }
        };
        /**
         * 打开版本更新弹窗。
         */
        const openVersionPopup = (title : string, content : string, forceUpdate : boolean, downloadUrl : string) => {
                versionPopupTitle.value = title || '更新提示';
                versionPopupContent.value = content || '发现新版本,请及时更新';
                versionPopupForceUpdate.value = forceUpdate;
                versionDownloadUrl.value = downloadUrl || '';
                versionUpdating.value = false;
                versionUpdateButtonText.value = '立即更新';
                versionPopupVisible.value = true;
        };
        /**
         * 关闭版本更新弹窗。
         */
        const closeVersionPopup = () => {
                if (versionPopupForceUpdate.value) {
                        return;
                }
                versionPopupVisible.value = false;
        };
        /**
         * 点击立即更新后,Android 下载 APK 并拉起安装,iOS 继续跳转更新地址。
         */
        const handleUpdateNow = async () => {
                if (versionUpdating.value) {
                        return;
                }
                versionUpdating.value = true;
                try {
                        await startVersionUpdate(versionDownloadUrl.value, (progressInfo : { text ?: string }) => {
                                versionUpdateButtonText.value = progressInfo?.text || '立即更新';
                        });
                        versionPopupVisible.value = false;
                } catch (e) {
                        console.warn('[home] 执行更新失败:', e);
                } finally {
                        versionUpdating.value = false;
                        versionUpdateButtonText.value = '立即更新';
                }
        };
    const loadBannerList = async () => {
        try {
            const res = await getBannerList();
traditionalApp/pages/mine/mine.vue
@@ -11,7 +11,8 @@
                    <image class="action-icon" src="/static/image/mine/notice.png" mode="aspectFit"></image>
                    <view v-if="unreadNoticeTotal > 0" class="notice-badge">{{ noticeBadgeText }}</view>
                </view>
                <image class="action-icon" src="/static/image/mine/setting.png" mode="aspectFit" @tap="goPage('/pages/settings/settings')"></image>
                <image class="action-icon" src="/static/image/mine/setting.png" mode="aspectFit"
                    @tap="goPage('/pages/settings/settings')"></image>
            </view>
            <view class="user-info">
@@ -27,35 +28,51 @@
                </view>
            </view>
            <view class="grid-section card-box">
                <view class="grid-item" v-for="(item, index) in gridMenuList" :key="`${item.title}-${index}`" @tap="handleGridClick(item)">
                <view class="grid-item" v-for="(item, index) in gridMenuList" :key="`${item.title}-${index}`"
                    @tap="handleGridClick(item)">
                    <image class="grid-icon" :src="item.icon" mode="aspectFit"></image>
                    <text class="grid-title">{{ item.title }}</text>
                </view>
            </view>
            <view class="list-section card-box">
                <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`" @tap="handleBottomMenuClick(item)">
                <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`"
                    @tap="handleBottomMenuClick(item)">
                    <view class="list-left">
                        <image class="list-icon" :class="item.iconClass" :src="item.icon" mode="aspectFit"></image>
                        <text class="list-title">{{ item.title }}</text>
                    </view>
                    <view class="list-right">
                        <text v-if="item.versionText" class="list-version-text">{{ item.versionText }}</text>
                    <u-icon name="arrow-right" color="#999" size="16"></u-icon>
                </view>
            </view>
            <view class="app-version-text">海诊康道 版本{{ appVersionText }}</view>
            </view>
            <!-- <view class="app-version-text">海诊康道 版本{{ appVersionText }}</view> -->
            <view class="icp-text">ICP备案号:琼ICP备2025059220号-3A</view>
        </view>
        <update-popup
            :show="versionPopupVisible"
            :title="versionPopupTitle"
            :content="versionPopupContent"
            :force-update="versionPopupForceUpdate"
            :confirm-text="versionUpdateButtonText"
            :submitting="versionUpdating"
            @close="closeVersionPopup"
            @submit="handleUpdateNow"
        />
    </view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import UpdatePopup from '@/components/updatePopup.vue'
import { getNoticeList } from '../notice/service'
import { getUserCenter } from './service'
import { hasStoredSession } from '@/utils/auth-session.js'
import { checkAppVersion, normalizeVersionCheckData, normalizeVersionText, resolveRuntimeVersion, startVersionUpdate } from '@/utils/app-version.js'
import manifestConfig from '../../manifest.json'
type UserRole = 'personal' | 'doctor'
@@ -94,6 +111,7 @@
    url: string
    iconClass?: string
    requiresLogin?: boolean
    versionText?: string
}
interface NoticeSummaryItem {
@@ -102,8 +120,16 @@
const statusBarHeight = ref(0)
const unreadNoticeTotal = ref(0)
const isCheckingVersion = ref(false)
const manifestVersionName = String(manifestConfig.versionName || '')
const appVersionText = manifestVersionName
const appVersionText = ref(manifestVersionName)
const versionPopupVisible = ref(false)
const versionPopupTitle = ref('更新提示')
const versionPopupContent = ref('')
const versionPopupForceUpdate = ref(false)
const versionDownloadUrl = ref('')
const versionUpdating = ref(false)
const versionUpdateButtonText = ref('立即更新')
const userCenter = ref<UserCenterData>(createDefaultUserCenter())
const userRole = computed<UserRole>(() => {
    return isDoctorLikeUser(userCenter.value) ? 'doctor' : 'personal'
@@ -167,6 +193,7 @@
    const agreementList = [
        { title: '用户协议', icon: '/static/image/mine/coustom.png', url: '/pages/protocol/protocol?type=user', iconClass: 'icon-user-agree' },
        { title: '隐私协议', icon: '/static/image/mine/agree.png', url: '/pages/protocol/protocol?type=privacy', iconClass: 'icon-privacy' },
        { title: '更新版本', icon: '/static/image/mine/setting.png', url: '', iconClass: 'icon-version', versionText: `V${appVersionText.value}` },
    ]
    if (userRole.value === 'doctor') {
        return [
@@ -215,6 +242,7 @@
onLoad(() => {
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 20
    syncAppVersion()
    syncCachedUserInfo()
})
@@ -385,6 +413,10 @@
 * 处理底部普通菜单跳转。
 */
async function handleBottomMenuClick(item: MenuItem) {
    if (item.title === '更新版本') {
                await handleCheckAppVersion()
        return
    }
    goPage(item)
}
@@ -429,6 +461,100 @@
    }
    return value.trim()
}
/**
 * 检查 APP 最新版本,按真实返回结构展示结果,暂不执行下载或安装。
 */
async function handleCheckAppVersion() {
    if (isCheckingVersion.value) {
        return
    }
    isCheckingVersion.value = true
    try {
        const response = await checkAppVersion()
        const versionInfo = normalizeVersionCheckData(response?.data)
        if (!versionInfo.latestVersionNo) {
            uni.showToast({
                title: '暂未获取到最新版本',
                icon: 'none',
            })
            return
        }
        const currentVersion = normalizeVersionText(appVersionText.value)
        const latestVersion = normalizeVersionText(versionInfo.latestVersionNo)
        if (!versionInfo.hasUpdate) {
            uni.showToast({
                title: `当前已是最新版本 V${latestVersion}`,
                icon: 'none',
            })
            return
        }
        const popupContent = versionInfo.updateNotice || `发现新版本 V${latestVersion},请及时更新`
        openVersionPopup('更新提示', popupContent, versionInfo.forceUpdate, versionInfo.downloadUrl)
    } catch (e) {
        uni.showToast({
            title: '检查更新失败',
            icon: 'none',
        })
    } finally {
        isCheckingVersion.value = false
    }
}
/**
 * 优先读取运行时版本号,取不到时回退 manifest 配置版本。
 */
function syncAppVersion() {
    appVersionText.value = resolveRuntimeVersion(manifestVersionName || '1.0.0')
}
/**
 * 打开版本更新弹窗。
 */
function openVersionPopup(title: string, content: string, forceUpdate: boolean, downloadUrl: string) {
    versionPopupTitle.value = title || '更新提示'
    versionPopupContent.value = content
    versionPopupForceUpdate.value = forceUpdate
    versionDownloadUrl.value = downloadUrl
    versionUpdating.value = false
    versionUpdateButtonText.value = '立即更新'
    versionPopupVisible.value = true
}
/**
 * 关闭版本更新弹窗。
 */
function closeVersionPopup() {
    if (versionPopupForceUpdate.value) {
        return
    }
    versionPopupVisible.value = false
}
/**
 * 点击立即更新后,Android 下载 APK 并拉起安装,iOS 继续跳转更新地址。
 */
async function handleUpdateNow() {
    if (versionUpdating.value) {
        return
    }
    versionUpdating.value = true
    try {
        await startVersionUpdate(versionDownloadUrl.value, (progressInfo: { text?: string }) => {
            versionUpdateButtonText.value = progressInfo?.text || '立即更新'
        })
        versionPopupVisible.value = false
    } catch (e) {
        console.warn('[mine] 执行更新失败:', e)
    } finally {
        versionUpdating.value = false
        versionUpdateButtonText.value = '立即更新'
    }
}
</script>
<style lang="scss" scoped>
@@ -665,6 +791,11 @@
            border-bottom: none;
        }
        .list-right {
            display: flex;
            align-items: center;
        }
        .list-left {
            display: flex;
            align-items: center;
@@ -697,6 +828,11 @@
                    width: 27rpx;
                    height: 33rpx;
                }
                &.icon-version {
                    width: 30rpx;
                    height: 30rpx;
                }
            }
            .list-title {
@@ -704,7 +840,12 @@
                color: #1A1A1A;
                margin-left: 22rpx;
            }
        }
        .list-version-text {
            margin-right: 16rpx;
            font-size: 24rpx;
            color: #86909C;
        }
    }
}
traditionalApp/pages/mine/service.js
@@ -13,3 +13,10 @@
export const getMemberOpenPage = () => {
    return request.get('/api/app/member/open-page', {}, false, false)
}
/**
 * 检查 APP 最新版本信息。
 */
export const checkAppVersion = (platform) => {
        return request.get('/api/app/version/check', { platform }, false, true)
}
traditionalApp/pages/register/register.vue
@@ -54,7 +54,7 @@
                            class="input"
                            type="text"
                            maxlength="6"
                            placeholder="请输入6位邀请码"
                            placeholder="请输入6位邀请码(选填)"
                            placeholder-class="placeholder-style"
                            v-model="form.inviteCode"
                        />
@@ -485,12 +485,17 @@
 * 提交普通用户注册请求。
 */
const submitUserRegister = async () => {
    const res = await registerUser({
        const payload: Record<string, string> = {
        phone: form.phone.trim(),
        code: form.code.trim(),
        inviteCode: form.inviteCode.trim().toUpperCase(),
        bindTicket: bindTicket.value
    })
        }
        const inviteCode = form.inviteCode.trim().toUpperCase()
        // 邀请码改为选填:仅在用户填写时才透传给后端,避免空字符串被后端当成必填校验。
        if (inviteCode) {
                payload.inviteCode = inviteCode
        }
        const res = await registerUser(payload)
    if (!isSuccessResponse(res)) {
        return
    }
traditionalApp/utils/app-version.js
New file
@@ -0,0 +1,274 @@
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, ''),
    }
}
/**
 * 优先读取运行时版本号,取不到时回退 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)
    }
}