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

新增完整的应用版本检查与更新流程,包括更新弹窗组件、版本检查工具函数及首页集成逻辑
优化注册页面邀请码字段:改为选填,仅用户填写时才向后端传递参数,避免空字符串触发后端必填校验
添加本地开发环境BASE_URL注释,方便本地调试
统一多个文件的代码缩进格式,提升代码可读性
1个文件已添加
7个文件已修改
1831 ■■■■■ 已修改文件
traditionalApp/App.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/components/updatePopup.vue 271 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/config/index.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 1129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/service.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/register.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/app-version.js 274 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/App.vue
@@ -1,21 +1,21 @@
<script setup>
    import {
        onLaunch,
        onShow
    } from '@dcloudio/uni-app'
    import {
        extractBindTicket,
        extractInviteCode
    } from '@/utils/utils.js'
    import { hasCompletedAuthSession } from '@/utils/auth-session.js'
    import { cacheInviteCode } from '@/utils/invite-cache.js'
    import { initJPushLifecycle } from '@/utils/push.js'
    import {
        getOpeninstallInstallData,
        initOpeninstall,
        registerOpeninstallWakeUp
    } from '@/utils/openinstall.js'
    let lastHandledInvitePayload = ''
        import {
                onLaunch,
                onShow
        } from '@dcloudio/uni-app'
        import {
                extractBindTicket,
                extractInviteCode
        } from '@/utils/utils.js'
        import { hasCompletedAuthSession } from '@/utils/auth-session.js'
        import { cacheInviteCode } from '@/utils/invite-cache.js'
        import { initJPushLifecycle } from '@/utils/push.js'
        import {
                getOpeninstallInstallData,
                initOpeninstall,
                registerOpeninstallWakeUp
        } from '@/utils/openinstall.js'
        let lastHandledInvitePayload = ''
    /**
     * 应用启动时初始化极光与 openinstall,承接推广关系参数。
traditionalApp/components/updatePopup.vue
@@ -1,134 +1,213 @@
<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>
                </scroll-view>
                <view class="update-btn" @tap="handleUpdate">立即更新</view>
                <view class="cancel-btn" @tap="handleClose">暂不更新</view>
            </view>
        </view>
    </view>
    <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 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>
</template>
<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">
.update-popup-mask {
    position: fixed;
    inset: 0;
    z-index: 99999;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 40rpx;
    box-sizing: border-box;
    background: rgba(0, 0, 0, 0.68);
    position: fixed;
    inset: 0;
    z-index: 99999;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 40rpx;
    box-sizing: border-box;
    background: rgba(0, 0, 0, 0.5);
}
.update-popup-content {
    width: 580rpx;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    animation: modalShow 0.3s ease-out;
.update-popup-panel {
    width: 560rpx;
    background: #ffffff;
    border-radius: 4rpx;
    overflow: hidden;
    position: relative;
    animation: modalShow 0.25s ease-out;
}
.update-header-img {
    width: 100%;
    height: auto;
    z-index: 1;
    display: block;
.update-popup-close {
    position: absolute;
    top: 20rpx;
    right: 20rpx;
    width: 48rpx;
    height: 48rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 24rpx;
    background: rgba(134, 144, 156, 0.08);
    z-index: 2;
}
.text-content {
    width: 100%;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 410rpx 40rpx 40rpx;
    position: absolute;
    inset: 0;
    z-index: 2;
.update-popup-close-icon {
    position: relative;
    width: 20rpx;
    height: 20rpx;
}
.update-title {
    font-size: 44rpx;
    font-weight: 700;
    color: #333333;
    margin-bottom: 30rpx;
.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;
}
.desc-scroll {
    width: 100%;
    height: 200rpx;
    margin-bottom: 40rpx;
.update-popup-close-icon::before {
    transform: rotate(45deg);
}
.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;
    font-size: 26rpx;
    color: #4e5969;
}
.update-popup-body {
    padding: 34rpx 26rpx 30rpx;
}
.update-desc-scroll {
    max-height: 240rpx;
    min-height: 180rpx;
}
.update-desc {
    font-size: 28rpx;
    color: #666666;
    line-height: 1.6;
    text-align: left;
    display: block;
    display: block;
    font-size: 28rpx;
    color: #1d2129;
    line-height: 1.7;
    word-break: break-all;
    white-space: pre-wrap;
}
.update-btn {
    width: 100%;
    height: 96rpx;
    background: #c63520;
    color: #ffffff;
    font-size: 34rpx;
    font-weight: 500;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 16rpx;
    margin-bottom: 24rpx;
.update-action-row {
    display: flex;
    justify-content: center;
    gap: 30rpx;
    margin-top: 34rpx;
}
.cancel-btn {
    font-size: 28rpx;
    color: #999999;
    padding: 20rpx;
.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);
        opacity: 0;
    }
    from {
        transform: scale(0.96);
        opacity: 0;
    }
    to {
        transform: scale(1);
        opacity: 1;
    }
    to {
        transform: scale(1);
        opacity: 1;
    }
}
</style>
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
@@ -1,727 +1,868 @@
<template>
    <view class="container">
        <view class="top-bg">
            <image src="/static/image/home/bg.png" mode="widthFix" class="bg-img"></image>
        </view>
        <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
    <view class="container">
        <view class="top-bg">
            <image src="/static/image/home/bg.png" mode="widthFix" class="bg-img"></image>
        </view>
        <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
        <view class="content">
            <view class="top-actions">
                <view class="notice-action" @tap="goPage('/pages/notice/notice')">
                    <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>
            </view>
        <view class="content">
            <view class="top-actions">
                <view class="notice-action" @tap="goPage('/pages/notice/notice')">
                    <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>
            </view>
            <view class="user-info">
                <view class="avatar-wrap">
                    <image class="avatar" :src="displayAvatar" mode="aspectFill"></image>
                    <view class="role-tag" :class="userRole === 'doctor' ? 'doctor-tag' : 'personal-tag'">
                        {{ roleTagText }}
                    </view>
                </view>
                <view class="user-detail">
                    <view class="user-name">{{ displayName }}</view>
                    <view v-if="userRole === 'personal'" class="user-points">积分:{{ displayPoints }}</view>
                </view>
            </view>
            <view class="user-info">
                <view class="avatar-wrap">
                    <image class="avatar" :src="displayAvatar" mode="aspectFill"></image>
                    <view class="role-tag" :class="userRole === 'doctor' ? 'doctor-tag' : 'personal-tag'">
                        {{ roleTagText }}
                    </view>
                </view>
                <view class="user-detail">
                    <view class="user-name">{{ displayName }}</view>
                    <view v-if="userRole === 'personal'" class="user-points">积分:{{ displayPoints }}</view>
                </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)">
                    <image class="grid-icon" :src="item.icon" mode="aspectFit"></image>
                    <text class="grid-title">{{ item.title }}</text>
                </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)">
                    <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-left">
                        <image class="list-icon" :class="item.iconClass" :src="item.icon" mode="aspectFit"></image>
                        <text class="list-title">{{ item.title }}</text>
                    </view>
                    <u-icon name="arrow-right" color="#999" size="16"></u-icon>
                </view>
            </view>
            <view class="app-version-text">海诊康道 版本{{ appVersionText }}</view>
            <view class="icp-text">ICP备案号:琼ICP备2025059220号-3A</view>
        </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-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>
            <!-- <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'
interface UserCenterData {
    id?: number | null
    nickname?: string
    avatar?: string
    phone?: string
    type?: number | null
    typeText?: string
    roleCode?: string
    roleCodes?: string[]
    memberExpireAt?: string
    pointsBalance?: number | null
    clinicId?: number | null
    clinicName?: string
    id?: number | null
    nickname?: string
    avatar?: string
    phone?: string
    type?: number | null
    typeText?: string
    roleCode?: string
    roleCodes?: string[]
    memberExpireAt?: string
    pointsBalance?: number | null
    clinicId?: number | null
    clinicName?: string
}
interface AppUserCache {
    id?: number | null
    nickname?: string
    avatar?: string
    phone?: string
    type?: number | null
    typeText?: string
    roleCode?: string
    roleCodes?: string[]
    memberExpireAt?: string
    clinicId?: number | null
    id?: number | null
    nickname?: string
    avatar?: string
    phone?: string
    type?: number | null
    typeText?: string
    roleCode?: string
    roleCodes?: string[]
    memberExpireAt?: string
    clinicId?: number | null
}
interface MenuItem {
    title: string
    icon: string
    url: string
    iconClass?: string
    requiresLogin?: boolean
    title: string
    icon: string
    url: string
    iconClass?: string
    requiresLogin?: boolean
    versionText?: string
}
interface NoticeSummaryItem {
    badge?: number | string | null
    badge?: number | string | null
}
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'
    return isDoctorLikeUser(userCenter.value) ? 'doctor' : 'personal'
})
const roleTagText = computed(() => {
    const typeText = normalizeOptionalText(userCenter.value.typeText)
    if (typeText.includes('店长')) {
        return '店长'
    }
    return userRole.value === 'doctor' ? '医生' : '个人'
    const typeText = normalizeOptionalText(userCenter.value.typeText)
    if (typeText.includes('店长')) {
        return '店长'
    }
    return userRole.value === 'doctor' ? '医生' : '个人'
})
const displayName = computed(() => {
    return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || (hasStoredSession() ? '已登录用户' : '游客')
    return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || (hasStoredSession() ? '已登录用户' : '游客')
})
const displayAvatar = computed(() => {
    return normalizeOptionalText(userCenter.value.avatar) || '/static/image/mine/header.png'
    return normalizeOptionalText(userCenter.value.avatar) || '/static/image/mine/header.png'
})
const displayPoints = computed(() => {
    const points = Number(userCenter.value.pointsBalance || 0)
    return Number.isFinite(points) ? points : 0
    const points = Number(userCenter.value.pointsBalance || 0)
    return Number.isFinite(points) ? points : 0
})
const noticeBadgeText = computed(() => {
    return unreadNoticeTotal.value > 99 ? '99+' : String(unreadNoticeTotal.value)
    return unreadNoticeTotal.value > 99 ? '99+' : String(unreadNoticeTotal.value)
})
const gridMenuList = computed<MenuItem[]>(() => {
    if (userRole.value === 'doctor') {
        return [
            { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true },
            { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true },
            { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true },
            { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients', requiresLogin: true },
            { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo', requiresLogin: true },
            ...(Number(userCenter.value.type) === 1 ? [
                { title: '银行卡设置', icon: '/static/image/mine/yinghangka.png', url: '/pages/bankCard/bankCard', requiresLogin: true },
            ] : []),
            { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser', requiresLogin: true },
        ]
    }
    return [
        { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true },
        { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord', requiresLogin: true },
        { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true },
        { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true },
        { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point', requiresLogin: true },
    ]
    if (userRole.value === 'doctor') {
        return [
            { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true },
            { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true },
            { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true },
            { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients', requiresLogin: true },
            { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo', requiresLogin: true },
            ...(Number(userCenter.value.type) === 1 ? [
                { title: '银行卡设置', icon: '/static/image/mine/yinghangka.png', url: '/pages/bankCard/bankCard', requiresLogin: true },
            ] : []),
            { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser', requiresLogin: true },
        ]
    }
    return [
        { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true },
        { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord', requiresLogin: true },
        { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true },
        { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true },
        { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point', requiresLogin: true },
    ]
})
const WEIZAN_LIVE_STATS_URL = 'https://wzzhibo.haizhentong.com/live/m/agent/index?zbid=1247381477&v=1787212616638'
const WEIZAN_LIVE_ORDER_URL = 'https://wzzhibo.haizhentong.com/live/m/order/myorder?zbid=1247381477&tpid=0&state=0&v=1787212292181'
const bottomMenuList = computed<MenuItem[]>(() => {
    const baseList = [
        { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass', requiresLogin: true },
        { title: '帮助中心', icon: '/static/image/mine/help.png', url: '/pages/helpCenter/helpCenter', iconClass: 'icon-help' },
        { title: '意见反馈', icon: '/static/image/mine/message.png', url: '/pages/feedback/feedback', iconClass: 'icon-feedback' },
    ]
    const liveOrderItem = { title: '直播订单', icon: '/static/image/mine/dingdan.png', url: buildWebviewUrl(WEIZAN_LIVE_ORDER_URL, '直播订单'), iconClass: 'icon-live-order', requiresLogin: true }
    const liveStatsList = Number(userCenter.value.type) === 1
        ? [{ title: '直播统计', icon: '/static/image/mine/list.png', url: buildWebviewUrl(WEIZAN_LIVE_STATS_URL, '直播统计'), iconClass: 'icon-live-stats', requiresLogin: true }]
        : []
    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' },
    ]
    if (userRole.value === 'doctor') {
        return [
            ...liveStatsList,
            liveOrderItem,
            ...baseList,
            { title: '平台资质', icon: '/static/image/mine/chimaping.png', url: '/pages/qualification/qualification', iconClass: 'icon-platform' },
            ...agreementList,
        ]
    }
    return [
        liveOrderItem,
        ...baseList,
        ...agreementList,
    ]
    const baseList = [
        { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass', requiresLogin: true },
        { title: '帮助中心', icon: '/static/image/mine/help.png', url: '/pages/helpCenter/helpCenter', iconClass: 'icon-help' },
        { title: '意见反馈', icon: '/static/image/mine/message.png', url: '/pages/feedback/feedback', iconClass: 'icon-feedback' },
    ]
    const liveOrderItem = { title: '直播订单', icon: '/static/image/mine/dingdan.png', url: buildWebviewUrl(WEIZAN_LIVE_ORDER_URL, '直播订单'), iconClass: 'icon-live-order', requiresLogin: true }
    const liveStatsList = Number(userCenter.value.type) === 1
        ? [{ title: '直播统计', icon: '/static/image/mine/list.png', url: buildWebviewUrl(WEIZAN_LIVE_STATS_URL, '直播统计'), iconClass: 'icon-live-stats', requiresLogin: true }]
        : []
    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 [
            ...liveStatsList,
            liveOrderItem,
            ...baseList,
            { title: '平台资质', icon: '/static/image/mine/chimaping.png', url: '/pages/qualification/qualification', iconClass: 'icon-platform' },
            ...agreementList,
        ]
    }
    return [
        liveOrderItem,
        ...baseList,
        ...agreementList,
    ]
})
/**
 * 生成微赞 H5 内嵌页地址。
 */
function buildWebviewUrl(url: string, title: string) {
    return `/pages/webview/webview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`
    return `/pages/webview/webview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`
}
/**
 * 创建个人中心默认数据,便于首屏兜底。
 */
function createDefaultUserCenter(): UserCenterData {
    return {
        id: null,
        nickname: '',
        avatar: '',
        phone: '',
        type: 0,
        typeText: '个人',
        memberExpireAt: '',
        pointsBalance: 0,
        clinicId: null,
        clinicName: '',
    }
    return {
        id: null,
        nickname: '',
        avatar: '',
        phone: '',
        type: 0,
        typeText: '个人',
        memberExpireAt: '',
        pointsBalance: 0,
        clinicId: null,
        clinicName: '',
    }
}
/**
 * 页面加载时初始化状态栏高度并回显本地缓存。
 */
onLoad(() => {
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 20
    syncCachedUserInfo()
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 20
    syncAppVersion()
    syncCachedUserInfo()
})
/**
 * 页面回显时重新拉取个人中心聚合数据,保证头像和积分同步刷新。
 */
onShow(() => {
    syncCachedUserInfo()
    loadUserCenter()
    loadUnreadNoticeTotal()
    syncCachedUserInfo()
    loadUserCenter()
    loadUnreadNoticeTotal()
})
/**
 * 拉取当前登录用户的通知未读总数。
 */
async function loadUnreadNoticeTotal() {
    try {
        const response = await getNoticeList()
        if (response?.code !== 0 || !Array.isArray(response.data)) {
            unreadNoticeTotal.value = 0
            return
        }
        unreadNoticeTotal.value = response.data.reduce((total: number, item: NoticeSummaryItem) => {
            return total + normalizeNoticeBadge(item?.badge)
        }, 0)
    } catch {
        // 通知角标失败时静默降级,避免影响我的页主体信息展示。
        unreadNoticeTotal.value = 0
    }
    try {
        const response = await getNoticeList()
        if (response?.code !== 0 || !Array.isArray(response.data)) {
            unreadNoticeTotal.value = 0
            return
        }
        unreadNoticeTotal.value = response.data.reduce((total: number, item: NoticeSummaryItem) => {
            return total + normalizeNoticeBadge(item?.badge)
        }, 0)
    } catch {
        // 通知角标失败时静默降级,避免影响我的页主体信息展示。
        unreadNoticeTotal.value = 0
    }
}
/**
 * 将通知分类未读数量归一化为非负整数。
 */
function normalizeNoticeBadge(value: unknown) {
    const parsedValue = Number(value)
    if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
        return 0
    }
    return Math.floor(parsedValue)
    const parsedValue = Number(value)
    if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
        return 0
    }
    return Math.floor(parsedValue)
}
/**
 * 拉取当前登录用户的个人中心聚合数据。
 */
async function loadUserCenter() {
    try {
        const response = await getUserCenter()
        if (response?.code !== 0 || !response.data) {
            return
        }
        userCenter.value = normalizeUserCenter(response.data)
        cacheUserCenter(userCenter.value)
    } catch {
        // 未登录或网络异常时由统一请求层处理跳转,这里不重复 toast。
    }
    try {
        const response = await getUserCenter()
        if (response?.code !== 0 || !response.data) {
            return
        }
        userCenter.value = normalizeUserCenter(response.data)
        cacheUserCenter(userCenter.value)
    } catch {
        // 未登录或网络异常时由统一请求层处理跳转,这里不重复 toast。
    }
}
/**
 * 使用本地登录缓存兜底首屏展示,减少页面闪烁。
 */
function syncCachedUserInfo() {
    const cachedUser = uni.getStorageSync('appUserInfo')
    if (!cachedUser || typeof cachedUser !== 'object') {
        userCenter.value = createDefaultUserCenter()
        return
    }
    userCenter.value = normalizeUserCenter(cachedUser)
    const cachedUser = uni.getStorageSync('appUserInfo')
    if (!cachedUser || typeof cachedUser !== 'object') {
        userCenter.value = createDefaultUserCenter()
        return
    }
    userCenter.value = normalizeUserCenter(cachedUser)
}
/**
 * 将接口或缓存中的用户信息归一化为页面可直接消费的结构。
 */
function normalizeUserCenter(value: unknown): UserCenterData {
    const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData> & Record<string, unknown>
    const type = resolveUserType(source)
    return {
        id: Number.isFinite(Number(source.id)) ? Number(source.id) : null,
        nickname: normalizeOptionalText(source.nickname) || normalizeOptionalText(source.nickName) || normalizeOptionalText(source.name),
        avatar: normalizeOptionalText(source.avatar) || normalizeOptionalText(source.headImgUrl),
        phone: normalizeOptionalText(source.phone) || normalizeOptionalText(source.mobile),
        type,
        typeText: normalizeOptionalText(source.typeText),
        roleCode: normalizeOptionalText(source.roleCode),
        roleCodes: Array.isArray(source.roleCodes) ? source.roleCodes.map((item) => normalizeOptionalText(item)).filter(Boolean) : [],
        memberExpireAt: normalizeOptionalText(source.memberExpireAt),
        pointsBalance: Number.isFinite(Number(source.pointsBalance)) ? Number(source.pointsBalance) : 0,
        clinicId: resolveClinicId(source),
        clinicName: normalizeOptionalText(source.clinicName),
    }
    const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData> & Record<string, unknown>
    const type = resolveUserType(source)
    return {
        id: Number.isFinite(Number(source.id)) ? Number(source.id) : null,
        nickname: normalizeOptionalText(source.nickname) || normalizeOptionalText(source.nickName) || normalizeOptionalText(source.name),
        avatar: normalizeOptionalText(source.avatar) || normalizeOptionalText(source.headImgUrl),
        phone: normalizeOptionalText(source.phone) || normalizeOptionalText(source.mobile),
        type,
        typeText: normalizeOptionalText(source.typeText),
        roleCode: normalizeOptionalText(source.roleCode),
        roleCodes: Array.isArray(source.roleCodes) ? source.roleCodes.map((item) => normalizeOptionalText(item)).filter(Boolean) : [],
        memberExpireAt: normalizeOptionalText(source.memberExpireAt),
        pointsBalance: Number.isFinite(Number(source.pointsBalance)) ? Number(source.pointsBalance) : 0,
        clinicId: resolveClinicId(source),
        clinicName: normalizeOptionalText(source.clinicName),
    }
}
/**
 * 兼容登录缓存、个人中心接口不同字段;绑定诊所/店长文案都视为医生端用户。
 */
function resolveUserType(source: Partial<UserCenterData> & Record<string, unknown>) {
    const explicitType = Number(source.type ?? source.userType ?? source.user_type)
    if (Number.isFinite(explicitType)) {
        return explicitType === 1 ? 1 : 0
    }
    const rawType = normalizeOptionalText(source.type || source.userType || source.user_type).toLowerCase()
    if (['doctor', 'operator', 'clinic_manager', 'manager', 'store_manager'].includes(rawType)) {
        return 1
    }
    const clinicId = Number(resolveClinicId(source) || 0)
    if (Number.isFinite(clinicId) && clinicId > 0) {
        return 1
    }
    const roleCodes = Array.isArray(source.roleCodes) ? source.roleCodes.join(',') : ''
    const typeText = normalizeOptionalText(source.typeText || source.roleName || source.roleText || source.roleCode || roleCodes).toLowerCase()
    if (typeText.includes('医生') || typeText.includes('店长') || typeText.includes('诊所')
        || typeText.includes('doctor') || typeText.includes('clinic') || typeText.includes('manager')) {
        return 1
    }
    return 0
    const explicitType = Number(source.type ?? source.userType ?? source.user_type)
    if (Number.isFinite(explicitType)) {
        return explicitType === 1 ? 1 : 0
    }
    const rawType = normalizeOptionalText(source.type || source.userType || source.user_type).toLowerCase()
    if (['doctor', 'operator', 'clinic_manager', 'manager', 'store_manager'].includes(rawType)) {
        return 1
    }
    const clinicId = Number(resolveClinicId(source) || 0)
    if (Number.isFinite(clinicId) && clinicId > 0) {
        return 1
    }
    const roleCodes = Array.isArray(source.roleCodes) ? source.roleCodes.join(',') : ''
    const typeText = normalizeOptionalText(source.typeText || source.roleName || source.roleText || source.roleCode || roleCodes).toLowerCase()
    if (typeText.includes('医生') || typeText.includes('店长') || typeText.includes('诊所')
        || typeText.includes('doctor') || typeText.includes('clinic') || typeText.includes('manager')) {
        return 1
    }
    return 0
}
function resolveClinicId(source: Record<string, unknown>) {
    const clinicId = Number(source.clinicId ?? source.clinic_id ?? source.clinicID ?? source.storeId ?? source.store_id ?? 0)
    return Number.isFinite(clinicId) && clinicId > 0 ? clinicId : null
    const clinicId = Number(source.clinicId ?? source.clinic_id ?? source.clinicID ?? source.storeId ?? source.store_id ?? 0)
    return Number.isFinite(clinicId) && clinicId > 0 ? clinicId : null
}
function isDoctorLikeUser(value: UserCenterData) {
    return Number(value.type) === 1
        || (Number.isFinite(Number(value.clinicId)) && Number(value.clinicId) > 0)
        || normalizeOptionalText(value.typeText).includes('医生')
        || normalizeOptionalText(value.typeText).includes('店长')
        || normalizeOptionalText(value.roleCode).toLowerCase().includes('doctor')
        || normalizeOptionalText(value.roleCode).toLowerCase().includes('manager')
        || (Array.isArray(value.roleCodes) && value.roleCodes.some((item) => {
            const code = normalizeOptionalText(item).toLowerCase()
            return code.includes('doctor') || code.includes('manager') || code.includes('clinic')
        }))
    return Number(value.type) === 1
        || (Number.isFinite(Number(value.clinicId)) && Number(value.clinicId) > 0)
        || normalizeOptionalText(value.typeText).includes('医生')
        || normalizeOptionalText(value.typeText).includes('店长')
        || normalizeOptionalText(value.roleCode).toLowerCase().includes('doctor')
        || normalizeOptionalText(value.roleCode).toLowerCase().includes('manager')
        || (Array.isArray(value.roleCodes) && value.roleCodes.some((item) => {
            const code = normalizeOptionalText(item).toLowerCase()
            return code.includes('doctor') || code.includes('manager') || code.includes('clinic')
        }))
}
/**
 * 将个人中心接口返回的关键用户字段同步回本地登录缓存。
 */
function cacheUserCenter(centerData: UserCenterData) {
    const cachedUser = uni.getStorageSync('appUserInfo')
    const nextCache: AppUserCache = {
        ...(cachedUser && typeof cachedUser === 'object' ? cachedUser : {}),
        id: centerData.id,
        nickname: centerData.nickname,
        avatar: centerData.avatar,
        phone: centerData.phone,
        type: centerData.type,
        typeText: centerData.typeText,
        roleCode: centerData.roleCode,
        roleCodes: centerData.roleCodes,
        memberExpireAt: centerData.memberExpireAt,
        clinicId: centerData.clinicId,
    }
    uni.setStorageSync('appUserInfo', nextCache)
    const cachedUser = uni.getStorageSync('appUserInfo')
    const nextCache: AppUserCache = {
        ...(cachedUser && typeof cachedUser === 'object' ? cachedUser : {}),
        id: centerData.id,
        nickname: centerData.nickname,
        avatar: centerData.avatar,
        phone: centerData.phone,
        type: centerData.type,
        typeText: centerData.typeText,
        roleCode: centerData.roleCode,
        roleCodes: centerData.roleCodes,
        memberExpireAt: centerData.memberExpireAt,
        clinicId: centerData.clinicId,
    }
    uni.setStorageSync('appUserInfo', nextCache)
}
/**
 * 点击宫格菜单时跳转目标页面。
 */
function handleGridClick(item: MenuItem) {
    if (!item.url) {
        return
    }
    goPage(item)
    if (!item.url) {
        return
    }
    goPage(item)
}
/**
 * 处理底部普通菜单跳转。
 */
async function handleBottomMenuClick(item: MenuItem) {
    goPage(item)
    if (item.title === '更新版本') {
                await handleCheckAppVersion()
        return
    }
    goPage(item)
}
/**
 * 统一跳转个人中心相关页面。
 */
function goPage(target: string | MenuItem) {
    const url = typeof target === 'string' ? target : target.url
    if (!url) {
        return
    }
    if (typeof target !== 'string' && target.requiresLogin && !hasStoredSession()) {
        redirectToLogin()
        return
    }
    uni.navigateTo({
        url,
    })
    const url = typeof target === 'string' ? target : target.url
    if (!url) {
        return
    }
    if (typeof target !== 'string' && target.requiresLogin && !hasStoredSession()) {
        redirectToLogin()
        return
    }
    uni.navigateTo({
        url,
    })
}
/**
 * 未登录访问个人资产类页面时,先进入登录页。
 */
function redirectToLogin() {
    uni.showToast({
        title: '请先登录',
        icon: 'none',
    })
    setTimeout(() => {
        uni.navigateTo({
            url: '/pages/login/login',
        })
    }, 200)
    uni.showToast({
        title: '请先登录',
        icon: 'none',
    })
    setTimeout(() => {
        uni.navigateTo({
            url: '/pages/login/login',
        })
    }, 200)
}
/**
 * 将可选文本归一化为空字符串。
 */
function normalizeOptionalText(value: unknown) {
    if (typeof value !== 'string') {
        return ''
    }
    return value.trim()
    if (typeof value !== 'string') {
        return ''
    }
    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>
page {
    background-color: #F8F6F4;
    background-color: #F8F6F4;
}
.container {
    width: 750rpx;
    min-height: 100vh;
    position: relative;
    background-color: #F8F6F4;
    padding-bottom: env(safe-area-inset-bottom);
    width: 750rpx;
    min-height: 100vh;
    position: relative;
    background-color: #F8F6F4;
    padding-bottom: env(safe-area-inset-bottom);
}
.top-bg {
    position: absolute;
    top: 0;
    left: 0;
    width: 750rpx;
    z-index: 0;
    position: absolute;
    top: 0;
    left: 0;
    width: 750rpx;
    z-index: 0;
    .bg-img {
        width: 750rpx;
        height: auto;
    }
    .bg-img {
        width: 750rpx;
        height: auto;
    }
}
.status-bar-placeholder {
    width: 100%;
    position: relative;
    z-index: 10;
    width: 100%;
    position: relative;
    z-index: 10;
}
.content {
    position: relative;
    z-index: 10;
    padding: 0 32rpx;
    padding-bottom: 40rpx;
    position: relative;
    z-index: 10;
    padding: 0 32rpx;
    padding-bottom: 40rpx;
}
.top-actions {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding-top: 80rpx;
    gap: 32rpx;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding-top: 80rpx;
    gap: 32rpx;
    .notice-action {
        position: relative;
        width: 48rpx;
        height: 48rpx;
    }
    .notice-action {
        position: relative;
        width: 48rpx;
        height: 48rpx;
    }
    .action-icon {
        width: 48rpx;
        height: 48rpx;
    }
    .action-icon {
        width: 48rpx;
        height: 48rpx;
    }
    .notice-badge {
        position: absolute;
        top: -8rpx;
        right: -12rpx;
        min-width: 28rpx;
        height: 28rpx;
        padding: 0 8rpx;
        border-radius: 16rpx;
        background-color: #E64340;
        color: #FFFFFF;
        font-size: 20rpx;
        font-weight: 500;
        line-height: 28rpx;
        text-align: center;
        box-sizing: border-box;
    }
    .notice-badge {
        position: absolute;
        top: -8rpx;
        right: -12rpx;
        min-width: 28rpx;
        height: 28rpx;
        padding: 0 8rpx;
        border-radius: 16rpx;
        background-color: #E64340;
        color: #FFFFFF;
        font-size: 20rpx;
        font-weight: 500;
        line-height: 28rpx;
        text-align: center;
        box-sizing: border-box;
    }
}
.user-info {
    display: flex;
    align-items: center;
    margin-top: 24rpx;
    display: flex;
    align-items: center;
    margin-top: 24rpx;
    .avatar-wrap {
        position: relative;
        width: 120rpx;
        height: 120rpx;
    .avatar-wrap {
        position: relative;
        width: 120rpx;
        height: 120rpx;
        .avatar {
            width: 120rpx;
            height: 120rpx;
            border-radius: 50%;
            background-color: #eee;
        }
        .avatar {
            width: 120rpx;
            height: 120rpx;
            border-radius: 50%;
            background-color: #eee;
        }
        .role-tag {
            position: absolute;
            bottom: -10rpx;
            left: 50%;
            transform: translateX(-50%);
            color: #fff;
            font-size: 20rpx;
            padding: 2rpx 12rpx;
            border-radius: 10rpx;
            white-space: nowrap;
            z-index: 1;
        }
        .role-tag {
            position: absolute;
            bottom: -10rpx;
            left: 50%;
            transform: translateX(-50%);
            color: #fff;
            font-size: 20rpx;
            padding: 2rpx 12rpx;
            border-radius: 10rpx;
            white-space: nowrap;
            z-index: 1;
        }
        .doctor-tag {
            background-color: #E64340;
        }
        .doctor-tag {
            background-color: #E64340;
        }
        .personal-tag {
            background-color: #FF5A5F;
        }
    }
        .personal-tag {
            background-color: #FF5A5F;
        }
    }
    .user-detail {
        margin-left: 32rpx;
        display: flex;
        flex-direction: column;
    .user-detail {
        margin-left: 32rpx;
        display: flex;
        flex-direction: column;
        .user-name {
            font-size: 40rpx;
            color: #1A1A1A;
            font-weight: 500;
        }
        .user-name {
            font-size: 40rpx;
            color: #1A1A1A;
            font-weight: 500;
        }
        .user-points {
            font-size: 24rpx;
            color: #666;
            margin-top: 8rpx;
        }
    }
        .user-points {
            font-size: 24rpx;
            color: #666;
            margin-top: 8rpx;
        }
    }
}
.vip-banner {
    position: relative;
    width: 100%;
    height: 140rpx;
    border-radius: 16rpx;
    overflow: hidden;
    margin-top: 48rpx;
    margin-bottom: 32rpx;
    position: relative;
    width: 100%;
    height: 140rpx;
    border-radius: 16rpx;
    overflow: hidden;
    margin-top: 48rpx;
    margin-bottom: 32rpx;
    .vip-bg {
        position: absolute;
        width: 100%;
        height: 100%;
        top: 0;
        left: 0;
    }
    .vip-bg {
        position: absolute;
        width: 100%;
        height: 100%;
        top: 0;
        left: 0;
    }
    .vip-content {
        position: relative;
        width: 100%;
        height: 100%;
        box-sizing: border-box;
        padding: 24rpx 32rpx 24rpx 140rpx;
        display: flex;
        justify-content: space-between;
        align-items: center;
    .vip-content {
        position: relative;
        width: 100%;
        height: 100%;
        box-sizing: border-box;
        padding: 24rpx 32rpx 24rpx 140rpx;
        display: flex;
        justify-content: space-between;
        align-items: center;
        .vip-left {
            display: flex;
            flex-direction: column;
            justify-content: center;
        .vip-left {
            display: flex;
            flex-direction: column;
            justify-content: center;
            .vip-title {
                font-size: 32rpx;
                font-weight: bold;
                color: #AA5200;
                margin-bottom: 4rpx;
            }
            .vip-title {
                font-size: 32rpx;
                font-weight: bold;
                color: #AA5200;
                margin-bottom: 4rpx;
            }
            .vip-desc {
                font-size: 24rpx;
                color: #AA5200;
                opacity: 0.8;
            }
        }
            .vip-desc {
                font-size: 24rpx;
                color: #AA5200;
                opacity: 0.8;
            }
        }
        .vip-btn {
            background: linear-gradient(180deg, #FFD99B 0%, #FFB13C 100%);
            color: #AA5200;
            font-size: 24rpx;
            font-weight: bold;
            padding: 12rpx 24rpx;
            border-radius: 30rpx;
        }
    }
        .vip-btn {
            background: linear-gradient(180deg, #FFD99B 0%, #FFB13C 100%);
            color: #AA5200;
            font-size: 24rpx;
            font-weight: bold;
            padding: 12rpx 24rpx;
            border-radius: 30rpx;
        }
    }
}
.card-box {
    background-color: #FFFFFF;
    border-radius: 16rpx;
    padding: 32rpx;
    margin-bottom: 24rpx;
    background-color: #FFFFFF;
    border-radius: 16rpx;
    padding: 32rpx;
    margin-bottom: 24rpx;
}
.grid-section {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 40rpx 0;
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 40rpx 0;
    .grid-item {
        display: flex;
        flex-direction: column;
        align-items: center;
    .grid-item {
        display: flex;
        flex-direction: column;
        align-items: center;
        .grid-icon {
            width: 56rpx;
            height: 56rpx;
            margin-bottom: 16rpx;
        }
        .grid-icon {
            width: 56rpx;
            height: 56rpx;
            margin-bottom: 16rpx;
        }
        .grid-title {
            font-size: 26rpx;
            color: #1A1A1A;
        }
    }
        .grid-title {
            font-size: 26rpx;
            color: #1A1A1A;
        }
    }
}
.list-section {
    padding: 0 32rpx;
    padding: 0 32rpx;
    .list-item {
        display: flex;
        align-items: center;
        justify-content: space-between;
        height: 110rpx;
        border-bottom: 1px solid #F2F3F5;
    .list-item {
        display: flex;
        align-items: center;
        justify-content: space-between;
        height: 110rpx;
        border-bottom: 1px solid #F2F3F5;
        &:last-child {
            border-bottom: none;
        }
        &:last-child {
            border-bottom: none;
        }
        .list-left {
            display: flex;
            align-items: center;
        .list-right {
            display: flex;
            align-items: center;
        }
            .list-icon {
                width: 40rpx;
                height: 40rpx;
        .list-left {
            display: flex;
            align-items: center;
                &.icon-help {
                    width: 34rpx;
                    height: 34rpx;
                }
            .list-icon {
                width: 40rpx;
                height: 40rpx;
                &.icon-feedback {
                    width: 34rpx;
                    height: 34rpx;
                }
                &.icon-help {
                    width: 34rpx;
                    height: 34rpx;
                }
                &.icon-platform {
                    width: 34rpx;
                    height: 34rpx;
                }
                &.icon-feedback {
                    width: 34rpx;
                    height: 34rpx;
                }
                &.icon-user-agree {
                    width: 30rpx;
                    height: 30rpx;
                }
                &.icon-platform {
                    width: 34rpx;
                    height: 34rpx;
                }
                &.icon-privacy {
                    width: 27rpx;
                    height: 33rpx;
                }
            }
                &.icon-user-agree {
                    width: 30rpx;
                    height: 30rpx;
                }
            .list-title {
                font-size: 32rpx;
                color: #1A1A1A;
                margin-left: 22rpx;
            }
                &.icon-privacy {
                    width: 27rpx;
                    height: 33rpx;
                }
        }
    }
                &.icon-version {
                    width: 30rpx;
                    height: 30rpx;
                }
            }
            .list-title {
                font-size: 32rpx;
                color: #1A1A1A;
                margin-left: 22rpx;
            }
        }
        .list-version-text {
            margin-right: 16rpx;
            font-size: 24rpx;
            color: #86909C;
        }
    }
}
.app-version-text,
.icp-text {
    font-size: 28rpx;
    color: #86909C;
    text-align: center;
    font-size: 28rpx;
    color: #86909C;
    text-align: center;
}
.app-version-text {
    margin-top: 32rpx;
    margin-top: 32rpx;
}
.icp-text {
    margin-top: 8rpx;
    padding-bottom: 20rpx;
    margin-top: 8rpx;
    padding-bottom: 20rpx;
}
</style>
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({
        phone: form.phone.trim(),
        code: form.code.trim(),
        inviteCode: form.inviteCode.trim().toUpperCase(),
        bindTicket: bindTicket.value
    })
        const payload: Record<string, string> = {
                phone: form.phone.trim(),
                code: form.code.trim(),
                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)
    }
}