Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
| | |
| | | } |
| | | |
| | | /** |
| | | * 校验登录验证码,成功后销毁缓存中的一次性验证码。 |
| | | * 校验登录验证码,缓存有效期内允许重复提交。 |
| | | */ |
| | | 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 统一校验指定场景的验证码并执行一次性销毁。 |
| | | * 统一校验指定场景的验证码,验证码仅按缓存 TTL 自然失效。 |
| | | */ |
| | | private void validateCode(String phone, String code, String cachePrefix, String scene) { |
| | | if (matchesDebugCode(scene, code)) { |
| | |
| | | if (!cachedCode.equals(code)) { |
| | | throw new IllegalArgumentException("验证码错误"); |
| | | } |
| | | // 验证码登录成功后立即删除,避免重复使用。 |
| | | stringRedisTemplate.delete(cacheKey); |
| | | } |
| | | |
| | | /** |
| | |
| | | return "答题成功,积分已到账"; |
| | | } |
| | | if (dailyCoursePointsAlreadyAwarded) { |
| | | return "今日该课程积分已获得"; |
| | | return "今日已获得积分,明日再来"; |
| | | } |
| | | return "答题成功"; |
| | | } |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 首次绑定当前登录用户手机号。 |
| | | */ |
| | | @PutMapping("/phone/bind") |
| | | public ApiResponse<AppSettingsProfileVO> bindPhone(@Valid @RequestBody AppSettingsPhoneBindRequest request) { |
| | | return ApiResponse.ok(appSettingsService.bindPhone(request)); |
| | | } |
| | | |
| | | /** |
| | | * 修改当前登录用户密码。 |
| | | */ |
| | | @PutMapping("/password") |
| New file |
| | |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 首次绑定当前登录用户手机号,供微信登录未绑定手机号场景使用。 |
| | | */ |
| | | @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 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 验证码校验成功后不应删除缓存,保证 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 |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | <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) { |
| | | uni.showToast({ |
| | | title: '请输入正确的手机号', |
| | | icon: 'none' |
| | | }); |
| | | return; |
| | | /** |
| | | * 发送首次绑定手机号验证码。 |
| | | */ |
| | | const getCode = async () => { |
| | | if (loading.value) { |
| | | return |
| | | } |
| | | // Simulate sending code |
| | | isCodeSent.value = true; |
| | | 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); |
| | | }; |
| | | |
| | | const focusCodeInput = () => { |
| | | isFocused.value = true; |
| | | }; |
| | | |
| | | const verifyCode = () => { |
| | | if (code.value.length !== 6) { |
| | | isFocused.value = true |
| | | }, 100) |
| | | uni.showToast({ |
| | | title: '请输入6位验证码', |
| | | icon: 'none' |
| | | }); |
| | | return; |
| | | 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' |
| | | }); |
| | | // Proceed to home page or previous page |
| | | }) |
| | | setTimeout(() => { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }); |
| | | }, 1500); |
| | | }; |
| | | }) |
| | | }, 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 normalizedPhone |
| | | } |
| | | |
| | | /** |
| | | * 校验验证码输入是否合法。 |
| | | */ |
| | | const normalizeCode = () => { |
| | | const normalizedCode = (code.value || '').trim() |
| | | if (!/^\d{6}$/.test(normalizedCode)) { |
| | | uni.showToast({ |
| | | title: '请输入6位验证码', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | 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> |
| | |
| | | font-size: 36rpx; |
| | | border-radius: 16rpx; |
| | | margin-top: 60rpx; |
| | | |
| | | &.disabled { |
| | | opacity: 0.7; |
| | | } |
| | | } |
| | | |
| | | .code-form { |
| New file |
| | |
| | | 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) |
| | | } |
| | |
| | | fileSize?: number | null |
| | | } |
| | | |
| | | type DownloadSourceType = 'remoteCompleted' | 'localDownloading' |
| | | type DownloadSourceType = 'remoteCompleted' | 'localTask' |
| | | |
| | | interface DownloadListItem { |
| | | id: string |
| | |
| | | }) |
| | | 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', |
| | |
| | | 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 |
| | | } |
| | | |
| | | /** |
| | |
| | | * 删除勾选或单条下载项,并分别处理远端完成记录与本地下载中任务。 |
| | | */ |
| | | 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) |
| | |
| | | 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), |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 合并本地与远端下载项,优先保留本地任务。 |
| | | * 合并本地与远端下载项,优先保留带本地文件路径的任务,远端记录补齐删除标识。 |
| | | */ |
| | | 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, |
| | | ] |
| | | } |
| | | |
| | |
| | | interface AppLoginUser { |
| | | id : number |
| | | nickname : string |
| | | phone : string |
| | | phone ?: string |
| | | avatar ?: string |
| | | type ?: number |
| | | status ?: number |
| | |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | if (shouldBindPhone(res.data)) { |
| | | uni.redirectTo({ |
| | | url: '/pages/bindPhone/bindPhone' |
| | | }) |
| | | return |
| | | } |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | icon: 'success' |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断微信登录返回用户是否缺少手机号,缺少时进入绑定手机号流程。 |
| | | */ |
| | | const shouldBindPhone = (loginData : AppLoginPayload) => { |
| | | return !normalizeOptionalText(loginData?.user?.phone) |
| | | } |
| | | |
| | | /** |
| | | * 校验手机号输入是否合法。 |
| | | */ |
| | | const normalizePhone = () => { |
| | |
| | | |
| | | <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> |
| | | |
| | |
| | | <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' |
| | |
| | | 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' |
| | |
| | | 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') { |
| | |
| | | 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) |
| | | } |
| | | |
| | | /** |
| | | * 拉取当前登录用户的个人中心聚合数据。 |
| | |
| | | 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 { |
| | |
| | | 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}">` |
| | | }) |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | .rich-content { |
| | | width: 100%; |
| | | max-width: 100%; |
| | | overflow-x: hidden; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .empty-box { |
| | |
| | | :style="videoSectionStyle" |
| | | :src="liveDetail.videoUrl" |
| | | :poster="videoPoster" |
| | | :initial-time="liveInitialTime" |
| | | :controls="false" |
| | | :autoplay="isAppPlus" |
| | | :show-center-play-btn="false" |
| | |
| | | object-fit="contain" |
| | | play-btn-position="center" |
| | | @loadedmetadata="handleVideoLoadedMetadata" |
| | | @canplay="handleVideoCanPlay" |
| | | @timeupdate="handleVideoTimeUpdate" |
| | | @play="handleVideoPlay" |
| | | @pause="handleVideoPause" |
| | | @ended="handleVideoEnded" |
| | |
| | | </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"> |
| | |
| | | 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?: { |
| | |
| | | type VideoErrorEvent = { |
| | | detail?: { |
| | | errMsg?: string |
| | | } |
| | | } |
| | | type VideoTimeUpdateEvent = { |
| | | detail?: { |
| | | currentTime?: number |
| | | duration?: number |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | | |
| | | /** |
| | |
| | | && !!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) |
| | |
| | | if (quizState.value.quizPointsAwarded) { |
| | | return 'success' |
| | | } |
| | | // 历史数据若出现已全部答对但积分到账标记延迟,界面仍按成功态兜底展示。 |
| | | // 答对不等于本次获得积分,重复答题需明确提示今日已达奖励上限。 |
| | | if (quizState.value.quizAllCorrect) { |
| | | return 'success' |
| | | return 'alreadyAwarded' |
| | | } |
| | | return 'wrong' |
| | | } |
| | |
| | | */ |
| | | onHide(() => { |
| | | clearPlaybackStartupTimer() |
| | | clearPlaybackSyncTimer() |
| | | clearQuizAvailabilityTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | |
| | | */ |
| | | onUnload(() => { |
| | | clearPlaybackStartupTimer() |
| | | clearPlaybackSyncTimer() |
| | | clearQuizAvailabilityTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | |
| | | */ |
| | | function resetVideoPlaybackState() { |
| | | clearPlaybackStartupTimer() |
| | | clearPlaybackSyncTimer() |
| | | currentWatchStartedAt.value = 0 |
| | | isVideoReady.value = false |
| | | isVideoPaused.value = false |
| | |
| | | isStartingPlayback.value = false |
| | | isPlaybackStarted.value = false |
| | | playbackFailed.value = false |
| | | pendingPlaybackSync.value = false |
| | | playbackSyncAttempts.value = 0 |
| | | lastMediaCurrentTime.value = 0 |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 计算视频组件初始播放秒数,供安卓原生层首帧加载时直接定位。 |
| | | */ |
| | | 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 { |
| | |
| | | if (playbackStartupTimer) { |
| | | clearTimeout(playbackStartupTimer) |
| | | playbackStartupTimer = null |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 清理播放进度同步重试定时器,避免页面隐藏后继续 seek。 |
| | | */ |
| | | function clearPlaybackSyncTimer() { |
| | | if (playbackSyncTimer) { |
| | | clearTimeout(playbackSyncTimer) |
| | | playbackSyncTimer = null |
| | | } |
| | | } |
| | | |
| | |
| | | * 按当前时刻同步模拟直播进度,恢复播放时不会沿用旧的暂停点。 |
| | | */ |
| | | 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 |
| | | if (shouldPlayAfterSeek) { |
| | | triggerVideoPlay() |
| | | }, 180) |
| | | } |
| | | }, 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) |
| | | } |
| | | |
| | | /** |
| | |
| | | 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() { |
| | |
| | | durationSeconds: normalizedOptions.durationSeconds, |
| | | durationText: normalizedOptions.durationText, |
| | | videoUrl: normalizedOptions.videoUrl, |
| | | savedFilePath: '', |
| | | savedAt: '', |
| | | fileSize: 0, |
| | | progress: 0, |
| | | status: 'downloading', |
| | | updatedAt: new Date().toISOString(), |
| | |
| | | }, |
| | | }) |
| | | 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(), |
| | | }) |
| | | }) |
| | |
| | | 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) => { |