From 8b81df3a7638e768b640b7935d9da52b77cd7649 Mon Sep 17 00:00:00 2001
From: 13404089107 <puwei@sinata.cn>
Date: 星期五, 24 四月 2026 20:22:47 +0800
Subject: [PATCH] Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
---
traditionalApp/pages/mine/mine.vue | 68 +++
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java | 19 +
traditionalApp/utils/course-download.js | 11
traditionalApp/pages/qualificationDetail/qualificationDetail.vue | 25 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java | 2
traditionalApp/pages/login/login.vue | 15
traditionalApp/pages/watchLive/watchLive.vue | 173 ++++++++
traditionalApp/utils/utils.js | 212 +++++++++-
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java | 188 +++++++++
traditionalApp/pages/downloads/downloads.vue | 79 ++-
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java | 9
traditionalApp/pages/bindPhone/service.js | 15
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java | 12
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java | 22 +
traditionalApp/pages/bindPhone/bindPhone.vue | 226 +++++++++--
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java | 42 ++
16 files changed, 990 insertions(+), 128 deletions(-)
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
index 1b8ee69..1b25665 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
+++ b/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);
}
/**
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
index b81e292..a4f3da4 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
+++ b/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 "答题成功";
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
index 564397a..3ae0926 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
+++ b/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")
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
new file mode 100644
index 0000000..43b4be6
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
@@ -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;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
index 87cbfb4..0af4fed 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
+++ b/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
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
index 1a035ef..e489563 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
+++ b/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
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
new file mode 100644
index 0000000..b695bf2
--- /dev/null
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
@@ -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;
+ }
+}
diff --git a/traditionalApp/pages/bindPhone/bindPhone.vue b/traditionalApp/pages/bindPhone/bindPhone.vue
index 3effa66..643a91c 100644
--- a/traditionalApp/pages/bindPhone/bindPhone.vue
+++ b/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>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/bindPhone/service.js b/traditionalApp/pages/bindPhone/service.js
new file mode 100644
index 0000000..c6336d8
--- /dev/null
+++ b/traditionalApp/pages/bindPhone/service.js
@@ -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)
+}
diff --git a/traditionalApp/pages/downloads/downloads.vue b/traditionalApp/pages/downloads/downloads.vue
index c7262c7..42ee1ce 100644
--- a/traditionalApp/pages/downloads/downloads.vue
+++ b/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,
]
}
diff --git a/traditionalApp/pages/login/login.vue b/traditionalApp/pages/login/login.vue
index 669f15c..c056a92 100644
--- a/traditionalApp/pages/login/login.vue
+++ b/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 = () => {
diff --git a/traditionalApp/pages/mine/mine.vue b/traditionalApp/pages/mine/mine.vue
index a74b264..86562e9 100644
--- a/traditionalApp/pages/mine/mine.vue
+++ b/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 {
diff --git a/traditionalApp/pages/qualificationDetail/qualificationDetail.vue b/traditionalApp/pages/qualificationDetail/qualificationDetail.vue
index ae4b159..d71a0f8 100644
--- a/traditionalApp/pages/qualificationDetail/qualificationDetail.vue
+++ b/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 {
diff --git a/traditionalApp/pages/watchLive/watchLive.vue b/traditionalApp/pages/watchLive/watchLive.vue
index be3045d..e307ff0 100644
--- a/traditionalApp/pages/watchLive/watchLive.vue
+++ b/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() {
diff --git a/traditionalApp/utils/course-download.js b/traditionalApp/utils/course-download.js
index 813801a..1a329cc 100644
--- a/traditionalApp/utils/course-download.js
+++ b/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(),
})
})
diff --git a/traditionalApp/utils/utils.js b/traditionalApp/utils/utils.js
index 6bf4230..0e7c2af 100644
--- a/traditionalApp/utils/utils.js
+++ b/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) => {
--
Gitblit v1.7.1