13404089107
2026-04-24 8b81df3a7638e768b640b7935d9da52b77cd7649
Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
3个文件已添加
13个文件已修改
1118 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/bindPhone/bindPhone.vue 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/bindPhone/service.js 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/downloads/downloads.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/qualificationDetail/qualificationDetail.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/course-download.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/utils.js 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
@@ -67,28 +67,28 @@
    }
    /**
     * 校验登录验证码,成功后销毁缓存中的一次性验证码。
     * 校验登录验证码,缓存有效期内允许重复提交。
     */
    public void validateLoginCode(String phone, String code) {
        validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX, SCENE_LOGIN);
    }
    /**
     * 校验注册验证码,成功后销毁缓存中的一次性验证码。
     * 校验注册验证码,缓存有效期内允许重复提交。
     */
    public void validateRegisterCode(String phone, String code) {
        validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX, SCENE_REGISTER);
    }
    /**
     * 校验忘记密码验证码,成功后销毁缓存中的一次性验证码。
     * 校验忘记密码验证码,缓存有效期内允许重复提交。
     */
    public void validatePasswordResetCode(String phone, String code) {
        validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX, SCENE_PASSWORD_RESET);
    }
    /**
     * 校验修改手机号验证码,成功后销毁缓存中的一次性验证码。
     * 校验修改手机号验证码,缓存有效期内允许重复提交。
     */
    public void validatePhoneChangeCode(String phone, String code) {
        validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX, SCENE_PHONE_CHANGE);
@@ -118,7 +118,7 @@
    }
    /**
     * 统一校验指定场景的验证码并执行一次性销毁。
     * 统一校验指定场景的验证码,验证码仅按缓存 TTL 自然失效。
     */
    private void validateCode(String phone, String code, String cachePrefix, String scene) {
        if (matchesDebugCode(scene, code)) {
@@ -133,8 +133,6 @@
        if (!cachedCode.equals(code)) {
            throw new IllegalArgumentException("验证码错误");
        }
        // 验证码登录成功后立即删除,避免重复使用。
        stringRedisTemplate.delete(cacheKey);
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
@@ -757,7 +757,7 @@
            return "答题成功,积分已到账";
        }
        if (dailyCoursePointsAlreadyAwarded) {
            return "今日该课程积分已获得";
            return "今日已获得积分,明日再来";
        }
        return "答题成功";
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
@@ -3,6 +3,7 @@
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneCodeRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsProfileUpdateRequest;
@@ -85,6 +86,14 @@
    }
    /**
     * 首次绑定当前登录用户手机号。
     */
    @PutMapping("/phone/bind")
    public ApiResponse<AppSettingsProfileVO> bindPhone(@Valid @RequestBody AppSettingsPhoneBindRequest request) {
        return ApiResponse.ok(appSettingsService.bindPhone(request));
    }
    /**
     * 修改当前登录用户密码。
     */
    @PutMapping("/password")
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
New file
@@ -0,0 +1,22 @@
package com.tcm.training.admin.app.settings.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
/**
 * APP 首次绑定手机号请求。
 */
@Getter
@Setter
@NoArgsConstructor
public class AppSettingsPhoneBindRequest {
    @NotBlank(message = "请输入正确的手机号")
    private String newPhone;
    @NotBlank(message = "请输入验证码")
    private String code;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
@@ -8,6 +8,7 @@
import com.tcm.training.admin.app.auth.service.AppWechatUserProfile;
import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneCodeRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneUpdateRequest;
import com.tcm.training.admin.app.settings.dto.AppSettingsProfileUpdateRequest;
@@ -143,6 +144,24 @@
    }
    /**
     * 首次绑定当前登录用户手机号,供微信登录未绑定手机号场景使用。
     */
    @Transactional
    public AppSettingsProfileVO bindPhone(AppSettingsPhoneBindRequest request) {
        AppUser user = requireCurrentEnabledUser();
        String newPhone = normalizePhone(request.getNewPhone());
        String code = normalizeRequired(request.getCode(), "请输入验证码");
        if (trimToNull(user.getPhone()) != null) {
            throw new IllegalArgumentException("当前账号已绑定手机号");
        }
        requirePhoneAvailable(newPhone, user.getId());
        appSmsCodeService.validatePhoneChangeCode(newPhone, code);
        user.setPhone(newPhone);
        userMapper.updateById(user);
        return toProfileVO(user);
    }
    /**
     * 修改当前登录用户密码,并在成功后使当前登录态失效。
     */
    @Transactional
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
@@ -84,6 +84,48 @@
    }
    /**
     * 验证码校验成功后不应删除缓存,保证 5 分钟有效期内可重复提交。
     */
    @Test
    void validateLoginCodeShouldKeepCachedCodeAfterSuccess() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get("app:sms:login:13800138000")).thenReturn("123456");
        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "123456"));
        verify(stringRedisTemplate, never()).delete(anyString());
    }
    /**
     * 同一验证码在缓存 TTL 内多次提交都应校验通过。
     */
    @Test
    void validateLoginCodeShouldAllowRepeatedSubmissionsWithinTtl() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get("app:sms:login:13800138000")).thenReturn("123456");
        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "123456"));
        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "123456"));
        verify(stringRedisTemplate, never()).delete(anyString());
    }
    /**
     * 缓存中存在验证码但输入不一致时仍应提示验证码错误。
     */
    @Test
    void validateLoginCodeShouldRejectMismatchedCode() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get("app:sms:login:13800138000")).thenReturn("123456");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appSmsCodeService.validateLoginCode("13800138000", "654321"));
        assertEquals("验证码错误", exception.getMessage());
        verify(stringRedisTemplate, never()).delete(anyString());
    }
    /**
     * 短信发送成功后才应写入验证码缓存。
     */
    @Test
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
New file
@@ -0,0 +1,188 @@
package com.tcm.training.admin.app.settings.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
import com.tcm.training.admin.app.auth.service.AppSmsCodeService;
import com.tcm.training.admin.app.auth.service.AppWechatAuthClient;
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
import com.tcm.training.admin.app.settings.vo.AppSettingsProfileVO;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.upload.ObsUploadService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * APP 设置服务手机号绑定测试。
 */
@ExtendWith(MockitoExtension.class)
class AppSettingsServiceTest {
    @BeforeAll
    static void initLambdaCache() {
        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
        TableInfoHelper.initTableInfo(assistant, AppUser.class);
    }
    @Mock
    private AppAuthSessionService appAuthSessionService;
    @Mock
    private UserMapper userMapper;
    @Mock
    private AppSmsCodeService appSmsCodeService;
    @Mock
    private BCryptPasswordEncoder passwordEncoder;
    @Mock
    private AppWechatAuthClient appWechatAuthClient;
    @Mock
    private AppUserNotificationSettingMapper appUserNotificationSettingMapper;
    @Mock
    private ObsUploadService obsUploadService;
    @Captor
    private ArgumentCaptor<AppUser> appUserCaptor;
    private AppSettingsService appSettingsService;
    /**
     * 每个用例前重新构造被测设置服务。
     */
    @BeforeEach
    void setUp() {
        appSettingsService = new AppSettingsService(
                appAuthSessionService,
                userMapper,
                appSmsCodeService,
                passwordEncoder,
                appWechatAuthClient,
                appUserNotificationSettingMapper,
                obsUploadService
        );
    }
    /**
     * 微信登录后未绑定手机号的用户,可通过验证码首次绑定手机号。
     */
    @Test
    void bindPhoneShouldUpdateCurrentUserWhenPhoneMissing() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        AppUser currentUser = buildEnabledUser(null);
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(currentUser);
        when(userMapper.selectOne(any())).thenReturn(null);
        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
        AppSettingsProfileVO response = appSettingsService.bindPhone(request);
        verify(userMapper).updateById(appUserCaptor.capture());
        assertEquals("13800138000", appUserCaptor.getValue().getPhone());
        assertEquals("13800138000", response.getPhone());
    }
    /**
     * 已绑定手机号的用户不允许走首次绑定接口。
     */
    @Test
    void bindPhoneShouldRejectWhenCurrentUserAlreadyHasPhone() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(buildEnabledUser("13900139000"));
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appSettingsService.bindPhone(request));
        assertEquals("当前账号已绑定手机号", exception.getMessage());
        verify(appSmsCodeService, never()).validatePhoneChangeCode(any(), any());
        verify(userMapper, never()).updateById(any());
    }
    /**
     * 目标手机号被其他有效用户占用时不允许绑定。
     */
    @Test
    void bindPhoneShouldRejectOccupiedPhone() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("123456");
        AppUser occupiedUser = buildEnabledUser("13800138000");
        occupiedUser.setId(2L);
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(buildEnabledUser(null));
        when(userMapper.selectOne(any())).thenReturn(occupiedUser);
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appSettingsService.bindPhone(request));
        assertEquals("该手机号已注册", exception.getMessage());
        verify(appSmsCodeService, never()).validatePhoneChangeCode(any(), any());
        verify(userMapper, never()).updateById(any());
    }
    /**
     * 验证码错误时不应更新用户手机号。
     */
    @Test
    void bindPhoneShouldNotUpdateWhenSmsCodeInvalid() {
        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
        request.setNewPhone("13800138000");
        request.setCode("000000");
        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
        when(userMapper.selectById(1L)).thenReturn(buildEnabledUser(null));
        when(userMapper.selectOne(any())).thenReturn(null);
        org.mockito.Mockito.doThrow(new IllegalArgumentException("验证码错误"))
                .when(appSmsCodeService).validatePhoneChangeCode("13800138000", "000000");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appSettingsService.bindPhone(request));
        assertEquals("验证码错误", exception.getMessage());
        verify(userMapper, never()).updateById(any());
    }
    /**
     * 构造启用状态 APP 用户。
     */
    private AppUser buildEnabledUser(String phone) {
        AppUser user = new AppUser();
        user.setId(1L);
        user.setNickname("微信用户");
        user.setAvatar("avatar.png");
        user.setPhone(phone);
        user.setType(0);
        user.setStatus(1);
        user.setDeleted(0);
        return user;
    }
}
traditionalApp/pages/bindPhone/bindPhone.vue
@@ -1,95 +1,213 @@
<template>
    <view class="container">
        <!-- Top Bar -->
        <view class="nav-bar">
            <!-- Note: Using a back arrow icon instead of close based on the screenshot -->
            <uni-icons type="left" size="24" color="#333333" @tap="goBack"></uni-icons>
        </view>
        <!-- Title -->
        <view class="title" :class="{ 'small-title': isCodeSent }">{{ isCodeSent ? '已发送验证码' : '绑定手机号' }}</view>
        <view class="phone-display" v-if="isCodeSent">{{ phone }}</view>
        <!-- Form Step 1: Input Phone -->
        <view class="form" v-if="!isCodeSent">
            <view class="input-group">
                <input class="input" type="number" placeholder="输入手机号" placeholder-class="placeholder-style" v-model="phone" focus />
                <input class="input" type="number" maxlength="11" placeholder="输入手机号" placeholder-class="placeholder-style" v-model="phone" focus />
            </view>
            <!-- Get Code Button -->
            <view class="get-code-btn" @tap="getCode">获取验证码</view>
            <view class="get-code-btn" :class="{ disabled: loading }" @tap="getCode">
                {{ loading ? '发送中...' : '获取验证码' }}
            </view>
        </view>
        <!-- Form Step 2: Input Verification Code -->
        <view class="code-form" v-else>
            <view class="code-inputs">
                <view class="code-box" :class="{ 'active': code.length === index }" v-for="(item, index) in 6" :key="index" @tap="focusCodeInput">
                <view class="code-box" :class="{ active: code.length === index }" v-for="(item, index) in 6" :key="index" @tap="focusCodeInput">
                    {{ code[index] || '' }}
                    <view class="cursor" v-if="code.length === index && isFocused"></view>
                </view>
            </view>
            <input class="hidden-input" type="number" :maxlength="6" v-model="code" :focus="isFocused" @blur="isFocused = false" @focus="isFocused = true" />
            <!-- Verify Button -->
            <view class="get-code-btn" @tap="verifyCode">立即验证</view>
            <view class="get-code-btn" :class="{ disabled: loading }" @tap="verifyCode">
                {{ loading ? '验证中...' : '立即验证' }}
            </view>
        </view>
    </view>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref } from 'vue'
import { bindSettingsPhone, sendBindPhoneCode } from './service.js'
const phone = ref('');
const isCodeSent = ref(false);
const code = ref('');
const isFocused = ref(false);
interface ApiResponse<T> {
    code: number
    msg?: string
    message?: string
    data: T
}
interface SettingsProfile {
    id?: number | null
    nickname?: string
    avatar?: string
    phone?: string
    type?: number | null
}
const phone = ref('')
const isCodeSent = ref(false)
const code = ref('')
const isFocused = ref(false)
const loading = ref(false)
/**
 * 返回上一步;首屏返回登录页,避免未绑定手机号用户直接进入业务页。
 */
const goBack = () => {
    if (isCodeSent.value) {
        isCodeSent.value = false;
        code.value = '';
    } else {
        uni.navigateBack();
        isCodeSent.value = false
        code.value = ''
        isFocused.value = false
        return
    }
};
    uni.redirectTo({
        url: '/pages/login/login'
    })
}
const getCode = () => {
    if (!phone.value || phone.value.length !== 11) {
/**
 * 发送首次绑定手机号验证码。
 */
const getCode = async () => {
    if (loading.value) {
        return
    }
    const normalizedPhone = normalizePhone()
    if (!normalizedPhone) {
        return
    }
    loading.value = true
    try {
        const response = await sendBindPhoneCode({ newPhone: normalizedPhone })
        if (!isSuccessResponse(response)) {
            return
        }
        phone.value = normalizedPhone
        isCodeSent.value = true
        code.value = ''
        setTimeout(() => {
            isFocused.value = true
        }, 100)
        uni.showToast({
            title: '验证码已发送',
            icon: 'success'
        })
    } finally {
        loading.value = false
    }
}
/**
 * 聚焦隐藏验证码输入框。
 */
const focusCodeInput = () => {
    isFocused.value = true
}
/**
 * 校验验证码并绑定当前微信登录账号手机号。
 */
const verifyCode = async () => {
    if (loading.value) {
        return
    }
    const normalizedPhone = normalizePhone()
    if (!normalizedPhone) {
        return
    }
    const normalizedCode = normalizeCode()
    if (!normalizedCode) {
        return
    }
    loading.value = true
    try {
        const response = await bindSettingsPhone({
            newPhone: normalizedPhone,
            code: normalizedCode
        })
        if (!isSuccessResponse(response)) {
            return
        }
        cacheBoundPhone(response.data, normalizedPhone)
        uni.showToast({
            title: '绑定成功',
            icon: 'success'
        })
        setTimeout(() => {
            uni.switchTab({
                url: '/pages/home/home'
            })
        }, 800)
    } finally {
        loading.value = false
    }
}
/**
 * 校验手机号输入是否合法。
 */
const normalizePhone = () => {
    const normalizedPhone = (phone.value || '').trim()
    if (!/^1\d{10}$/.test(normalizedPhone)) {
        uni.showToast({
            title: '请输入正确的手机号',
            icon: 'none'
        });
        return;
        })
        return ''
    }
    // Simulate sending code
    isCodeSent.value = true;
    setTimeout(() => {
        isFocused.value = true;
    }, 100);
};
    return normalizedPhone
}
const focusCodeInput = () => {
    isFocused.value = true;
};
const verifyCode = () => {
    if (code.value.length !== 6) {
/**
 * 校验验证码输入是否合法。
 */
const normalizeCode = () => {
    const normalizedCode = (code.value || '').trim()
    if (!/^\d{6}$/.test(normalizedCode)) {
        uni.showToast({
            title: '请输入6位验证码',
            icon: 'none'
        });
        return;
        })
        return ''
    }
    uni.showToast({
        title: '绑定成功',
        icon: 'success'
    });
    // Proceed to home page or previous page
    setTimeout(() => {
        uni.switchTab({
            url: '/pages/home/home'
        });
    }, 1500);
};
    return normalizedCode
}
/**
 * 绑定成功后同步本地用户缓存,保证个人中心立即展示手机号。
 */
const cacheBoundPhone = (profile: SettingsProfile | undefined, fallbackPhone: string) => {
    const cachedUser = uni.getStorageSync('appUserInfo')
    const nextUserInfo = {
        ...(cachedUser && typeof cachedUser === 'object' ? cachedUser : {}),
        ...(profile && typeof profile === 'object' ? profile : {}),
        phone: normalizeOptionalText(profile?.phone) || fallbackPhone
    }
    uni.setStorageSync('appUserInfo', nextUserInfo)
}
/**
 * 判断接口响应是否为成功态。
 */
const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => {
    return !!response && (response.code === 0 || response.code === 200)
}
/**
 * 将可选文本归一化为空字符串。
 */
const normalizeOptionalText = (value: unknown) => {
    if (typeof value !== 'string') {
        return ''
    }
    return value.trim()
}
</script>
<style lang="scss" scoped>
@@ -167,6 +285,10 @@
    font-size: 36rpx;
    border-radius: 16rpx;
    margin-top: 60rpx;
    &.disabled {
        opacity: 0.7;
    }
}
.code-form {
@@ -217,4 +339,4 @@
    0%, 100% { opacity: 1; }
    50% { opacity: 0; }
}
</style>
</style>
traditionalApp/pages/bindPhone/service.js
New file
@@ -0,0 +1,15 @@
import request from '@/utils/request.js'
/**
 * 发送 APP 首次绑定手机号验证码。
 */
export const sendBindPhoneCode = (data) => {
    return request.post('/api/app/settings/phone/code', data)
}
/**
 * 提交 APP 首次绑定手机号。
 */
export const bindSettingsPhone = (data) => {
    return request.put('/api/app/settings/phone/bind', data)
}
traditionalApp/pages/downloads/downloads.vue
@@ -101,7 +101,7 @@
    fileSize?: number | null
}
type DownloadSourceType = 'remoteCompleted' | 'localDownloading'
type DownloadSourceType = 'remoteCompleted' | 'localTask'
interface DownloadListItem {
    id: string
@@ -236,15 +236,8 @@
        })
        return
    }
    if (!currentItem.clientTaskId) {
        uni.showToast({
            title: '本地文件不存在,请重新下载',
            icon: 'none',
        })
        return
    }
    const taskInfo = getDownloadTask(resolveCurrentUserId(), currentItem.clientTaskId)
    if (!taskInfo || !normalizeOptionalText(taskInfo.savedFilePath)) {
    const playableTask = resolvePlayableDownloadTask(currentItem)
    if (!playableTask) {
        uni.showToast({
            title: '本地文件不存在,请重新下载',
            icon: 'none',
@@ -252,8 +245,29 @@
        return
    }
    uni.navigateTo({
        url: `/pages/downloadPlayer/downloadPlayer?clientTaskId=${encodeURIComponent(currentItem.clientTaskId)}`,
        url: `/pages/downloadPlayer/downloadPlayer?clientTaskId=${encodeURIComponent(playableTask.clientTaskId)}`,
    })
}
/**
 * 解析当前列表项对应的本地可播放任务,远端完成记录也必须匹配本地文件路径后才能播放。
 */
function resolvePlayableDownloadTask(item: DownloadListItem): LocalDownloadTask | null {
    if (item.clientTaskId) {
        const taskInfo = getDownloadTask(resolveCurrentUserId(), item.clientTaskId) as LocalDownloadTask | null
        if (taskInfo && normalizeOptionalText(taskInfo.savedFilePath)) {
            return taskInfo
        }
    }
    if (!item.courseVideoId) {
        return null
    }
    const matchedTask = listDownloadTasks(resolveCurrentUserId()).find((task) => {
        return Number(task.courseVideoId || 0) === Number(item.courseVideoId || 0)
            && task.status === 'downloaded'
            && !!normalizeOptionalText(task.savedFilePath)
    }) as LocalDownloadTask | undefined
    return matchedTask || null
}
/**
@@ -292,9 +306,9 @@
 * 删除勾选或单条下载项,并分别处理远端完成记录与本地下载中任务。
 */
async function deleteDownloadItems(items: DownloadListItem[]) {
    const remoteIds = items
        .filter((item) => item.sourceType === 'remoteCompleted' && item.remoteId)
        .map((item) => Number(item.remoteId))
    const remoteIds = Array.from(new Set(items
        .filter((item) => item.remoteId)
        .map((item) => Number(item.remoteId))))
    const localTaskIds = items
        .filter((item) => item.clientTaskId)
        .map((item) => item.clientTaskId)
@@ -382,7 +396,7 @@
            status: item.status === 'downloaded' ? 'downloaded' : 'downloading',
            progress: normalizeProgress(item.progress),
            selected: false,
            sourceType: 'localDownloading',
            sourceType: 'localTask',
            sortTime: resolveDateTime(item.savedAt || item.updatedAt),
            savedFilePath: normalizeOptionalText(item.savedFilePath),
        }
@@ -390,17 +404,36 @@
}
/**
 * 合并本地与远端下载项,优先保留本地任务。
 * 合并本地与远端下载项,优先保留带本地文件路径的任务,远端记录补齐删除标识。
 */
function mergeDownloadItems(localItems: DownloadListItem[], remoteItems: DownloadListItem[]) {
    const localVideoIdSet = new Set(
        localItems
            .map((item) => Number(item.courseVideoId || 0))
            .filter((item) => Number.isFinite(item) && item > 0)
    )
    const localByVideoId = new Map<number, DownloadListItem>()
    localItems.forEach((item) => {
        const videoId = Number(item.courseVideoId || 0)
        if (Number.isFinite(videoId) && videoId > 0) {
            localByVideoId.set(videoId, item)
        }
    })
    const mergedItems = remoteItems.map((remoteItem) => {
        const localItem = localByVideoId.get(Number(remoteItem.courseVideoId || 0))
        if (!localItem) {
            return remoteItem
        }
        localByVideoId.delete(Number(remoteItem.courseVideoId || 0))
        return {
            ...localItem,
            id: localItem.id,
            remoteId: remoteItem.remoteId,
            title: localItem.title || remoteItem.title,
            subtitle: localItem.subtitle || remoteItem.subtitle,
            duration: localItem.duration || remoteItem.duration,
            image: localItem.image || remoteItem.image,
            sortTime: Math.max(localItem.sortTime, remoteItem.sortTime),
        }
    })
    return [
        ...localItems,
        ...remoteItems.filter((item) => !localVideoIdSet.has(Number(item.courseVideoId || 0))),
        ...Array.from(localByVideoId.values()),
        ...mergedItems,
    ]
}
traditionalApp/pages/login/login.vue
@@ -60,7 +60,7 @@
    interface AppLoginUser {
        id : number
        nickname : string
        phone : string
        phone ?: string
        avatar ?: string
        type ?: number
        status ?: number
@@ -177,6 +177,12 @@
                return
            }
            saveLoginSession(res.data)
            if (shouldBindPhone(res.data)) {
                uni.redirectTo({
                    url: '/pages/bindPhone/bindPhone'
                })
                return
            }
            uni.showToast({
                title: '登录成功',
                icon: 'success'
@@ -402,6 +408,13 @@
    }
    /**
     * 判断微信登录返回用户是否缺少手机号,缺少时进入绑定手机号流程。
     */
    const shouldBindPhone = (loginData : AppLoginPayload) => {
        return !normalizeOptionalText(loginData?.user?.phone)
    }
    /**
     * 校验手机号输入是否合法。
     */
    const normalizePhone = () => {
traditionalApp/pages/mine/mine.vue
@@ -7,7 +7,10 @@
        <view class="content">
            <view class="top-actions">
                <image class="action-icon" src="/static/image/mine/notice.png" mode="aspectFit" @tap="goPage('/pages/notice/notice')"></image>
                <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>
@@ -49,6 +52,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getNoticeList } from '../notice/service'
import { getUserCenter } from './service'
type UserRole = 'personal' | 'doctor'
@@ -84,7 +88,12 @@
    iconClass?: string
}
interface NoticeSummaryItem {
    badge?: number | string | null
}
const statusBarHeight = ref(0)
const unreadNoticeTotal = ref(0)
const userCenter = ref<UserCenterData>(createDefaultUserCenter())
const userRole = computed<UserRole>(() => {
    return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal'
@@ -98,6 +107,9 @@
const displayPoints = computed(() => {
    const points = Number(userCenter.value.pointsBalance || 0)
    return Number.isFinite(points) ? points : 0
})
const noticeBadgeText = computed(() => {
    return unreadNoticeTotal.value > 99 ? '99+' : String(unreadNoticeTotal.value)
})
const gridMenuList = computed<MenuItem[]>(() => {
    if (userRole.value === 'doctor') {
@@ -172,7 +184,38 @@
onShow(() => {
    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
    }
}
/**
 * 将通知分类未读数量归一化为非负整数。
 */
function normalizeNoticeBadge(value: unknown) {
    const parsedValue = Number(value)
    if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
        return 0
    }
    return Math.floor(parsedValue)
}
/**
 * 拉取当前登录用户的个人中心聚合数据。
@@ -320,10 +363,33 @@
    padding-top: 20rpx;
    gap: 32rpx;
    .notice-action {
        position: relative;
        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;
    }
}
.user-info {
traditionalApp/pages/qualificationDetail/qualificationDetail.vue
@@ -61,9 +61,29 @@
function buildRichTextNodes(content: unknown) {
    const normalizedContent = normalizeOptionalText(content)
    if (normalizedContent) {
        return normalizedContent
        return normalizeRichTextImages(normalizedContent)
    }
    return '<p>暂无资质内容</p>'
}
/**
 * 将富文本图片调整为移动端自适应样式。
 */
function normalizeRichTextImages(htmlContent: string) {
    return htmlContent.replace(/<img\b([^>]*)>/gi, (_matchedTag, attributes = '') => {
        const cleanedAttributes = String(attributes)
            .replace(/\s(?:width|height)\s*=\s*(["']).*?\1/gi, '')
            .replace(/\s(?:width|height)\s*=\s*[^\s>]+/gi, '')
        const adaptiveStyle = 'max-width:100%;width:100%;height:auto;display:block;box-sizing:border-box;'
        if (/\sstyle\s*=/i.test(cleanedAttributes)) {
            const mergedAttributes = cleanedAttributes.replace(
                /\sstyle\s*=\s*(["'])(.*?)\1/i,
                (_styleText, quote, styleValue) => ` style=${quote}${styleValue};${adaptiveStyle}${quote}`,
            )
            return `<img${mergedAttributes}>`
        }
        return `<img${cleanedAttributes} style="${adaptiveStyle}">`
    })
}
/**
@@ -98,6 +118,9 @@
.rich-content {
    width: 100%;
    max-width: 100%;
    overflow-x: hidden;
    box-sizing: border-box;
}
.empty-box {
traditionalApp/pages/watchLive/watchLive.vue
@@ -16,6 +16,7 @@
                    :style="videoSectionStyle"
                    :src="liveDetail.videoUrl"
                    :poster="videoPoster"
                    :initial-time="liveInitialTime"
                    :controls="false"
                    :autoplay="isAppPlus"
                    :show-center-play-btn="false"
@@ -26,6 +27,8 @@
                    object-fit="contain"
                    play-btn-position="center"
                    @loadedmetadata="handleVideoLoadedMetadata"
                    @canplay="handleVideoCanPlay"
                    @timeupdate="handleVideoTimeUpdate"
                    @play="handleVideoPlay"
                    @pause="handleVideoPause"
                    @ended="handleVideoEnded"
@@ -110,6 +113,16 @@
                    </view>
                </template>
                <!-- 今日已获得积分状态 -->
                <template v-else-if="answerViewState === 'alreadyAwarded'">
                    <view class="result-container">
                        <image class="trophy-img grayscale" src="/static/image/home/qualified.png" mode="aspectFit"></image>
                        <view class="result-box failed-box">
                            <view class="result-title">今日已获得积分,明日再来</view>
                        </view>
                    </view>
                </template>
                <!-- 回答错误状态 -->
                <template v-else-if="answerViewState === 'wrong'">
                    <view class="result-container">
@@ -142,7 +155,7 @@
import config from '@/config/index.js';
type LiveActionStatus = 'RESERVABLE' | 'RESERVED' | 'WATCHABLE' | 'CLOSED'
type QuizViewState = 'answering' | 'success' | 'wrong' | 'unopened'
type QuizViewState = 'answering' | 'success' | 'alreadyAwarded' | 'wrong' | 'unopened'
type QuestionOptionLabel = 'A' | 'B' | 'C'
type VideoMetadataEvent = {
    detail?: {
@@ -152,6 +165,12 @@
type VideoErrorEvent = {
    detail?: {
        errMsg?: string
    }
}
type VideoTimeUpdateEvent = {
    detail?: {
        currentTime?: number
        duration?: number
    }
}
@@ -255,10 +274,14 @@
const isStartingPlayback = ref(false)
const isPlaybackStarted = ref(false)
const playbackFailed = ref(false)
const pendingPlaybackSync = ref(false)
const playbackSyncAttempts = ref(0)
const lastMediaCurrentTime = ref(0)
const currentWatchStartedAt = ref(0)
const instance = getCurrentInstance()
let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
let playbackStartupTimer: ReturnType<typeof setTimeout> | null = null
let playbackSyncTimer: ReturnType<typeof setTimeout> | null = null
let quizAvailabilityTimer: ReturnType<typeof setInterval> | null = null
/**
@@ -417,6 +440,7 @@
        && !!activeSessionId.value
        && !!quizState.value.quizEnabled
})
const liveInitialTime = computed(() => resolveInitialPlaybackSeconds())
const watchCountText = computed(() => formatCount(liveDetail.value.watchCount))
const rewardPointsText = computed(() => String(quizState.value.pointsReward || liveDetail.value.quizPoints || 0))
const hasQuizQuestions = computed(() => questions.value.length > 0)
@@ -449,9 +473,9 @@
        if (quizState.value.quizPointsAwarded) {
            return 'success'
        }
        // 历史数据若出现已全部答对但积分到账标记延迟,界面仍按成功态兜底展示。
        // 答对不等于本次获得积分,重复答题需明确提示今日已达奖励上限。
        if (quizState.value.quizAllCorrect) {
            return 'success'
            return 'alreadyAwarded'
        }
        return 'wrong'
    }
@@ -526,6 +550,7 @@
 */
onHide(() => {
    clearPlaybackStartupTimer()
    clearPlaybackSyncTimer()
    clearQuizAvailabilityTimer()
    submitCurrentWatchSegment()
})
@@ -535,6 +560,7 @@
 */
onUnload(() => {
    clearPlaybackStartupTimer()
    clearPlaybackSyncTimer()
    clearQuizAvailabilityTimer()
    submitCurrentWatchSegment()
})
@@ -647,6 +673,7 @@
 */
function resetVideoPlaybackState() {
    clearPlaybackStartupTimer()
    clearPlaybackSyncTimer()
    currentWatchStartedAt.value = 0
    isVideoReady.value = false
    isVideoPaused.value = false
@@ -655,6 +682,9 @@
    isStartingPlayback.value = false
    isPlaybackStarted.value = false
    playbackFailed.value = false
    pendingPlaybackSync.value = false
    playbackSyncAttempts.value = 0
    lastMediaCurrentTime.value = 0
}
/**
@@ -751,6 +781,24 @@
}
/**
 * 计算视频组件初始播放秒数,供安卓原生层首帧加载时直接定位。
 */
function resolveInitialPlaybackSeconds(): number {
    if (!supportsSimulatedPlayback.value) {
        return 0
    }
    const expectedProgressSeconds = resolveExpectedProgressSeconds()
    const configuredDurationSeconds = Number(liveDetail.value.durationSeconds || 0)
    if (configuredDurationSeconds <= 0) {
        return 0
    }
    if (mediaDurationSeconds.value > 0) {
        return resolveSeekSeconds()
    }
    return Math.max(expectedProgressSeconds, 0)
}
/**
 * 计算视频文件当前应 seek 到的秒数。
 */
function resolveSeekSeconds(): number {
@@ -787,6 +835,16 @@
    if (playbackStartupTimer) {
        clearTimeout(playbackStartupTimer)
        playbackStartupTimer = null
    }
}
/**
 * 清理播放进度同步重试定时器,避免页面隐藏后继续 seek。
 */
function clearPlaybackSyncTimer() {
    if (playbackSyncTimer) {
        clearTimeout(playbackSyncTimer)
        playbackSyncTimer = null
    }
}
@@ -832,37 +890,114 @@
 * 按当前时刻同步模拟直播进度,恢复播放时不会沿用旧的暂停点。
 */
function tryStartPlayback() {
    if (!videoContext || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
    if (!videoContext) {
        pendingPlaybackSync.value = true
        return
    }
    if (!supportsSimulatedPlayback.value) {
        triggerVideoPlay()
        return
    }
    if (!isVideoReady.value || mediaDurationSeconds.value <= 0) {
        pendingPlaybackSync.value = true
        schedulePlaybackSyncRetry()
        return
    }
    seekToExpectedPlayback(true)
}
/**
 * 按开播时间计算目标进度并执行 seek,必要时随后拉起播放。
 */
function seekToExpectedPlayback(shouldPlayAfterSeek: boolean, shouldScheduleRetry = true) {
    if (!videoContext || !supportsSimulatedPlayback.value || mediaDurationSeconds.value <= 0) {
        pendingPlaybackSync.value = true
        return
    }
    const seekSeconds = resolveSeekSeconds()
    pendingPlaybackSync.value = false
    isSyncingPlayback.value = true
    videoContext.seek(seekSeconds)
    if (shouldScheduleRetry) {
        schedulePlaybackSyncRetry()
    }
    setTimeout(() => {
        isSyncingPlayback.value = false
        triggerVideoPlay()
    }, 180)
        if (shouldPlayAfterSeek) {
            triggerVideoPlay()
        }
    }, 220)
}
/**
 * 安卓原生播放器偶发忽略早期 seek,这里做短延迟重试兜底。
 */
function schedulePlaybackSyncRetry() {
    if (!isAppPlus || !supportsSimulatedPlayback.value || playbackSyncAttempts.value >= 4) {
        return
    }
    clearPlaybackSyncTimer()
    playbackSyncAttempts.value += 1
    playbackSyncTimer = setTimeout(() => {
        if (!videoContext || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
            schedulePlaybackSyncRetry()
            return
        }
        seekToExpectedPlayback(false, false)
    }, 260)
}
/**
 * 在播放器进入可播放状态时补偿 pending 的同步请求。
 */
function syncPlaybackWhenReady(shouldPlayAfterSeek: boolean) {
    if (!supportsSimulatedPlayback.value) {
        if (shouldPlayAfterSeek) {
            triggerVideoPlay()
        }
        return
    }
    if (!videoContext || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
        pendingPlaybackSync.value = true
        schedulePlaybackSyncRetry()
        return
    }
    seekToExpectedPlayback(shouldPlayAfterSeek)
}
/**
 * 元数据加载后记录真实视频长度,并立即校准模拟直播进度。
 */
function handleVideoLoadedMetadata(event: VideoMetadataEvent) {
    mediaDurationSeconds.value = Number(event?.detail?.duration || 0)
    markVideoReady(event?.detail?.duration)
}
/**
 * 视频进入可播放状态时补一次校准,兼容安卓 APP loadedmetadata 不稳定。
 */
function handleVideoCanPlay(event: VideoMetadataEvent) {
    markVideoReady(event?.detail?.duration)
}
/**
 * 统一记录视频时长并在 APP 端按开播时间校准进度。
 */
function markVideoReady(duration?: number) {
    const normalizedDuration = Number(duration || 0)
    if (normalizedDuration > 0) {
        mediaDurationSeconds.value = normalizedDuration
    }
    isVideoReady.value = mediaDurationSeconds.value > 0
    if (!isVideoReady.value) {
        pendingPlaybackSync.value = true
        schedulePlaybackSyncRetry()
        return
    }
    if (isH5) {
        isVideoPaused.value = true
        return
    }
    // 元数据就绪后统一走同一套播放拉起逻辑,避免 App 端首帧停留。
    tryStartPlayback()
    syncPlaybackWhenReady(true)
}
/**
@@ -950,10 +1085,30 @@
    isPlaybackStarted.value = true
    playbackFailed.value = false
    isVideoPaused.value = false
    if (pendingPlaybackSync.value && !isSyncingPlayback.value) {
        syncPlaybackWhenReady(false)
    }
    startWatchSegment()
}
/**
 * 监听原生播放进度,发现安卓 APP 未按开播时间 seek 时再次校准。
 */
function handleVideoTimeUpdate(event: VideoTimeUpdateEvent) {
    lastMediaCurrentTime.value = Number(event?.detail?.currentTime || 0)
    if (!isAppPlus || !supportsSimulatedPlayback.value || !isVideoReady.value || isSyncingPlayback.value) {
        return
    }
    if (playbackSyncAttempts.value >= 4) {
        return
    }
    const expectedSeconds = resolveSeekSeconds()
    if (Math.abs(lastMediaCurrentTime.value - expectedSeconds) > 3) {
        seekToExpectedPlayback(false, false)
    }
}
/**
 * 记录视频暂停状态,供点击恢复时重新对齐直播进度,同时结束本次观看区间。
 */
function handleVideoPause() {
traditionalApp/utils/course-download.js
@@ -71,6 +71,9 @@
        durationSeconds: normalizedOptions.durationSeconds,
        durationText: normalizedOptions.durationText,
        videoUrl: normalizedOptions.videoUrl,
        savedFilePath: '',
        savedAt: '',
        fileSize: 0,
        progress: 0,
        status: 'downloading',
        updatedAt: new Date().toISOString(),
@@ -122,9 +125,15 @@
        },
    })
    downloadTask.onProgressUpdate((progressEvent) => {
        const currentTask = findDownloadTaskByVideoId(currentUserId, normalizedOptions.courseVideoId)
        if (currentTask?.status === 'downloaded' && normalizeText(currentTask.savedFilePath)) {
            return
        }
        upsertDownloadTask(currentUserId, {
            ...initialTask,
            ...(currentTask || initialTask),
            clientTaskId: downloadTaskId,
            progress: Number(progressEvent?.progress || 0),
            status: 'downloading',
            updatedAt: new Date().toISOString(),
        })
    })
traditionalApp/utils/utils.js
@@ -1,75 +1,223 @@
const SAVE_IMAGE_TIMEOUT_MS = 15000
/**
 * 保存图片到系统相册,APP 端优先走下载/本地文件链路,避免云打包后 getImageInfo 卡住。
 */
export const saveImage = (url) => {
    const normalizedUrl = normalizeOptionalText(url)
    if (!normalizedUrl) {
        uni.showToast({
            title: '图片地址为空',
            icon: 'none'
        })
        return
    }
    uni.showLoading({
        title: '正在保存图片...'
    });
    if (normalizeOptionalText(url).startsWith('data:image/')) {
        uni.hideLoading();
    })
    // #ifdef APP-PLUS
    saveImageInApp(normalizedUrl)
    return
    // #endif
    saveImageWithAlbumAuth(normalizedUrl)
}
/**
 * APP 环境保存图片,远程图片先下载,base64 图片先写入本地临时文件。
 */
const saveImageInApp = async (url) => {
    try {
        const filePath = await resolveAppImageFilePath(url)
        await withTimeout(saveImageToAlbum(filePath), '保存超时')
        showSaveImageSuccess()
    } catch (error) {
        showSaveImageFailure(error)
    }
}
/**
 * 非 APP 环境按原授权流程保存图片。
 */
const saveImageWithAlbumAuth = (url) => {
    if (url.startsWith('data:image/')) {
        uni.hideLoading()
        uni.showToast({
            title: "当前图片暂不支持直接保存,请稍后重试!",
            title: '当前图片暂不支持直接保存,请稍后重试!',
            icon: 'none'
        });
        })
        return
    }
    uni.getSetting({
        success: async (res) => {
            if (!res.authSetting["scope.writePhotosAlbum"]) {
            if (!res.authSetting['scope.writePhotosAlbum']) {
                uni.authorize({
                    scope: "scope.writePhotosAlbum",
                    scope: 'scope.writePhotosAlbum',
                    success: async () => {
                        await saveImageFile(url)
                    },
                    fail: () => {
                        uni.hideLoading();
                        uni.hideLoading()
                        uni.showModal({
                            title: "权限未授权",
                            content: "请前往设置中开启相册权限",
                            title: '权限未授权',
                            content: '请前往设置中开启相册权限',
                            success: (res) => {
                                if (res.confirm) {
                                    uni.openSetting();
                                    uni.openSetting()
                                }
                            }
                        });
                        })
                    }
                });
                })
            } else {
                await saveImageFile(url)
            }
        },
        fail: () => {
            uni.hideLoading();
            uni.hideLoading()
            uni.showToast({
                title: "获取权限失败!",
                title: '获取权限失败!',
                icon: 'error'
            });
            })
        }
    });
};
    })
}
/**
 * 根据图片类型解析 APP 端可保存的本地文件路径。
 */
const resolveAppImageFilePath = async (url) => {
    if (url.startsWith('data:image/')) {
        return writeBase64ImageToAppFile(url)
    }
    if (/^https?:\/\//i.test(url)) {
        return downloadImageToTempFile(url)
    }
    return url
}
/**
 * 下载远程图片到临时文件,避免 APP 端 getImageInfo 不回调导致 loading 卡住。
 */
const downloadImageToTempFile = (url) => {
    return withTimeout(new Promise((resolve, reject) => {
        uni.downloadFile({
            url,
            success: (result) => {
                if (result.statusCode !== 200 || !result.tempFilePath) {
                    reject(new Error('二维码下载失败'))
                    return
                }
                resolve(result.tempFilePath)
            },
            fail: () => reject(new Error('二维码下载失败'))
        })
    }), '二维码下载超时')
}
/**
 * 将 dataURL 图片写入 APP 私有临时文件。
 */
const writeBase64ImageToAppFile = (dataUrl) => {
    return withTimeout(new Promise((resolve, reject) => {
        const matchedData = dataUrl.match(/^data:image\/(png|jpeg|jpg);base64,(.+)$/i)
        if (!matchedData) {
            reject(new Error('二维码图片格式不正确'))
            return
        }
        // #ifdef APP-PLUS
        const extension = matchedData[1].toLowerCase() === 'jpg' ? 'jpg' : matchedData[1].toLowerCase()
        const fileName = `share-qrcode-${Date.now()}.${extension}`
        const bitmap = new plus.nativeObj.Bitmap(fileName)
        bitmap.loadBase64Data(dataUrl, () => {
            const filePath = `_doc/${fileName}`
            bitmap.save(filePath, {}, () => {
                bitmap.clear()
                resolve(filePath)
            }, () => {
                bitmap.clear()
                reject(new Error('二维码图片写入失败'))
            })
        }, () => {
            bitmap.clear()
            reject(new Error('二维码图片写入失败'))
        })
        // #endif
        // #ifndef APP-PLUS
        reject(new Error('当前环境暂不支持保存该图片'))
        // #endif
    }), '二维码图片写入超时')
}
/**
 * 保存本地图片文件到系统相册。
 */
const saveImageToAlbum = (filePath) => {
    return new Promise((resolve, reject) => {
        uni.saveImageToPhotosAlbum({
            filePath,
            success: resolve,
            fail: () => reject(new Error('保存失败,请检查相册权限'))
        })
    })
}
/**
 * 下载图片并写入系统相册。
 */
const saveImageFile = async (url) => {
    try {
        const imageInfo = await uni.getImageInfo({
        const imageInfo = await withTimeout(uni.getImageInfo({
            src: url
        });
        await uni.saveImageToPhotosAlbum({
            filePath: imageInfo.path,
        });
        uni.hideLoading();
        uni.showToast({
            title: "保存成功!"
        });
        }), '图片加载超时')
        await withTimeout(saveImageToAlbum(imageInfo.path), '保存超时')
        showSaveImageSuccess()
    } catch (error) {
        uni.hideLoading();
        uni.showToast({
            title: "保存失败,请重试!",
            icon: 'error'
        });
        showSaveImageFailure(error)
    }
}
/**
 * 为异步保存步骤增加超时保护,避免云打包后回调丢失导致 loading 常驻。
 */
const withTimeout = (promise, message) => {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            reject(new Error(message || '操作超时'))
        }, SAVE_IMAGE_TIMEOUT_MS)
        Promise.resolve(promise)
            .then((value) => {
                clearTimeout(timer)
                resolve(value)
            })
            .catch((error) => {
                clearTimeout(timer)
                reject(error)
            })
    })
}
/**
 * 展示保存成功状态。
 */
const showSaveImageSuccess = () => {
    uni.hideLoading()
    uni.showToast({
        title: '保存成功!'
    })
}
/**
 * 展示保存失败状态,确保任何失败路径都会关闭 loading。
 */
const showSaveImageFailure = (error) => {
    uni.hideLoading()
    uni.showToast({
        title: normalizeOptionalText(error && error.message) || '保存失败,请重试!',
        icon: 'none'
    })
}
/**
 * 将可选文本归一化为空字符串。
 */
export const normalizeOptionalText = (value) => {