Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
| | |
| | | proxy_set_header X-Forwarded-Proto $scheme; |
| | | } |
| | | |
| | | # 本地上传文件与 APP 安装包统一通过 Spring Boot 的 /uploads/** 静态映射对外提供访问。 |
| | | location /uploads/ { |
| | | proxy_pass http://127.0.0.1:48080; |
| | | proxy_read_timeout 60s; |
| | | proxy_http_version 1.1; |
| | | proxy_set_header Host $host; |
| | | proxy_set_header X-Real-IP $remote_addr; |
| | | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| | | proxy_set_header X-Forwarded-Proto $scheme; |
| | | } |
| | | |
| | | # 邀请二维码落地页入口,补全尾部斜杠时保留邀请码查询参数。 |
| | | location = /app-download { |
| | | return 301 /app-download/$is_args$args; |
| | |
| | | # 3. 线上部署目录建议:/usr/local/project/tcm-training/admin/ |
| | | # 4. 线上访问地址:https://haizhentong.com/admin/ |
| | | # 5. 后端接口地址:https://haizhentong.com/api/ |
| | | # 6. 邀请二维码落地页地址:https://haizhentong.com/app-download/?inviteCode=XXXXXX |
| | | # 7. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。 |
| | | # 6. 本地上传文件与 APP 安装包访问地址:https://haizhentong.com/uploads/... |
| | | # 7. 邀请二维码落地页地址:https://haizhentong.com/app-download/?inviteCode=XXXXXX |
| | | # 8. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。 |
| | | # 9. 若 /uploads/... 线上返回 404,先确认本段 location 已合并并 reload,再检查文件是否写入 /data/tcm-training-admin/uploads/。 |
| | |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | |
| | | detailVO.setDisplayStatusText(resolveDisplayStatusText(snapshot)); |
| | | detailVO.setCountdownTime(resolveCountdownTime(snapshot)); |
| | | detailVO.setActionSessionId(actionSession == null ? null : actionSession.getId()); |
| | | detailVO.setVideoUrl(actionSession == null ? null : appLiveCourseDisplaySupport.trimToNull(actionSession.getVideoUrl())); |
| | | detailVO.setVideoUrl(resolvePlayableVideoUrl(snapshot, actionSession)); |
| | | detailVO.setPlaybackStartTime(playbackStartTime); |
| | | detailVO.setDurationSeconds(actionSession == null ? null : actionSession.getDurationSeconds()); |
| | | detailVO.setReserveCount(resolveReserveCount(courseId, sessions)); |
| | |
| | | |
| | | Map<Long, String> normalizedAnswers = validateAndNormalizeAnswers(request.getAnswers(), questions); |
| | | WatchProgressSummary watchSummary = resolveWatchProgress(sessionId, userId, session); |
| | | LocalDateTime answeredAt = LocalDateTime.now(); |
| | | boolean allCorrect = true; |
| | | List<LiveSessionQuizRecord> insertedRecords = new ArrayList<LiveSessionQuizRecord>(); |
| | | for (LiveSessionQuestion question : questions) { |
| | |
| | | record.setChosenOption(chosenOption); |
| | | record.setIsCorrect(correct ? 1 : 0); |
| | | record.setPointsEarned(0); |
| | | record.setAnsweredAt(LocalDateTime.now()); |
| | | record.setAnsweredAt(answeredAt); |
| | | liveSessionQuizRecordMapper.insert(record); |
| | | insertedRecords.add(record); |
| | | } |
| | | |
| | | boolean pointsAwarded = false; |
| | | boolean dailyCoursePointsAlreadyAwarded = false; |
| | | // 当前业务改为只要三题全部答对就立即发放积分,不再依赖完整观看直播。 |
| | | if (allCorrect) { |
| | | pointsAwarded = awardQuizPoints(course, session, userId, insertedRecords, questions); |
| | | dailyCoursePointsAlreadyAwarded = hasAwardedCourseQuizPointsToday(course.getId(), userId, answeredAt); |
| | | pointsAwarded = awardQuizPoints(course, session, userId, insertedRecords, questions, answeredAt); |
| | | } |
| | | |
| | | AppLiveQuizSubmitResultVO resultVO = new AppLiveQuizSubmitResultVO(); |
| | |
| | | resultVO.setWatchCompleted(Boolean.valueOf(watchSummary.isWatchCompleted())); |
| | | resultVO.setPointsAwarded(Boolean.valueOf(pointsAwarded)); |
| | | resultVO.setPointsReward(Integer.valueOf(defaultNumber(course.getQuizPoints()))); |
| | | resultVO.setMessage(resolveQuizSubmitMessage(allCorrect, pointsAwarded)); |
| | | resultVO.setMessage(resolveQuizSubmitMessage(allCorrect, pointsAwarded, dailyCoursePointsAlreadyAwarded)); |
| | | return resultVO; |
| | | } |
| | | |
| | |
| | | LiveSession session, |
| | | Long userId, |
| | | List<LiveSessionQuizRecord> quizRecords, |
| | | List<LiveSessionQuestion> questions) { |
| | | List<LiveSessionQuestion> questions, |
| | | LocalDateTime answeredAt) { |
| | | int pointsReward = defaultNumber(course.getQuizPoints()); |
| | | if (pointsReward <= 0 || hasAwardedQuizPoints(quizRecords)) { |
| | | return false; |
| | | } |
| | | if (hasAwardedCourseQuizPointsToday(course.getId(), userId, answeredAt)) { |
| | | return false; |
| | | } |
| | | LiveSessionQuizRecord rewardRecord = resolveRewardRecord(quizRecords, questions); |
| | |
| | | // 积分到账后立即同步流水,避免 APP 积分页与直播观看页状态延迟。 |
| | | pointsLedgerAppService.syncLedger(); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 判断用户在同一直播课的答题自然日内是否已获得过积分奖励。 |
| | | */ |
| | | private boolean hasAwardedCourseQuizPointsToday(Long courseId, Long userId, LocalDateTime answeredAt) { |
| | | if (courseId == null || userId == null) { |
| | | return false; |
| | | } |
| | | LocalDate answerDate = (answeredAt == null ? LocalDateTime.now() : answeredAt).toLocalDate(); |
| | | Integer awardedCount = liveSessionQuizRecordMapper.countAwardedCourseQuizPoints( |
| | | courseId, |
| | | userId, |
| | | answerDate.atStartOfDay(), |
| | | answerDate.plusDays(1).atStartOfDay() |
| | | ); |
| | | return awardedCount != null && awardedCount.intValue() > 0; |
| | | } |
| | | |
| | | /** |
| | |
| | | /** |
| | | * 构造答题提交后的提示文案。 |
| | | */ |
| | | private String resolveQuizSubmitMessage(boolean allCorrect, boolean pointsAwarded) { |
| | | private String resolveQuizSubmitMessage(boolean allCorrect, |
| | | boolean pointsAwarded, |
| | | boolean dailyCoursePointsAlreadyAwarded) { |
| | | if (!allCorrect) { |
| | | return "回答错误,本次不获得积分"; |
| | | } |
| | | if (pointsAwarded) { |
| | | return "答题成功,积分已到账"; |
| | | } |
| | | if (dailyCoursePointsAlreadyAwarded) { |
| | | return "今日该课程积分已获得"; |
| | | } |
| | | return "答题成功"; |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 解析观看页可播放视频地址,直播结束冷却期只保留答题入口不继续播放视频。 |
| | | */ |
| | | private String resolvePlayableVideoUrl(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot, |
| | | LiveSession actionSession) { |
| | | if (snapshot.isEndedCooldown() || actionSession == null) { |
| | | return null; |
| | | } |
| | | return appLiveCourseDisplaySupport.trimToNull(actionSession.getVideoUrl()); |
| | | } |
| | | |
| | | /** |
| | | * 解析模拟直播回放进度的起播时间,优先使用实际开播时间。 |
| | | */ |
| | | private LocalDateTime resolvePlaybackStartTime(LiveSession actionSession) { |
| | |
| | | CourseDisplaySnapshot snapshot = new CourseDisplaySnapshot(); |
| | | LiveSession latestStartedSession = findLatestStartedSession(safeSessions, now); |
| | | snapshot.setLatestStartedSession(latestStartedSession); |
| | | LiveSession currentSession = resolveCurrentSession(latestStartedSession); |
| | | LiveSession currentSession = resolveCurrentSession(latestStartedSession, now); |
| | | snapshot.setCurrentSession(currentSession); |
| | | if (currentSession != null) { |
| | | snapshot.setDisplayStatus(DISPLAY_STATUS_LIVING); |
| | | return snapshot; |
| | | } |
| | | |
| | | LiveSession latestEndedSession = resolveLatestEndedSession(safeSessions, latestStartedSession); |
| | | LiveSession latestEndedSession = resolveLatestEndedSession(safeSessions, latestStartedSession, now); |
| | | snapshot.setLatestEndedSession(latestEndedSession); |
| | | snapshot.setLatestEndedAt(resolveEndedAt(latestEndedSession)); |
| | | if (isInEndedCooldown(latestEndedSession, now, endedCooldownMinutes)) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取直播结束时间,优先使用实际结束时间。 |
| | | * 获取直播结束时间,优先使用实际结束时间,其次按计划开播时间和配置时长计算。 |
| | | */ |
| | | public LocalDateTime resolveEndedAt(LiveSession session) { |
| | | if (session == null) { |
| | |
| | | } |
| | | if (session.getActualEndTime() != null) { |
| | | return session.getActualEndTime(); |
| | | } |
| | | if (session.getStartTime() != null && defaultNumber(session.getDurationSeconds()) > 0) { |
| | | return session.getStartTime().plusSeconds(defaultNumber(session.getDurationSeconds())); |
| | | } |
| | | return session.getStartTime(); |
| | | } |
| | |
| | | /** |
| | | * 将时间线上最近一场已开始场次解析为当前直播场次。 |
| | | */ |
| | | private LiveSession resolveCurrentSession(LiveSession latestStartedSession) { |
| | | private LiveSession resolveCurrentSession(LiveSession latestStartedSession, LocalDateTime now) { |
| | | if (latestStartedSession == null) { |
| | | return null; |
| | | } |
| | | if (defaultNumber(latestStartedSession.getLiveStatus()) != SESSION_STATUS_LIVING) { |
| | | return null; |
| | | if (defaultNumber(latestStartedSession.getLiveStatus()) == SESSION_STATUS_LIVING) { |
| | | if (defaultNumber(latestStartedSession.getDurationSeconds()) > 0) { |
| | | LocalDateTime endedAt = resolveEndedAt(latestStartedSession); |
| | | if (endedAt != null && !endedAt.isAfter(now)) { |
| | | return null; |
| | | } |
| | | } |
| | | return latestStartedSession; |
| | | } |
| | | return latestStartedSession; |
| | | if (defaultNumber(latestStartedSession.getLiveStatus()) == SESSION_STATUS_NOT_STARTED) { |
| | | if (defaultNumber(latestStartedSession.getDurationSeconds()) <= 0) { |
| | | return latestStartedSession; |
| | | } |
| | | LocalDateTime endedAt = resolveEndedAt(latestStartedSession); |
| | | // APP 展示态按计划时间实时计算,避免低频定时任务导致到点后仍显示未开始。 |
| | | if (endedAt == null || endedAt.isAfter(now)) { |
| | | return latestStartedSession; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 解析最近一场已结束场次,优先使用时间线上的最新已开始场次。 |
| | | */ |
| | | private LiveSession resolveLatestEndedSession(List<LiveSession> sessions, LiveSession latestStartedSession) { |
| | | if (latestStartedSession != null && defaultNumber(latestStartedSession.getLiveStatus()) == SESSION_STATUS_ENDED) { |
| | | private LiveSession resolveLatestEndedSession(List<LiveSession> sessions, |
| | | LiveSession latestStartedSession, |
| | | LocalDateTime now) { |
| | | if (isEndedForDisplay(latestStartedSession, now)) { |
| | | return latestStartedSession; |
| | | } |
| | | LiveSession result = null; |
| | | for (LiveSession session : sessions) { |
| | | if (defaultNumber(session.getLiveStatus()) != SESSION_STATUS_ENDED) { |
| | | if (!isEndedForDisplay(session, now)) { |
| | | continue; |
| | | } |
| | | if (result == null || compareEndedSession(result, session) < 0) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断场次在 APP 当前展示口径下是否已经结束。 |
| | | */ |
| | | private boolean isEndedForDisplay(LiveSession session, LocalDateTime now) { |
| | | if (session == null) { |
| | | return false; |
| | | } |
| | | if (defaultNumber(session.getLiveStatus()) == SESSION_STATUS_ENDED) { |
| | | return true; |
| | | } |
| | | int status = defaultNumber(session.getLiveStatus()); |
| | | if (status != SESSION_STATUS_NOT_STARTED && status != SESSION_STATUS_LIVING) { |
| | | return false; |
| | | } |
| | | if (defaultNumber(session.getDurationSeconds()) <= 0) { |
| | | return false; |
| | | } |
| | | LocalDateTime endedAt = resolveEndedAt(session); |
| | | return session.getStartTime() != null && endedAt != null && !endedAt.isAfter(now); |
| | | } |
| | | |
| | | /** |
| | | * 选择下一场待开播的场次。 |
| | | */ |
| | | private LiveSession findNextSession(List<LiveSession> sessions, LocalDateTime now) { |
| New file |
| | |
| | | package com.tcm.training.admin.app.share.controller; |
| | | |
| | | import com.tcm.training.admin.app.share.service.AppShareService; |
| | | import org.springframework.http.CacheControl; |
| | | import org.springframework.http.HttpHeaders; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.http.ResponseEntity; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | /** |
| | | * APP 分享二维码公开图片接口。 |
| | | */ |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/open/app-share", "/api/v1/open/app-share"}) |
| | | public class OpenAppShareController { |
| | | |
| | | private final AppShareService appShareService; |
| | | |
| | | /** |
| | | * 注入邀请分享服务。 |
| | | */ |
| | | public OpenAppShareController(AppShareService appShareService) { |
| | | this.appShareService = appShareService; |
| | | } |
| | | |
| | | /** |
| | | * 根据邀请码返回二维码 PNG 图片。 |
| | | */ |
| | | @GetMapping(value = "/qrcode-image", produces = MediaType.IMAGE_PNG_VALUE) |
| | | public ResponseEntity<byte[]> getQrcodeImage(@RequestParam(value = "inviteCode", required = false) String inviteCode) { |
| | | byte[] imageBytes = appShareService.generateQrcodeImage(inviteCode); |
| | | return ResponseEntity.ok() |
| | | .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"app-share-qrcode.png\"") |
| | | .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic()) |
| | | .contentType(MediaType.IMAGE_PNG) |
| | | .body(imageBytes); |
| | | } |
| | | } |
| | |
| | | import com.tcm.training.admin.system.service.AppVersionSettingsAppService; |
| | | import com.tcm.training.admin.system.vo.AppVersionCheckVO; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.util.StringUtils; |
| | | |
| | | import java.awt.image.BufferedImage; |
| | | import java.io.ByteArrayOutputStream; |
| | |
| | | result.setClinicName(trimToEmpty(clinic.getName())); |
| | | result.setInviteCode(inviteCode); |
| | | result.setShareLandingUrl(shareLandingUrl); |
| | | result.setQrCodeImageUrl(buildQrCodeImageUrl(inviteCode)); |
| | | result.setQrCodeDataUrl(buildQrCodeDataUrl(shareLandingUrl)); |
| | | result.setBoundUsers(listBoundUsers(doctorUser)); |
| | | return result; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构建二维码图片公开访问地址。 |
| | | */ |
| | | private String buildQrCodeImageUrl(String inviteCode) { |
| | | String landingBaseUrl = trimToNull(appShareProperties.getLandingBaseUrl()); |
| | | if (landingBaseUrl == null) { |
| | | throw new IllegalArgumentException("邀请下载页地址暂未配置"); |
| | | } |
| | | int schemeIndex = landingBaseUrl.indexOf("://"); |
| | | if (schemeIndex < 0) { |
| | | throw new IllegalArgumentException("邀请下载页地址格式不正确"); |
| | | } |
| | | int hostEndIndex = landingBaseUrl.indexOf('/', schemeIndex + 3); |
| | | String origin = hostEndIndex >= 0 ? landingBaseUrl.substring(0, hostEndIndex) : landingBaseUrl; |
| | | String normalizedInviteCode = trimToNull(inviteCode); |
| | | if (!StringUtils.hasText(normalizedInviteCode)) { |
| | | throw new IllegalArgumentException("邀请码不能为空"); |
| | | } |
| | | return origin + "/api/open/app-share/qrcode-image?inviteCode=" + normalizedInviteCode; |
| | | } |
| | | |
| | | /** |
| | | * 生成二维码 PNG 图片字节流。 |
| | | */ |
| | | public byte[] generateQrcodeImage(String inviteCode) { |
| | | Clinic clinic = findClinicByInviteCode(inviteCode); |
| | | if (clinic == null) { |
| | | throw new IllegalArgumentException("邀请码无效"); |
| | | } |
| | | String shareLandingUrl = buildShareLandingUrl(requireInviteCode(clinic)); |
| | | try { |
| | | BufferedImage image = QrCodeUtil.generate(shareLandingUrl, 400, 400); |
| | | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); |
| | | ImgUtil.write(image, "png", outputStream); |
| | | return outputStream.toByteArray(); |
| | | } catch (Exception ex) { |
| | | throw new IllegalArgumentException("生成分享二维码失败"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构建绑定用户展示项。 |
| | | */ |
| | | private AppShareBoundUserItemVO toBoundUserItem(AppUser user) { |
| | |
| | | |
| | | private String shareLandingUrl; |
| | | |
| | | private String qrCodeImageUrl; |
| | | |
| | | private String qrCodeDataUrl; |
| | | |
| | | private List<AppShareBoundUserItemVO> boundUsers; |
| | |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.apache.ibatis.annotations.Param; |
| | | |
| | | import java.time.LocalDateTime; |
| | | |
| | | @Mapper |
| | | public interface LiveSessionQuizRecordMapper extends BaseMapper<LiveSessionQuizRecord> { |
| | | |
| | |
| | | @Param("sessionId") Long sessionId, |
| | | @Param("keyword") String keyword, |
| | | @Param("isCorrect") Integer isCorrect); |
| | | |
| | | /** 查询用户在指定直播课答题日内是否已获得过积分。 */ |
| | | Integer countAwardedCourseQuizPoints(@Param("courseId") Long courseId, |
| | | @Param("userId") Long userId, |
| | | @Param("dayStart") LocalDateTime dayStart, |
| | | @Param("dayEnd") LocalDateTime dayEnd); |
| | | } |
| | |
| | | /** |
| | | * 扫描已到开播时间的未开始场次,并自动切换为直播中。 |
| | | */ |
| | | @Scheduled(fixedDelayString = "${app.live.session-status-sync-interval-ms:60000}") |
| | | @Scheduled(fixedDelayString = "${app.live.session-status-sync-interval-ms:5000}") |
| | | public void syncDueSessions() { |
| | | LocalDateTime now = LocalDateTime.now(); |
| | | List<LiveSession> dueSessions = liveSessionMapper.selectList(new LambdaQueryWrapper<LiveSession>() |
| | |
| | | import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent; |
| | | import com.tcm.training.admin.system.entity.ActivityNotice; |
| | | import com.tcm.training.admin.system.mapper.ActivityNoticeMapper; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.scheduling.annotation.Async; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.event.TransactionalEventListener; |
| | |
| | | */ |
| | | @Service |
| | | public class ActivityNoticeDispatchService { |
| | | |
| | | private static final Logger log = LoggerFactory.getLogger(ActivityNoticeDispatchService.class); |
| | | |
| | | private static final int USER_STATUS_ENABLED = 1; |
| | | private static final int USER_DELETED_NO = 0; |
| | |
| | | break; |
| | | } |
| | | for (AppUser user : users) { |
| | | if (user.getId() == null) { |
| | | continue; |
| | | } |
| | | boolean exists = appUserActivityNoticeMapper.selectCount(new LambdaQueryWrapper<AppUserActivityNotice>() |
| | | .eq(AppUserActivityNotice::getAppUserId, user.getId()) |
| | | .eq(AppUserActivityNotice::getActivityNoticeId, event.getActivityNoticeId()) |
| | | .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)) > 0; |
| | | if (exists) { |
| | | continue; |
| | | } |
| | | AppUserActivityNotice record = new AppUserActivityNotice(); |
| | | record.setAppUserId(user.getId()); |
| | | record.setActivityNoticeId(event.getActivityNoticeId()); |
| | | record.setDeleted(RECORD_DELETED_NO); |
| | | appUserActivityNoticeMapper.insert(record); |
| | | // 活动通知发布后同时写入统一系统通知表,并按双开关规则尝试极光推送。 |
| | | appSystemNoticeDispatchService.createAndPush(buildActivityNoticeCommand(user.getId(), activityNotice)); |
| | | distributeToUser(user, activityNotice); |
| | | } |
| | | if (page.getCurrent() >= page.getPages()) { |
| | | break; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 为单个 APP 用户分发活动通知,单用户失败不影响整批分发。 |
| | | */ |
| | | private void distributeToUser(AppUser user, ActivityNotice activityNotice) { |
| | | if (user == null || user.getId() == null || activityNotice == null || activityNotice.getId() == null) { |
| | | return; |
| | | } |
| | | try { |
| | | boolean exists = appUserActivityNoticeMapper.selectCount(new LambdaQueryWrapper<AppUserActivityNotice>() |
| | | .eq(AppUserActivityNotice::getAppUserId, user.getId()) |
| | | .eq(AppUserActivityNotice::getActivityNoticeId, activityNotice.getId()) |
| | | .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)) > 0; |
| | | if (!exists) { |
| | | AppUserActivityNotice record = new AppUserActivityNotice(); |
| | | record.setAppUserId(user.getId()); |
| | | record.setActivityNoticeId(activityNotice.getId()); |
| | | record.setDeleted(RECORD_DELETED_NO); |
| | | appUserActivityNoticeMapper.insert(record); |
| | | } |
| | | // 活动通知发布后同时写入统一系统通知表,并按双开关规则尝试极光推送。 |
| | | appSystemNoticeDispatchService.createAndPush(buildActivityNoticeCommand(user.getId(), activityNotice)); |
| | | } catch (Exception ex) { |
| | | log.error("activity notice distribute failed, activityNoticeId={}, appUserId={}", |
| | | activityNotice.getId(), user.getId(), ex); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构建活动通知统一发放命令。 |
| | | */ |
| | | private AppSystemNoticeDispatchCommand buildActivityNoticeCommand(Long appUserId, ActivityNotice activityNotice) { |
| | |
| | | code: "" |
| | | wechat: |
| | | # 微信开放平台 AppId,当前阶段可先留空,补齐后启用服务端换票模式 |
| | | app-id: "" |
| | | app-id: "wx074c1f0cf5a0ecf4" |
| | | # 微信开放平台 AppSecret,当前阶段可先留空,补齐后启用服务端换票模式 |
| | | app-secret: "" |
| | | app-secret: "7a294b47a3a2f4b69cba764b0da17dbc" |
| | | # 微信用户信息接口地址 |
| | | user-info-url: https://api.weixin.qq.com/sns/userinfo |
| | | # 微信授权换票接口地址 |
| | |
| | | # 邀请二维码默认指向线上下载页,开发环境可在 application-local.yml 覆盖。 |
| | | landing-base-url: https://haizhentong.com/app-download/ |
| | | jpush: |
| | | appKey: 111 |
| | | masterSecret: 111 |
| | | appKey: d2c5362f73af203f25704b8d |
| | | masterSecret: c6996db56be1a93ae16fc1b0 |
| | | |
| | | logging: |
| | | level: |
| | |
| | | ORDER BY r.id DESC |
| | | </select> |
| | | |
| | | <!-- 查询同一用户在同一直播课同一答题自然日是否已获得过积分 --> |
| | | <select id="countAwardedCourseQuizPoints" resultType="java.lang.Integer"> |
| | | SELECT COUNT(1) |
| | | FROM tcm_live_session_quiz_record r |
| | | INNER JOIN tcm_live_session s ON s.id = r.session_id AND s.deleted = 0 |
| | | WHERE s.live_course_id = #{courseId} |
| | | AND r.app_user_id = #{userId} |
| | | AND r.points_earned > 0 |
| | | AND r.answered_at <![CDATA[>=]]> #{dayStart} |
| | | AND r.answered_at <![CDATA[<]]> #{dayEnd} |
| | | </select> |
| | | |
| | | </mapper> |
| | |
| | | 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.live.dto.AppLiveQuizAnswerRequest; |
| | | import com.tcm.training.admin.app.live.dto.AppLiveQuizSubmitRequest; |
| | | import com.tcm.training.admin.app.live.support.AppLiveCourseDisplaySupport; |
| | | import com.tcm.training.admin.app.live.vo.AppLiveCourseDetailVO; |
| | | import com.tcm.training.admin.app.live.vo.AppLiveQuizSubmitResultVO; |
| | | import com.tcm.training.admin.app.live.vo.AppLiveReserveResultVO; |
| | | import com.tcm.training.admin.live.entity.Expert; |
| | | import com.tcm.training.admin.live.entity.LiveCourse; |
| | | import com.tcm.training.admin.live.entity.LiveReserve; |
| | | import com.tcm.training.admin.live.entity.LiveSession; |
| | | import com.tcm.training.admin.live.entity.LiveSessionQuestion; |
| | | import com.tcm.training.admin.live.entity.LiveSessionQuizRecord; |
| | | import com.tcm.training.admin.live.mapper.ExpertMapper; |
| | | import com.tcm.training.admin.live.mapper.LiveCourseMapper; |
| | | import com.tcm.training.admin.live.mapper.LiveReserveMapper; |
| | |
| | | import java.time.LocalDateTime; |
| | | import java.util.Arrays; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.ArgumentMatchers.anyLong; |
| | | import static org.mockito.Mockito.lenient; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | |
| | | assertEquals("立即查看", detailVO.getActionText()); |
| | | assertEquals(Long.valueOf(201L), detailVO.getActionSessionId()); |
| | | assertNull(detailVO.getCountdownTime()); |
| | | assertEquals("https://cdn.example/live.m3u8", detailVO.getVideoUrl()); |
| | | assertNull(detailVO.getVideoUrl()); |
| | | } |
| | | |
| | | /** |
| | |
| | | assertEquals("ENDED", detailVO.getDisplayStatus()); |
| | | assertEquals("WATCHABLE", detailVO.getActionStatus()); |
| | | assertEquals(Long.valueOf(211L), detailVO.getActionSessionId()); |
| | | assertEquals("https://cdn.example/latest-ended.m3u8", detailVO.getVideoUrl()); |
| | | assertNull(detailVO.getVideoUrl()); |
| | | } |
| | | |
| | | /** |
| | | * 同一直播课同一答题自然日已经获得过积分时,后续场次答对也不重复发放。 |
| | | */ |
| | | @Test |
| | | void submitQuizShouldNotAwardPointsTwiceForSameCourseSameDay() { |
| | | LiveCourse course = buildCourse(50L, 12L); |
| | | LiveSession session = buildSession(501L, 50L, LocalDateTime.now().minusMinutes(30), |
| | | AppLiveCourseDisplaySupport.SESSION_STATUS_LIVING); |
| | | List<LiveSessionQuestion> questions = Arrays.asList( |
| | | buildQuestion(1L, 501L, 1, "A"), |
| | | buildQuestion(2L, 501L, 2, "B"), |
| | | buildQuestion(3L, 501L, 3, "C") |
| | | ); |
| | | |
| | | when(appAuthSessionService.requireLoginUserId()).thenReturn(77L); |
| | | when(liveCourseMapper.selectOne(any())).thenReturn(course); |
| | | when(liveSessionMapper.selectOne(any())).thenReturn(session); |
| | | when(liveSessionQuestionMapper.selectList(any())).thenReturn(questions); |
| | | when(liveSessionQuizRecordMapper.selectList(any())).thenReturn(Collections.emptyList()); |
| | | when(liveSessionQuizRecordMapper.countAwardedCourseQuizPoints(anyLong(), anyLong(), any(), any())).thenReturn(1); |
| | | |
| | | AppLiveQuizSubmitResultVO resultVO = appLiveCourseService.submitQuiz(50L, 501L, buildQuizSubmitRequest()); |
| | | |
| | | verify(liveSessionQuizRecordMapper, never()).updateById(any(LiveSessionQuizRecord.class)); |
| | | verify(userPointsAccountMapper, never()).update(any(), any()); |
| | | verify(pointsLedgerAppService, never()).syncLedger(); |
| | | assertEquals(Boolean.TRUE, resultVO.getSubmitted()); |
| | | assertEquals(Boolean.TRUE, resultVO.getAllCorrect()); |
| | | assertEquals(Boolean.FALSE, resultVO.getPointsAwarded()); |
| | | assertEquals("今日该课程积分已获得", resultVO.getMessage()); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构造直播答题题目测试数据。 |
| | | */ |
| | | private LiveSessionQuestion buildQuestion(Long questionId, Long sessionId, Integer sort, String correctOption) { |
| | | LiveSessionQuestion question = new LiveSessionQuestion(); |
| | | question.setId(questionId); |
| | | question.setSessionId(sessionId); |
| | | question.setSort(sort); |
| | | question.setStem("题目" + sort); |
| | | question.setOptionA("选项A"); |
| | | question.setOptionB("选项B"); |
| | | question.setOptionC("选项C"); |
| | | question.setCorrectOption(correctOption); |
| | | return question; |
| | | } |
| | | |
| | | /** |
| | | * 构造三题全对的答题提交请求。 |
| | | */ |
| | | private AppLiveQuizSubmitRequest buildQuizSubmitRequest() { |
| | | AppLiveQuizSubmitRequest request = new AppLiveQuizSubmitRequest(); |
| | | request.setAnswers(Arrays.asList( |
| | | buildQuizAnswer(1L, "A"), |
| | | buildQuizAnswer(2L, "B"), |
| | | buildQuizAnswer(3L, "C") |
| | | )); |
| | | return request; |
| | | } |
| | | |
| | | /** |
| | | * 构造单题答题请求。 |
| | | */ |
| | | private AppLiveQuizAnswerRequest buildQuizAnswer(Long questionId, String chosenOption) { |
| | | AppLiveQuizAnswerRequest answer = new AppLiveQuizAnswerRequest(); |
| | | answer.setQuestionId(questionId); |
| | | answer.setChosenOption(chosenOption); |
| | | return answer; |
| | | } |
| | | |
| | | /** |
| | | * 构造启用中的专家测试数据。 |
| | | */ |
| | | private Expert buildExpert(Long expertId) { |
| | |
| | | import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
| | | import com.tcm.training.admin.system.dto.ActivityNoticeCreateRequest; |
| | | import com.tcm.training.admin.system.entity.ActivityNotice; |
| | | import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent; |
| | | import com.tcm.training.admin.system.mapper.ActivityNoticeMapper; |
| | | import com.tcm.training.admin.system.vo.ActivityNoticeListItemVO; |
| | | import org.apache.ibatis.builder.MapperBuilderAssistant; |
| | | import org.springframework.context.ApplicationEventPublisher; |
| | | import org.junit.jupiter.api.BeforeAll; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | |
| | | @Mock |
| | | private ActivityNoticeMapper activityNoticeMapper; |
| | | |
| | | @Mock |
| | | private ActivityNoticeDispatchService activityNoticeDispatchService; |
| | | |
| | | @Mock |
| | | private ApplicationEventPublisher applicationEventPublisher; |
| | | |
| | | @InjectMocks |
| | | private ActivityNoticeAppService activityNoticeAppService; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<ActivityNotice> activityNoticeCaptor; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<ActivityNoticeCreatedEvent> eventCaptor; |
| | | |
| | | @Test |
| | | void createShouldTrimTitleAndPersistContent() { |
| | |
| | | ActivityNoticeListItemVO result = activityNoticeAppService.create(request); |
| | | |
| | | verify(activityNoticeMapper).insert(activityNoticeCaptor.capture()); |
| | | verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); |
| | | assertEquals("春季活动", activityNoticeCaptor.getValue().getTitle()); |
| | | assertEquals("<p>活动内容</p>", activityNoticeCaptor.getValue().getContentHtml()); |
| | | assertEquals(Long.valueOf(1L), eventCaptor.getValue().getActivityNoticeId()); |
| | | assertEquals("春季活动", result.getTitle()); |
| | | } |
| | | |
| New file |
| | |
| | | package com.tcm.training.admin.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.MybatisConfiguration; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
| | | import com.tcm.training.admin.app.notice.entity.AppUserActivityNotice; |
| | | import com.tcm.training.admin.app.notice.mapper.AppUserActivityNoticeMapper; |
| | | import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand; |
| | | import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService; |
| | | import com.tcm.training.admin.app.notice.service.AppSystemNoticeType; |
| | | import com.tcm.training.admin.member.entity.AppUser; |
| | | import com.tcm.training.admin.member.mapper.UserMapper; |
| | | import com.tcm.training.admin.system.entity.ActivityNotice; |
| | | import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent; |
| | | import com.tcm.training.admin.system.mapper.ActivityNoticeMapper; |
| | | import org.apache.ibatis.builder.MapperBuilderAssistant; |
| | | import org.junit.jupiter.api.BeforeAll; |
| | | 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 java.util.Arrays; |
| | | import java.util.Collections; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.doThrow; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | /** |
| | | * 活动通知分发服务测试。 |
| | | */ |
| | | @ExtendWith(MockitoExtension.class) |
| | | class ActivityNoticeDispatchServiceTest { |
| | | |
| | | @BeforeAll |
| | | static void initLambdaCache() { |
| | | MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); |
| | | TableInfoHelper.initTableInfo(assistant, ActivityNotice.class); |
| | | TableInfoHelper.initTableInfo(assistant, AppUser.class); |
| | | TableInfoHelper.initTableInfo(assistant, AppUserActivityNotice.class); |
| | | } |
| | | |
| | | @Mock |
| | | private UserMapper userMapper; |
| | | |
| | | @Mock |
| | | private AppUserActivityNoticeMapper appUserActivityNoticeMapper; |
| | | |
| | | @Mock |
| | | private ActivityNoticeMapper activityNoticeMapper; |
| | | |
| | | @Mock |
| | | private AppSystemNoticeDispatchService appSystemNoticeDispatchService; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<AppUserActivityNotice> activityRecordCaptor; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<AppSystemNoticeDispatchCommand> commandCaptor; |
| | | |
| | | /** |
| | | * 活动通知创建后应为启用用户写入 APP 活动记录和统一系统通知。 |
| | | */ |
| | | @Test |
| | | void handleActivityNoticeCreatedShouldDispatchSystemNoticeToEnabledUsers() { |
| | | ActivityNoticeDispatchService service = buildService(); |
| | | ActivityNotice notice = buildNotice(10L); |
| | | AppUser user = buildUser(100L); |
| | | when(activityNoticeMapper.selectOne(any())).thenReturn(notice); |
| | | when(userMapper.selectPage(any(), any())).thenReturn(buildUserPage(user)); |
| | | when(appUserActivityNoticeMapper.selectCount(any())).thenReturn(0L); |
| | | |
| | | service.handleActivityNoticeCreated(new ActivityNoticeCreatedEvent(10L)); |
| | | |
| | | verify(appUserActivityNoticeMapper).insert(activityRecordCaptor.capture()); |
| | | assertEquals(Long.valueOf(100L), activityRecordCaptor.getValue().getAppUserId()); |
| | | assertEquals(Long.valueOf(10L), activityRecordCaptor.getValue().getActivityNoticeId()); |
| | | verify(appSystemNoticeDispatchService).createAndPush(commandCaptor.capture()); |
| | | assertEquals(Long.valueOf(100L), commandCaptor.getValue().getAppUserId()); |
| | | assertEquals(AppSystemNoticeType.ACTIVITY_NOTICE, commandCaptor.getValue().getNoticeTypeCode()); |
| | | assertEquals("ACTIVITY_NOTICE", commandCaptor.getValue().getReferenceType()); |
| | | assertEquals(Long.valueOf(10L), commandCaptor.getValue().getReferenceId()); |
| | | } |
| | | |
| | | /** |
| | | * 单个用户分发失败时不应中断后续用户分发。 |
| | | */ |
| | | @Test |
| | | void handleActivityNoticeCreatedShouldContinueWhenSingleUserDispatchFails() { |
| | | ActivityNoticeDispatchService service = buildService(); |
| | | ActivityNotice notice = buildNotice(20L); |
| | | AppUser firstUser = buildUser(201L); |
| | | AppUser secondUser = buildUser(202L); |
| | | when(activityNoticeMapper.selectOne(any())).thenReturn(notice); |
| | | when(userMapper.selectPage(any(), any())).thenReturn(buildUserPage(firstUser, secondUser)); |
| | | when(appUserActivityNoticeMapper.selectCount(any())).thenReturn(0L); |
| | | doThrow(new IllegalStateException("推送异常")) |
| | | .doReturn(null) |
| | | .when(appSystemNoticeDispatchService).createAndPush(any(AppSystemNoticeDispatchCommand.class)); |
| | | |
| | | service.handleActivityNoticeCreated(new ActivityNoticeCreatedEvent(20L)); |
| | | |
| | | verify(appSystemNoticeDispatchService, org.mockito.Mockito.times(2)).createAndPush(commandCaptor.capture()); |
| | | assertEquals(Long.valueOf(201L), commandCaptor.getAllValues().get(0).getAppUserId()); |
| | | assertEquals(Long.valueOf(202L), commandCaptor.getAllValues().get(1).getAppUserId()); |
| | | } |
| | | |
| | | /** |
| | | * 构造被测服务。 |
| | | */ |
| | | private ActivityNoticeDispatchService buildService() { |
| | | return new ActivityNoticeDispatchService( |
| | | userMapper, |
| | | appUserActivityNoticeMapper, |
| | | activityNoticeMapper, |
| | | appSystemNoticeDispatchService |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 构造活动通知主表记录。 |
| | | */ |
| | | private ActivityNotice buildNotice(Long id) { |
| | | ActivityNotice notice = new ActivityNotice(); |
| | | notice.setId(id); |
| | | notice.setTitle("活动通知"); |
| | | notice.setContentHtml("<p>活动内容</p>"); |
| | | notice.setDeleted(0); |
| | | return notice; |
| | | } |
| | | |
| | | /** |
| | | * 构造启用 APP 用户。 |
| | | */ |
| | | private AppUser buildUser(Long id) { |
| | | AppUser user = new AppUser(); |
| | | user.setId(id); |
| | | user.setStatus(1); |
| | | user.setDeleted(0); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 构造用户分页结果。 |
| | | */ |
| | | private Page<AppUser> buildUserPage(AppUser... users) { |
| | | Page<AppUser> page = new Page<AppUser>(1, 500); |
| | | page.setRecords(users == null ? Collections.<AppUser>emptyList() : Arrays.asList(users)); |
| | | page.setTotal(users == null ? 0 : users.length); |
| | | return page; |
| | | } |
| | | } |
| | |
| | | import { |
| | | onLaunch |
| | | } from '@dcloudio/uni-app' |
| | | import { cacheAppInviteCode, normalizeOptionalText } from '@/utils/utils.js' |
| | | import { cacheAppInviteCode, extractInviteCode } from '@/utils/utils.js' |
| | | |
| | | /** |
| | | * 应用启动时尝试读取系统剪贴板中的邀请码。 |
| | |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 从任意文本中提取 6 位邀请码。 |
| | | */ |
| | | function extractInviteCode(value) { |
| | | const normalizedValue = normalizeOptionalText(value).toUpperCase() |
| | | const matchedInviteCode = normalizedValue.match(/[A-Z0-9]{6}/) |
| | | return matchedInviteCode ? matchedInviteCode[0] : '' |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss"> |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/downloadPlayer/downloadPlayer", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/point/point", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | |
| | | "iconPath": "static/image/tabBar/home.png", |
| | | "selectedIconPath": "static/image/tabBar/Yhome.png", |
| | | "text": "首页" |
| | | }, |
| | | { |
| | | "pagePath": "pages/medicalHall/medicalHall", |
| | | "iconPath": "static/image/tabBar/good.png", |
| | | "selectedIconPath": "static/image/tabBar/good.png", |
| | | "text": "商城" |
| | | }, |
| | | { |
| | | "pagePath": "pages/healthCourses/healthCourses", |
| | |
| | | <text v-for="tag in displayTags" :key="tag" class="tag">{{ tag }}</text> |
| | | </view> |
| | | |
| | | <view class="price-stats-box"> |
| | | <view class="price-box"> |
| | | <text class="price-symbol">¥</text> |
| | | <text class="price-integer">{{ priceParts.integer }}</text> |
| | | <text class="price-decimal">.{{ priceParts.decimal }}</text> |
| | | </view> |
| | | <view class="price-stats-box price-stats-box--plain"> |
| | | <view class="stats-box"> |
| | | <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image> |
| | | <text class="stats-text">{{ formatLearningCount(courseDetail.learnUserCount) }}人正在学</text> |
| | |
| | | </view> |
| | | |
| | | <view class="desc-text">{{ courseDetail.summary || '暂无课程介绍' }}</view> |
| | | |
| | | <!-- 会员横幅 --> |
| | | <view class="vip-banner" @tap="goToVipPage"> |
| | | <image class="vip-bg" src="/static/image/healthCourses/openVip.png" mode="aspectFill"></image> |
| | | <view class="vip-content"> |
| | | <view class="vip-left"> |
| | | <view v-if="memberMinPriceParts" class="vip-price-box"> |
| | | <text class="vip-price-symbol">¥</text> |
| | | <text class="vip-price-num">{{ memberMinPriceParts.integer }}</text> |
| | | <text class="vip-price-decimal">.{{ memberMinPriceParts.decimal }}</text> |
| | | <text class="vip-price-suffix">起</text> |
| | | </view> |
| | | <text v-else class="vip-price-empty">暂未开放</text> |
| | | <text class="vip-desc">开通会员尊享养生课程</text> |
| | | </view> |
| | | <view class="vip-btn">{{ vipActionText }}</view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 课程列表模块 (也在课程tab中展示) --> |
| | |
| | | :class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }" |
| | | @tap.stop="handlePaidVideoTap(item)" |
| | | > |
| | | {{ item.purchased || courseDetail.courseOwned ? '已购买' : `¥${formatPrice(item.salePrice)}` }} |
| | | {{ item.purchased || courseDetail.courseOwned ? '已领取' : '领取' }} |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | :class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }" |
| | | @tap.stop="handlePaidVideoTap(item)" |
| | | > |
| | | {{ item.purchased || courseDetail.courseOwned ? '已购买' : `¥${formatPrice(item.salePrice)}` }} |
| | | {{ item.purchased || courseDetail.courseOwned ? '已领取' : '领取' }} |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | <text class="popup-title">支付</text> |
| | | </view> |
| | | |
| | | <view class="amount-box"> |
| | | <text class="amount-symbol">¥</text> |
| | | <text class="amount-value">{{ paymentAmount }}</text> |
| | | </view> |
| | | |
| | | <view class="payment-methods"> |
| | | <text class="method-title">选择支付方式</text> |
| | | |
| | |
| | | |
| | | <view class="popup-footer"> |
| | | <view class="pay-btn" :class="{ 'pay-btn--disabled': purchaseSubmitting }" @tap="submitPurchase"> |
| | | {{ purchaseSubmitting ? '支付中...' : '立即支付' }} |
| | | {{ purchaseSubmitting ? '领取中...' : '领取' }} |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | |
| | | <script setup lang="ts"> |
| | | import { computed, getCurrentInstance, nextTick, ref } from 'vue' |
| | | import { onLoad } from '@dcloudio/uni-app' |
| | | import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app' |
| | | import { |
| | | favoriteHealthCourse, |
| | | getHealthCourseDetail, |
| | | getMemberOpenPage, |
| | | memberJoinHealthCourse, |
| | | purchaseHealthCourse, |
| | | purchaseHealthCourseVideo, |
| | | reportHealthCourseDownload, |
| | | unfavoriteHealthCourse, |
| | | } from './service' |
| | | import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.js' |
| | | import { startCourseVideoDownload, hasCourseDownloadTask } from '@/utils/course-download.js' |
| | | import { resolveCurrentAppUserId } from '@/utils/app-user.js' |
| | | import { |
| | | bindVideoCaptureListener, |
| | | buildVideoWatermarkText, |
| | | disableVideoSecurityProtection, |
| | | enableVideoSecurityProtection, |
| | | isIosApp, |
| | | unbindVideoCaptureListener, |
| | | } from '@/utils/video-security.js' |
| | | |
| | | interface CourseExpert { |
| | | id?: number | null |
| | |
| | | purchased: boolean |
| | | locked: boolean |
| | | videoUrl?: string |
| | | } |
| | | |
| | | interface LocalDownloadTask { |
| | | clientTaskId: string |
| | | courseId: number |
| | | courseVideoId: number |
| | | title: string |
| | | subtitle: string |
| | | coverImage: string |
| | | durationSeconds: number |
| | | durationText: string |
| | | videoUrl: string |
| | | progress: number |
| | | status: 'downloading' |
| | | updatedAt: string |
| | | } |
| | | |
| | | interface CourseDetail { |
| | |
| | | coursePurchased: boolean |
| | | } |
| | | |
| | | interface MemberOpenPackage { |
| | | type?: string |
| | | title?: string |
| | | price?: number | string | null |
| | | days?: number | null |
| | | } |
| | | |
| | | interface MemberOpenPageData { |
| | | minPrice: number | string | null |
| | | packages: MemberOpenPackage[] |
| | | } |
| | | |
| | | interface SwiperChangeEvent { |
| | | detail?: { |
| | | current?: number |
| | |
| | | const currentTab = ref('course') |
| | | const currentSwiperIndex = ref(0) |
| | | const loading = ref(false) |
| | | const watermarkTick = ref(Date.now()) |
| | | const favoriteSubmitting = ref(false) |
| | | const purchaseSubmitting = ref(false) |
| | | const memberJoinSubmitting = ref(false) |
| | |
| | | const activePurchaseVideoId = ref<number | null>(null) |
| | | const courseId = ref('') |
| | | const courseDetail = ref<CourseDetail>(createDefaultCourseDetail()) |
| | | const memberOpenPage = ref<MemberOpenPageData>(createDefaultMemberOpenPage()) |
| | | const activeVideoId = ref<number>(0) |
| | | const showVideoPlayer = ref(false) |
| | | const isVideoReady = ref(false) |
| | |
| | | const playbackFailed = ref(false) |
| | | const instance = getCurrentInstance() |
| | | let videoContext: ReturnType<typeof uni.createVideoContext> | null = null |
| | | let videoSecurityTimer: ReturnType<typeof setInterval> | null = null |
| | | |
| | | /** |
| | | * 创建课程详情页默认数据,避免接口返回前页面闪空。 |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 创建会员开通页摘要默认数据,避免课程详情页价格区域闪烁。 |
| | | */ |
| | | function createDefaultMemberOpenPage(): MemberOpenPageData { |
| | | return { |
| | | minPrice: null, |
| | | packages: [], |
| | | } |
| | | } |
| | | |
| | | const swiperList = computed(() => { |
| | | if (courseDetail.value.bannerImages.length > 0) { |
| | | return courseDetail.value.bannerImages |
| | |
| | | return allVideos.value.filter((item) => !item.freeAccess) |
| | | }) |
| | | |
| | | const formattedPrice = computed(() => formatPrice(courseDetail.value.salePrice)) |
| | | const hasMemberMinPrice = computed(() => { |
| | | const normalizedPrice = Number(memberOpenPage.value.minPrice) |
| | | return Number.isFinite(normalizedPrice) && normalizedPrice > 0 |
| | | }) |
| | | const memberMinPriceText = computed(() => { |
| | | if (!hasMemberMinPrice.value) { |
| | | return '' |
| | | } |
| | | return formatPrice(memberOpenPage.value.minPrice || 0) |
| | | }) |
| | | const memberMinPriceParts = computed(() => { |
| | | if (!hasMemberMinPrice.value) { |
| | | return null |
| | | } |
| | | const [integer, decimal] = memberMinPriceText.value.split('.') |
| | | return { |
| | | integer: integer || '0', |
| | | decimal: decimal || '00', |
| | | } |
| | | }) |
| | | const vipActionText = computed(() => { |
| | | return courseDetail.value.isMember ? '立即续费' : '立即开通' |
| | | }) |
| | | const courseBuyButtonText = computed(() => { |
| | | if (courseDetail.value.courseOwned) { |
| | | return '已购买' |
| | | return '已领取' |
| | | } |
| | | if (courseDetail.value.isMember) { |
| | | return '加入我的课程' |
| | | } |
| | | return '立即购买' |
| | | return '领取' |
| | | }) |
| | | const favoriteIcon = computed(() => { |
| | | return courseDetail.value.favorited |
| | | ? '/static/image/healthCourses/loved.png' |
| | | : '/static/image/healthCourses/love.png' |
| | | }) |
| | | const paymentAmount = computed(() => { |
| | | if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value !== null) { |
| | | const matchedVideo = allVideos.value.find((item) => item.id === activePurchaseVideoId.value) |
| | | return formatPrice(matchedVideo?.salePrice) |
| | | } |
| | | return formatPrice(courseDetail.value.salePrice) |
| | | }) |
| | | const currentVideo = computed(() => { |
| | | return allVideos.value.find((item) => item.id === activeVideoId.value) || null |
| | |
| | | const currentVideoUrl = computed(() => currentVideo.value?.videoUrl || '') |
| | | const currentVideoPoster = computed(() => courseDetail.value.bannerImages[0] || '/static/image/home/class.png') |
| | | const videoWatermarkText = computed(() => { |
| | | const currentUserId = resolveCurrentUserId() |
| | | const currentUserId = resolveCurrentAppUserId() |
| | | if (!currentUserId) { |
| | | return '' |
| | | } |
| | | return `用户ID:${currentUserId}` |
| | | return buildVideoWatermarkText(currentUserId, watermarkTick.value) |
| | | }) |
| | | const showPlaybackRetry = computed(() => { |
| | | if (!showVideoPlayer.value || !currentVideoUrl.value || !isVideoReady.value) { |
| | | return false |
| | | } |
| | | return playbackFailed.value || (isH5 && !isPlaybackStarted.value) |
| | | }) |
| | | |
| | | const priceParts = computed(() => { |
| | | const [integer, decimal] = formattedPrice.value.split('.') |
| | | return { |
| | | integer: integer || '0', |
| | | decimal: decimal || '00', |
| | | } |
| | | }) |
| | | |
| | | const courseContentNodes = computed(() => buildRichTextNodes(courseDetail.value.content, '暂无课程详情')) |
| | |
| | | onLoad((options) => { |
| | | courseId.value = typeof options?.id === 'string' ? options.id : '' |
| | | loadCourseDetail() |
| | | loadMemberOpenPage() |
| | | }) |
| | | |
| | | /** |
| | | * 页面显示时开启播放器安全能力。 |
| | | */ |
| | | onShow(() => { |
| | | enableVideoSecurityProtection() |
| | | bindVideoCaptureListener(handleVideoCapture) |
| | | startVideoWatermarkTicker() |
| | | }) |
| | | |
| | | /** |
| | | * 页面隐藏时关闭播放器安全能力。 |
| | | */ |
| | | onHide(() => { |
| | | stopVideoWatermarkTicker() |
| | | unbindVideoCaptureListener(handleVideoCapture) |
| | | disableVideoSecurityProtection() |
| | | }) |
| | | |
| | | /** |
| | | * 页面销毁时释放播放器安全能力。 |
| | | */ |
| | | onUnload(() => { |
| | | stopVideoWatermarkTicker() |
| | | unbindVideoCaptureListener(handleVideoCapture) |
| | | disableVideoSecurityProtection() |
| | | }) |
| | | |
| | | /** |
| | |
| | | showLoadFailedToast() |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 拉取会员开通页摘要数据,供课程详情横幅展示最低启用价格。 |
| | | */ |
| | | async function loadMemberOpenPage() { |
| | | try { |
| | | const response = await getMemberOpenPage() |
| | | if (response?.code !== 0 || !response.data) { |
| | | memberOpenPage.value = createDefaultMemberOpenPage() |
| | | return |
| | | } |
| | | memberOpenPage.value = { |
| | | minPrice: response.data.minPrice ?? null, |
| | | packages: Array.isArray(response.data.packages) |
| | | ? response.data.packages.map((item) => ({ |
| | | ...item, |
| | | type: typeof item?.type === 'string' ? item.type.trim() : '', |
| | | title: typeof item?.title === 'string' ? item.title.trim() : '', |
| | | days: Number.isFinite(Number(item?.days)) ? Number(item?.days) : null, |
| | | price: item?.price ?? null, |
| | | })) |
| | | : [], |
| | | } |
| | | } catch { |
| | | memberOpenPage.value = createDefaultMemberOpenPage() |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 收到截图时给出安全提示。 |
| | | */ |
| | | function handleVideoCapture() { |
| | | uni.showToast({ |
| | | title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 刷新播放器水印时间片段。 |
| | | */ |
| | | function startVideoWatermarkTicker() { |
| | | if (videoSecurityTimer) { |
| | | return |
| | | } |
| | | videoSecurityTimer = setInterval(() => { |
| | | watermarkTick.value = Date.now() |
| | | }, 60000) |
| | | } |
| | | |
| | | /** |
| | | * 关闭播放器水印刷新定时器。 |
| | | */ |
| | | function stopVideoWatermarkTicker() { |
| | | if (!videoSecurityTimer) { |
| | | return |
| | | } |
| | | clearInterval(videoSecurityTimer) |
| | | videoSecurityTimer = null |
| | | } |
| | | |
| | | /** |
| | | * 跳转专家介绍页,并携带专家主键加载真实内容。 |
| | | */ |
| | | function goToExpertDetail() { |
| | |
| | | } |
| | | uni.navigateTo({ |
| | | url: `/pages/protocol/protocol?type=expert&id=${courseDetail.value.expert.id}` |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转会员开通页,供课程详情中的会员入口复用。 |
| | | */ |
| | | function goToVipPage() { |
| | | uni.navigateTo({ |
| | | url: '/pages/vip/vip', |
| | | }) |
| | | } |
| | | |
| | |
| | | * 读取当前缓存中的 APP 用户标识,供本地下载任务按账号隔离。 |
| | | */ |
| | | function resolveCurrentUserId() { |
| | | const cachedUser = uni.getStorageSync('appUserInfo') |
| | | if (!cachedUser || typeof cachedUser !== 'object') { |
| | | return 0 |
| | | } |
| | | const userId = Number((cachedUser as { id?: number | string }).id || 0) |
| | | if (!Number.isFinite(userId) || userId <= 0) { |
| | | return 0 |
| | | } |
| | | return userId |
| | | return resolveCurrentAppUserId() |
| | | } |
| | | |
| | | /** |
| | | * 判断指定章节是否已存在下载中任务,避免重复触发原生下载。 |
| | | */ |
| | | function hasActiveDownloadTask(userId: number, courseVideoId: number) { |
| | | return listDownloadTasks(userId).some((task) => Number(task.courseVideoId) === Number(courseVideoId)) |
| | | } |
| | | |
| | | /** |
| | | * 构建课程下载任务唯一标识。 |
| | | */ |
| | | function buildDownloadTaskId(courseVideoId: number) { |
| | | return `health-course-video-${courseVideoId}` |
| | | } |
| | | |
| | | /** |
| | | * 组装下载中任务的本地快照,供“我的下载”页面直接复用展示。 |
| | | */ |
| | | function createDownloadTaskPayload(clientTaskId: string, item: CourseVideo): LocalDownloadTask { |
| | | return { |
| | | clientTaskId, |
| | | courseId: Number(courseId.value || 0), |
| | | courseVideoId: Number(item.id || 0), |
| | | title: item.chapterName || '课程章节', |
| | | subtitle: courseDetail.value.title || '养生课程', |
| | | coverImage: swiperList.value[0] || '/static/image/home/class.png', |
| | | durationSeconds: Number(item.durationSeconds || 0), |
| | | durationText: item.durationText || '00:00', |
| | | videoUrl: item.videoUrl || '', |
| | | progress: 0, |
| | | status: 'downloading', |
| | | updatedAt: new Date().toISOString(), |
| | | } |
| | | return userId > 0 && hasCourseDownloadTask(courseVideoId) |
| | | } |
| | | |
| | | /** |
| | |
| | | if (!shouldShowVideoDownload(item)) { |
| | | return |
| | | } |
| | | // #ifdef H5 |
| | | window.open(item.videoUrl, '_blank') |
| | | uni.showToast({ |
| | | title: '已打开下载地址', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | // #endif |
| | | const currentUserId = resolveCurrentUserId() |
| | | if (!currentUserId) { |
| | | uni.showToast({ |
| | |
| | | } |
| | | if (hasActiveDownloadTask(currentUserId, item.id)) { |
| | | uni.showToast({ |
| | | title: '当前章节正在下载中', |
| | | title: '当前章节已在本地下载列表中', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | const downloadTaskId = buildDownloadTaskId(item.id) |
| | | const initialTask = createDownloadTaskPayload(downloadTaskId, item) |
| | | upsertDownloadTask(currentUserId, initialTask) |
| | | uni.showLoading({ |
| | | title: '下载中...', |
| | | mask: true, |
| | | }) |
| | | const downloadTask = uni.downloadFile({ |
| | | url: item.videoUrl || '', |
| | | success: (downloadResult) => { |
| | | if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | uni.showToast({ |
| | | title: '下载失败', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | startCourseVideoDownload({ |
| | | courseId: Number(courseId.value || 0), |
| | | courseVideoId: item.id, |
| | | title: item.chapterName || '课程章节', |
| | | subtitle: courseDetail.value.title || '养生课程', |
| | | coverImage: swiperList.value[0] || '/static/image/home/class.png', |
| | | durationSeconds: Number(item.durationSeconds || 0), |
| | | durationText: item.durationText || '00:00', |
| | | videoUrl: item.videoUrl || '', |
| | | reportDownload: async () => { |
| | | const response = await reportHealthCourseDownload({ |
| | | courseId: Number(courseId.value), |
| | | courseVideoId: item.id, |
| | | }) |
| | | if (response?.code !== 0) { |
| | | throw new Error('report failed') |
| | | } |
| | | // 先持久化保存,避免临时文件被系统回收后用户无法继续使用。 |
| | | uni.saveFile({ |
| | | tempFilePath: downloadResult.tempFilePath, |
| | | success: async () => { |
| | | // 文件已持久化后再移除下载中状态,并把完成记录同步给后端列表。 |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | try { |
| | | const response = await reportHealthCourseDownload({ |
| | | courseId: Number(courseId.value), |
| | | courseVideoId: item.id, |
| | | }) |
| | | if (response?.code !== 0) { |
| | | return |
| | | } |
| | | uni.showToast({ |
| | | title: '下载完成', |
| | | icon: 'none', |
| | | }) |
| | | } catch { |
| | | uni.showToast({ |
| | | title: '下载完成,记录同步失败', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | }, |
| | | fail: () => { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | uni.showToast({ |
| | | title: '文件已下载到临时目录', |
| | | icon: 'none', |
| | | }) |
| | | }, |
| | | }) |
| | | }, |
| | | fail: () => { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | uni.showToast({ |
| | | title: '下载失败', |
| | | icon: 'none', |
| | | }) |
| | | }, |
| | | complete: () => { |
| | | uni.hideLoading() |
| | | }, |
| | | }) |
| | | // 下载中的进度仅在当前设备维护,避免把临时任务状态同步到服务端。 |
| | | downloadTask.onProgressUpdate((progressEvent) => { |
| | | upsertDownloadTask(currentUserId, { |
| | | ...initialTask, |
| | | progress: Number(progressEvent?.progress || 0), |
| | | updatedAt: new Date().toISOString(), |
| | | }) |
| | | }) |
| | | } |
| | | |
| | |
| | | align-items: flex-end; |
| | | margin-bottom: 24rpx; |
| | | |
| | | .price-box { |
| | | display: flex; |
| | | align-items: baseline; |
| | | color: #D32E29; |
| | | |
| | | .price-symbol { |
| | | font-size: 24rpx; |
| | | } |
| | | |
| | | .price-integer { |
| | | font-size: 40rpx; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .price-decimal { |
| | | font-size: 28rpx; |
| | | font-weight: 600; |
| | | } |
| | | } |
| | | |
| | | .stats-box { |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | margin-bottom: 32rpx; |
| | | } |
| | | |
| | | .vip-banner { |
| | | position: relative; |
| | | width: 100%; |
| | | height: 140rpx; |
| | | border-radius: 16rpx; |
| | | overflow: hidden; |
| | | |
| | | .vip-bg { |
| | | position: absolute; |
| | | width: 100%; |
| | | height: 100%; |
| | | top: 0; |
| | | left: 0; |
| | | } |
| | | |
| | | .vip-content { |
| | | position: relative; |
| | | width: 100%; |
| | | height: 100%; |
| | | box-sizing: border-box; |
| | | padding-top: 20rpx; |
| | | padding-right: 28rpx; |
| | | padding-bottom: 30rpx; |
| | | padding-left: 170rpx; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | |
| | | .vip-left { |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: center; |
| | | |
| | | .vip-price-box { |
| | | color: #AA5200; |
| | | display: flex; |
| | | align-items: baseline; |
| | | margin-bottom: 4rpx; |
| | | |
| | | .vip-price-symbol { |
| | | font-size: 16rpx; |
| | | } |
| | | |
| | | .vip-price-num { |
| | | font-size: 36rpx; |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .vip-price-decimal { |
| | | font-size: 24rpx; |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .vip-price-suffix { |
| | | font-size: 24rpx; |
| | | margin-left: 4rpx; |
| | | } |
| | | } |
| | | |
| | | .vip-price-empty { |
| | | font-size: 32rpx; |
| | | font-weight: bold; |
| | | color: #AA5200; |
| | | margin-bottom: 4rpx; |
| | | } |
| | | |
| | | .vip-desc { |
| | | font-size: 22rpx; |
| | | color: #AA5200; |
| | | } |
| | | } |
| | | |
| | | .vip-btn { |
| | | width: 210rpx; |
| | | height: 60rpx; |
| | | background-color: #C63520; |
| | | border-radius: 20rpx; |
| | | color: #fff; |
| | | font-size: 28rpx; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | /* 为了美观可以稍微调整字体大小,原要求28rpx放在34rpx高度里可能会有点大 */ |
| | | line-height: 1; |
| | | transform: scale(0.8); /* 28rpx in 34rpx height needs some scaling to look right, or adjust font-size */ |
| | | transform-origin: right center; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .module-section { |
| | | background-color: #fff; |
| | | padding: 32rpx; |
| | | margin-bottom: 20rpx; |
| | | |
| | | &.last-module { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .module-title { |
| | | font-size: 32rpx; |
| | | color: #1D2129; |
| | | font-weight: 600; |
| | | margin-bottom: 24rpx; |
| | | } |
| | | |
| | | .module-desc { |
| | | font-size: 26rpx; |
| | | color: #86909C; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .expert-card { |
| | | background-color: #FAF8E9; |
| | | padding: 24rpx; |
| | | border-radius: 16rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .expert-avatar { |
| | | width: 96rpx; |
| | | height: 96rpx; |
| | | border-radius: 50%; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .expert-info { |
| | | margin-left: 16rpx; |
| | | flex: 1; |
| | | |
| | | .expert-name { |
| | | font-size: 28rpx; |
| | | color: #A27F53; |
| | | margin-bottom: 8rpx; |
| | | } |
| | | |
| | | .expert-desc { |
| | | font-size: 24rpx; |
| | | color: #86909C; |
| | | display: -webkit-box; |
| | | -webkit-box-orient: vertical; |
| | | -webkit-line-clamp: 2; |
| | | overflow: hidden; |
| | | line-height: 34rpx; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .detail-content { |
| | | font-size: 26rpx; |
| | | color: #1A1A1A; |
| | | line-height: 1.6; |
| | | } |
| | | |
| | | .rich-detail-content { |
| | | width: 100%; |
| | | } |
| | | } |
| | | |
| | | .course-list-section { |
| | |
| | | font-size: 32rpx; |
| | | color: #1A1A1A; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .amount-box { |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: baseline; |
| | | margin-bottom: 40rpx; |
| | | color: #C63520; |
| | | } |
| | | |
| | | .amount-symbol { |
| | | font-size: 32rpx; |
| | | } |
| | | |
| | | .amount-value { |
| | | font-size: 56rpx; |
| | | font-weight: 700; |
| | | } |
| | | |
| | | .payment-methods { |
| New file |
| | |
| | | <template> |
| | | <view class="container"> |
| | | <view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }"> |
| | | <view class="nav-content"> |
| | | <image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image> |
| | | <text class="nav-title">本地视频播放</text> |
| | | <view class="nav-right-placeholder"></view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="video-section"> |
| | | <view v-if="videoSource" class="video-player-wrapper"> |
| | | <video |
| | | id="download-video-player" |
| | | class="video-player" |
| | | :src="videoSource" |
| | | :poster="coverImage" |
| | | :controls="true" |
| | | :autoplay="true" |
| | | object-fit="contain" |
| | | play-btn-position="center" |
| | | @loadedmetadata="handleVideoLoaded" |
| | | @error="handleVideoError" |
| | | ></video> |
| | | <wd-watermark |
| | | v-if="watermarkText" |
| | | class="video-watermark" |
| | | :content="watermarkText" |
| | | :full-screen="false" |
| | | :width="160" |
| | | :height="110" |
| | | :gutter-x="36" |
| | | :gutter-y="36" |
| | | :size="15" |
| | | color="rgba(255, 255, 255, 0.24)" |
| | | :rotate="-18" |
| | | :opacity="1" |
| | | :z-index="2" |
| | | /> |
| | | <view v-if="showRetry" class="retry-mask" @tap.stop="retryPlayback"> |
| | | <text class="retry-text">点击开始播放</text> |
| | | </view> |
| | | </view> |
| | | <view v-else class="empty-box"> |
| | | <text class="empty-text">本地文件不存在,请重新下载</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="info-card"> |
| | | <text class="video-title">{{ title }}</text> |
| | | <view class="info-row"> |
| | | <text class="video-subtitle">{{ subtitle }}</text> |
| | | <text class="video-duration">{{ durationText }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { computed, getCurrentInstance, ref } from 'vue' |
| | | import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app' |
| | | import { getDownloadTask } from '@/utils/download-task-storage.js' |
| | | import { resolveCurrentAppUserId } from '@/utils/app-user.js' |
| | | import { |
| | | bindVideoCaptureListener, |
| | | buildVideoWatermarkText, |
| | | disableVideoSecurityProtection, |
| | | enableVideoSecurityProtection, |
| | | isIosApp, |
| | | unbindVideoCaptureListener, |
| | | } from '@/utils/video-security.js' |
| | | |
| | | interface DownloadTaskItem { |
| | | clientTaskId: string |
| | | title?: string |
| | | subtitle?: string |
| | | coverImage?: string |
| | | durationText?: string |
| | | savedFilePath?: string |
| | | } |
| | | |
| | | const PLAYER_ID = 'download-video-player' |
| | | const DEFAULT_COVER = '/static/image/home/class.png' |
| | | |
| | | const statusBarHeight = ref(20) |
| | | const taskInfo = ref<DownloadTaskItem | null>(null) |
| | | const playbackFailed = ref(false) |
| | | const isVideoReady = ref(false) |
| | | const watermarkTick = ref(Date.now()) |
| | | let videoContext: ReturnType<typeof uni.createVideoContext> | null = null |
| | | let watermarkTimer: ReturnType<typeof setInterval> | null = null |
| | | const instance = getCurrentInstance() |
| | | |
| | | const videoSource = computed(() => normalizeText(taskInfo.value?.savedFilePath)) |
| | | const coverImage = computed(() => normalizeText(taskInfo.value?.coverImage) || DEFAULT_COVER) |
| | | const title = computed(() => normalizeText(taskInfo.value?.title) || '本地视频') |
| | | const subtitle = computed(() => normalizeText(taskInfo.value?.subtitle) || '养生课程') |
| | | const durationText = computed(() => normalizeText(taskInfo.value?.durationText) || '00:00') |
| | | const watermarkText = computed(() => { |
| | | const userId = resolveCurrentAppUserId() |
| | | if (!userId) { |
| | | return '' |
| | | } |
| | | return buildVideoWatermarkText(userId, watermarkTick.value) |
| | | }) |
| | | const showRetry = computed(() => !!videoSource.value && isVideoReady.value && playbackFailed.value) |
| | | |
| | | /** |
| | | * 页面加载时读取任务信息。 |
| | | */ |
| | | onLoad((options) => { |
| | | const systemInfo = uni.getSystemInfoSync() |
| | | statusBarHeight.value = systemInfo.statusBarHeight || 20 |
| | | const clientTaskId = typeof options?.clientTaskId === 'string' ? options.clientTaskId : '' |
| | | const currentUserId = resolveCurrentAppUserId() |
| | | if (!currentUserId || !clientTaskId) { |
| | | return |
| | | } |
| | | taskInfo.value = getDownloadTask(currentUserId, clientTaskId) as DownloadTaskItem | null |
| | | videoContext = uni.createVideoContext(PLAYER_ID, instance?.proxy) |
| | | }) |
| | | |
| | | /** |
| | | * 页面显示时启用安全保护。 |
| | | */ |
| | | onShow(() => { |
| | | enableVideoSecurityProtection() |
| | | bindVideoCaptureListener(handleVideoCapture) |
| | | startWatermarkTimer() |
| | | }) |
| | | |
| | | /** |
| | | * 页面隐藏时关闭安全保护。 |
| | | */ |
| | | onHide(() => { |
| | | stopWatermarkTimer() |
| | | unbindVideoCaptureListener(handleVideoCapture) |
| | | disableVideoSecurityProtection() |
| | | }) |
| | | |
| | | /** |
| | | * 页面销毁时释放资源。 |
| | | */ |
| | | onUnload(() => { |
| | | stopWatermarkTimer() |
| | | unbindVideoCaptureListener(handleVideoCapture) |
| | | disableVideoSecurityProtection() |
| | | videoContext = null |
| | | }) |
| | | |
| | | /** |
| | | * 视频元数据准备完成后尝试自动播放。 |
| | | */ |
| | | function handleVideoLoaded() { |
| | | isVideoReady.value = true |
| | | retryPlayback() |
| | | } |
| | | |
| | | /** |
| | | * 视频播放失败时显示重试层。 |
| | | */ |
| | | function handleVideoError() { |
| | | playbackFailed.value = true |
| | | } |
| | | |
| | | /** |
| | | * 重新触发本地视频播放。 |
| | | */ |
| | | function retryPlayback() { |
| | | if (!videoContext || !videoSource.value) { |
| | | return |
| | | } |
| | | playbackFailed.value = false |
| | | try { |
| | | videoContext.play() |
| | | } catch (error) { |
| | | playbackFailed.value = true |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 返回上一页。 |
| | | */ |
| | | function goBack() { |
| | | uni.navigateBack() |
| | | } |
| | | |
| | | /** |
| | | * 收到截图时给出提示。 |
| | | */ |
| | | function handleVideoCapture() { |
| | | uni.showToast({ |
| | | title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 定时刷新水印时间片段。 |
| | | */ |
| | | function startWatermarkTimer() { |
| | | if (watermarkTimer) { |
| | | return |
| | | } |
| | | watermarkTimer = setInterval(() => { |
| | | watermarkTick.value = Date.now() |
| | | }, 60000) |
| | | } |
| | | |
| | | /** |
| | | * 关闭水印刷新定时器。 |
| | | */ |
| | | function stopWatermarkTimer() { |
| | | if (!watermarkTimer) { |
| | | return |
| | | } |
| | | clearInterval(watermarkTimer) |
| | | watermarkTimer = null |
| | | } |
| | | |
| | | /** |
| | | * 归一化文本。 |
| | | */ |
| | | function normalizeText(value: unknown) { |
| | | return typeof value === 'string' ? value.trim() : '' |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | page { |
| | | background-color: #ffffff; |
| | | } |
| | | |
| | | .container { |
| | | min-height: 100vh; |
| | | background-color: #ffffff; |
| | | } |
| | | |
| | | .custom-navbar { |
| | | width: 100%; |
| | | position: relative; |
| | | z-index: 10; |
| | | background: #ffffff; |
| | | } |
| | | |
| | | .nav-content { |
| | | height: 88rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 0 32rpx; |
| | | } |
| | | |
| | | .back-icon { |
| | | width: 40rpx; |
| | | height: 40rpx; |
| | | } |
| | | |
| | | .nav-title { |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | color: #1f1f1f; |
| | | position: absolute; |
| | | left: 50%; |
| | | transform: translateX(-50%); |
| | | } |
| | | |
| | | .nav-right-placeholder { |
| | | width: 40rpx; |
| | | height: 40rpx; |
| | | } |
| | | |
| | | .video-section { |
| | | padding: 24rpx 24rpx 0; |
| | | } |
| | | |
| | | .video-player-wrapper { |
| | | position: relative; |
| | | width: 100%; |
| | | height: 420rpx; |
| | | border-radius: 24rpx; |
| | | overflow: hidden; |
| | | background: #000; |
| | | } |
| | | |
| | | .video-player { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | .video-watermark { |
| | | position: absolute; |
| | | inset: 0; |
| | | pointer-events: none; |
| | | } |
| | | |
| | | .retry-mask { |
| | | position: absolute; |
| | | inset: 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | background: rgba(0, 0, 0, 0.36); |
| | | } |
| | | |
| | | .retry-text { |
| | | padding: 18rpx 32rpx; |
| | | border-radius: 999rpx; |
| | | background: rgba(255, 255, 255, 0.14); |
| | | color: #ffffff; |
| | | font-size: 28rpx; |
| | | } |
| | | |
| | | .empty-box { |
| | | height: 420rpx; |
| | | border-radius: 24rpx; |
| | | background: #f7f7f7; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .empty-text { |
| | | font-size: 28rpx; |
| | | color: #999999; |
| | | } |
| | | |
| | | .info-card { |
| | | margin: 24rpx; |
| | | padding: 28rpx 24rpx; |
| | | border-radius: 24rpx; |
| | | background: #fffaf4; |
| | | } |
| | | |
| | | .video-title { |
| | | display: block; |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | color: #2f1d12; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .info-row { |
| | | margin-top: 16rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | color: #8f8f8f; |
| | | font-size: 26rpx; |
| | | } |
| | | </style> |
| | |
| | | import { getHealthCourseDownloads, deleteHealthCourseDownloads } from './service' |
| | | import { |
| | | getDownloadTaskChangeEventName, |
| | | getDownloadTask, |
| | | listDownloadTasks, |
| | | removeDownloadTasks, |
| | | removeDownloadTasksWithFiles, |
| | | } from '@/utils/download-task-storage.js' |
| | | import { resolveCurrentAppUserId } from '@/utils/app-user.js' |
| | | |
| | | interface RemoteDownloadRecord { |
| | | id?: number | null |
| | |
| | | durationText?: string |
| | | videoUrl?: string |
| | | progress?: number | null |
| | | status?: string |
| | | updatedAt?: string |
| | | savedFilePath?: string |
| | | savedAt?: string |
| | | fileSize?: number | null |
| | | } |
| | | |
| | | type DownloadSourceType = 'remoteCompleted' | 'localDownloading' |
| | |
| | | selected: boolean |
| | | sourceType: DownloadSourceType |
| | | sortTime: number |
| | | savedFilePath: string |
| | | } |
| | | |
| | | const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png' |
| | |
| | | return |
| | | } |
| | | const remoteItems = normalizeRemoteDownloadItems(response.data) |
| | | downloadList.value = sortDownloadList([...localItems, ...remoteItems]) |
| | | downloadList.value = sortDownloadList(mergeDownloadItems(localItems, remoteItems)) |
| | | } catch { |
| | | downloadList.value = sortDownloadList(localItems) |
| | | } |
| | |
| | | currentItem.selected = !currentItem.selected |
| | | return |
| | | } |
| | | if (currentItem.status === 'downloading') { |
| | | uni.showToast({ |
| | | title: '下载中暂不可播放', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | if (!currentItem.clientTaskId) { |
| | | uni.showToast({ |
| | | title: '本地文件不存在,请重新下载', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | const taskInfo = getDownloadTask(resolveCurrentUserId(), currentItem.clientTaskId) |
| | | if (!taskInfo || !normalizeOptionalText(taskInfo.savedFilePath)) { |
| | | uni.showToast({ |
| | | title: '本地文件不存在,请重新下载', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | uni.navigateTo({ |
| | | url: `/pages/downloadPlayer/downloadPlayer?clientTaskId=${encodeURIComponent(currentItem.clientTaskId)}`, |
| | | }) |
| | | } |
| | | |
| | | /** |
| | |
| | | .filter((item) => item.sourceType === 'remoteCompleted' && item.remoteId) |
| | | .map((item) => Number(item.remoteId)) |
| | | const localTaskIds = items |
| | | .filter((item) => item.sourceType === 'localDownloading' && item.clientTaskId) |
| | | .filter((item) => item.clientTaskId) |
| | | .map((item) => item.clientTaskId) |
| | | |
| | | try { |
| | |
| | | } |
| | | } |
| | | if (localTaskIds.length > 0) { |
| | | // 下载中删除只移除本地任务展示,不强制终止底层原生下载。 |
| | | removeDownloadTasks(resolveCurrentUserId(), localTaskIds) |
| | | await removeDownloadTasksWithFiles(resolveCurrentUserId(), localTaskIds) |
| | | } |
| | | downloadList.value = downloadList.value.filter((item) => !items.some((target) => target.id === item.id)) |
| | | resetBatchMode() |
| | |
| | | selected: false, |
| | | sourceType: 'remoteCompleted', |
| | | sortTime: resolveDateTime(record.downloadedAt), |
| | | savedFilePath: '', |
| | | } |
| | | }) |
| | | } |
| | |
| | | subtitle: normalizeOptionalText(item.subtitle) || '养生课程', |
| | | duration: resolveDurationText(item.durationText, item.durationSeconds), |
| | | image: resolveCourseImage(item.coverImage), |
| | | status: 'downloading', |
| | | status: item.status === 'downloaded' ? 'downloaded' : 'downloading', |
| | | progress: normalizeProgress(item.progress), |
| | | selected: false, |
| | | sourceType: 'localDownloading', |
| | | sortTime: resolveDateTime(item.updatedAt), |
| | | 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) |
| | | ) |
| | | return [ |
| | | ...localItems, |
| | | ...remoteItems.filter((item) => !localVideoIdSet.has(Number(item.courseVideoId || 0))), |
| | | ] |
| | | } |
| | | |
| | | /** |
| | |
| | | * 读取当前缓存中的 APP 用户标识。 |
| | | */ |
| | | function resolveCurrentUserId() { |
| | | const cachedUser = uni.getStorageSync('appUserInfo') |
| | | if (!cachedUser || typeof cachedUser !== 'object') { |
| | | return 0 |
| | | } |
| | | const userId = Number((cachedUser as { id?: number | string }).id || 0) |
| | | if (!Number.isFinite(userId) || userId <= 0) { |
| | | return 0 |
| | | } |
| | | return userId |
| | | return resolveCurrentAppUserId() |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | if (currentBanner.jumpType === 'MEMBER') { |
| | | uni.navigateTo({ |
| | | url: '/pages/vip/vip' |
| | | }); |
| | | return; |
| | | } |
| | | |
| | |
| | | |
| | | <view class="card-bottom"> |
| | | <view class="qr-box"> |
| | | <image class="qr-image" :src="qrcodeData.qrCodeDataUrl || '/static/image/mine/code.png'" mode="aspectFit"></image> |
| | | <image class="qr-image" :src="qrcodeData.qrCodeImageUrl || qrcodeData.qrCodeDataUrl || '/static/image/mine/code.png'" mode="aspectFit"></image> |
| | | </view> |
| | | <text class="qr-tip">扫码下载APP,注册账号</text> |
| | | <view class="save-btn" @tap="saveQRCode">保存二维码</view> |
| | |
| | | clinicName: string |
| | | inviteCode: string |
| | | shareLandingUrl: string |
| | | qrCodeImageUrl: string |
| | | qrCodeDataUrl: string |
| | | boundUsers: ShareBoundUserItem[] |
| | | } |
| | |
| | | clinicName: '', |
| | | inviteCode: '', |
| | | shareLandingUrl: '', |
| | | qrCodeImageUrl: '', |
| | | qrCodeDataUrl: '', |
| | | boundUsers: [] |
| | | } |
| | |
| | | * 保存当前二维码图片到相册。 |
| | | */ |
| | | function saveQRCode() { |
| | | if (!qrcodeData.value.qrCodeDataUrl) { |
| | | const saveTarget = qrcodeData.value.qrCodeImageUrl || qrcodeData.value.qrCodeDataUrl |
| | | if (!saveTarget) { |
| | | uni.showToast({ |
| | | title: '二维码生成中,请稍后重试', |
| | | icon: 'none' |
| | | }) |
| | | return |
| | | } |
| | | saveImage(qrcodeData.value.qrCodeDataUrl) |
| | | saveImage(saveTarget) |
| | | } |
| | | |
| | | /** |
| | |
| | | clinicName: normalizeOptionalText(source.clinicName), |
| | | inviteCode: normalizeOptionalText(source.inviteCode), |
| | | shareLandingUrl: normalizeOptionalText(source.shareLandingUrl), |
| | | qrCodeImageUrl: normalizeOptionalText(source.qrCodeImageUrl), |
| | | qrCodeDataUrl: normalizeOptionalText(source.qrCodeDataUrl), |
| | | boundUsers: normalizeBoundUsers(source.boundUsers) |
| | | } |
| | |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="vip-banner"> |
| | | <image class="vip-bg" src="/static/image/mine/openVip.png" mode="aspectFill"></image> |
| | | <view class="vip-content"> |
| | | <view class="vip-left"> |
| | | <view class="vip-title-box"> |
| | | <text class="vip-title">VIP会员</text> |
| | | </view> |
| | | <text class="vip-desc">{{ vipDescription }}</text> |
| | | </view> |
| | | <view class="vip-btn" @tap="goPage('/pages/vip/vip')">{{ vipButtonText }}</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="grid-section card-box"> |
| | | <view class="grid-item" v-for="(item, index) in gridMenuList" :key="`${item.title}-${index}`" @tap="handleGridClick(item)"> |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from 'vue' |
| | | import { onLoad, onShow } from '@dcloudio/uni-app' |
| | | import { getMemberOpenPage, getUserCenter } from './service' |
| | | import { getUserCenter } from './service' |
| | | |
| | | type UserRole = 'personal' | 'doctor' |
| | | |
| | |
| | | |
| | | const statusBarHeight = ref(0) |
| | | const userCenter = ref<UserCenterData>(createDefaultUserCenter()) |
| | | const memberMinPrice = ref<number | string | null>(null) |
| | | |
| | | const userRole = computed<UserRole>(() => { |
| | | return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal' |
| | | }) |
| | |
| | | const points = Number(userCenter.value.pointsBalance || 0) |
| | | return Number.isFinite(points) ? points : 0 |
| | | }) |
| | | const isVipActive = computed(() => { |
| | | return isMemberActive(userCenter.value.memberExpireAt) |
| | | }) |
| | | const vipDescription = computed(() => { |
| | | if (isVipActive.value) { |
| | | return `${formatMemberExpireText(userCenter.value.memberExpireAt)}到期` |
| | | } |
| | | const formattedPrice = formatVipMinPrice(memberMinPrice.value) |
| | | if (!formattedPrice) { |
| | | return '开通畅享养生课' |
| | | } |
| | | return `${formattedPrice}元起开通畅享养生课` |
| | | }) |
| | | const vipButtonText = computed(() => { |
| | | return isVipActive.value ? '立即续费' : '立即开通' |
| | | }) |
| | | |
| | | const gridMenuList = computed<MenuItem[]>(() => { |
| | | if (userRole.value === 'doctor') { |
| | | return [ |
| | | { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' }, |
| | | { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' }, |
| | | { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' }, |
| | | { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' }, |
| | | { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients' }, |
| | |
| | | } |
| | | return [ |
| | | { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' }, |
| | | { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' }, |
| | | { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord' }, |
| | | { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' }, |
| | | { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' }, |
| | |
| | | const systemInfo = uni.getSystemInfoSync() |
| | | statusBarHeight.value = systemInfo.statusBarHeight || 20 |
| | | syncCachedUserInfo() |
| | | loadMemberOpenPage() |
| | | }) |
| | | |
| | | /** |
| | |
| | | onShow(() => { |
| | | syncCachedUserInfo() |
| | | loadUserCenter() |
| | | loadMemberOpenPage() |
| | | }) |
| | | |
| | | /** |
| | |
| | | cacheUserCenter(userCenter.value) |
| | | } catch { |
| | | // 未登录或网络异常时由统一请求层处理跳转,这里不重复 toast。 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 拉取会员开通页最低价格,用于个人中心会员横幅文案展示。 |
| | | */ |
| | | async function loadMemberOpenPage() { |
| | | try { |
| | | const response = await getMemberOpenPage() |
| | | if (response?.code !== 0 || !response.data) { |
| | | memberMinPrice.value = null |
| | | return |
| | | } |
| | | memberMinPrice.value = response.data.minPrice ?? null |
| | | } catch { |
| | | // 文案接口失败时使用兜底文案,避免个人中心反复提示。 |
| | | memberMinPrice.value = null |
| | | } |
| | | } |
| | | |
| | |
| | | uni.navigateTo({ |
| | | url, |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 将会员最低价格式化为横幅文案所需的金额字符串。 |
| | | */ |
| | | function formatVipMinPrice(value: number | string | null | undefined) { |
| | | const price = Number(value) |
| | | if (!Number.isFinite(price) || price <= 0) { |
| | | return '' |
| | | } |
| | | const fixed = price.toFixed(2) |
| | | return fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1') |
| | | } |
| | | |
| | | /** |
| | | * 判断当前会员是否仍在有效期内。 |
| | | */ |
| | | function isMemberActive(memberExpireAt?: string) { |
| | | const normalizedExpireAt = normalizeOptionalText(memberExpireAt) |
| | | if (!normalizedExpireAt) { |
| | | return false |
| | | } |
| | | const expireDate = new Date(normalizedExpireAt.replace(/-/g, '/')) |
| | | if (Number.isNaN(expireDate.getTime())) { |
| | | return false |
| | | } |
| | | return expireDate.getTime() > Date.now() |
| | | } |
| | | |
| | | /** |
| | | * 格式化会员到期日期,供个人中心会员卡片直接展示。 |
| | | */ |
| | | function formatMemberExpireText(memberExpireAt?: string) { |
| | | const normalizedExpireAt = normalizeOptionalText(memberExpireAt) |
| | | if (!normalizedExpireAt) { |
| | | return '会员' |
| | | } |
| | | return normalizedExpireAt.slice(0, 10) |
| | | } |
| | | |
| | | /** |
| | |
| | | import { computed, getCurrentInstance, ref } from 'vue' |
| | | import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app' |
| | | import { getMyHealthCourseDetail, uploadMyHealthCoursePlayRecord } from './service' |
| | | import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.js' |
| | | import { listDownloadTasks } from '@/utils/download-task-storage.js' |
| | | import { reportHealthCourseDownload } from '@/pages/courseDetail/service' |
| | | import { startCourseVideoDownload, hasCourseDownloadTask } from '@/utils/course-download.js' |
| | | import { resolveCurrentAppUserId } from '@/utils/app-user.js' |
| | | import { |
| | | bindVideoCaptureListener, |
| | | buildVideoWatermarkText, |
| | | disableVideoSecurityProtection, |
| | | enableVideoSecurityProtection, |
| | | isIosApp, |
| | | unbindVideoCaptureListener, |
| | | } from '@/utils/video-security.js' |
| | | |
| | | interface MyHealthCourseVideoItem { |
| | | videoId: number |
| | |
| | | durationText: string |
| | | videoUrl: string |
| | | progress: number |
| | | status: 'downloading' |
| | | status: 'downloading' | 'downloaded' |
| | | updatedAt: string |
| | | savedFilePath?: string |
| | | savedAt?: string |
| | | fileSize?: number |
| | | } |
| | | |
| | | type VideoMetadataEvent = { |
| | |
| | | const currentWatchStartedAt = ref(0) |
| | | const currentPlaybackSeconds = ref(0) |
| | | const lastReportedPlaybackSeconds = ref(0) |
| | | const watermarkTick = ref(Date.now()) |
| | | const backSubmitting = ref(false) |
| | | const watchSegmentSubmitting = ref(false) |
| | | const instance = getCurrentInstance() |
| | | let videoContext: ReturnType<typeof uni.createVideoContext> | null = null |
| | | let watchHeartbeatTimer: ReturnType<typeof setInterval> | null = null |
| | | let videoSecurityTimer: ReturnType<typeof setInterval> | null = null |
| | | |
| | | /** |
| | | * 生成默认详情数据,避免页面空对象访问。 |
| | |
| | | if (!currentUserId) { |
| | | return '' |
| | | } |
| | | return `用户ID:${currentUserId}` |
| | | return buildVideoWatermarkText(currentUserId, watermarkTick.value) |
| | | }) |
| | | const lessonList = computed<LessonViewItem[]>(() => { |
| | | const downloadingTasks = listCurrentDownloadTasks() |
| | |
| | | * 页面显示时刷新详情,保证章节和进度为最新状态。 |
| | | */ |
| | | onShow(() => { |
| | | enableVideoSecurityProtection() |
| | | bindVideoCaptureListener(handleVideoCapture) |
| | | startVideoWatermarkTicker() |
| | | loadMyHealthCourseDetail() |
| | | }) |
| | | |
| | |
| | | * 页面隐藏时补提最后一段播放区间。 |
| | | */ |
| | | onHide(() => { |
| | | stopVideoWatermarkTicker() |
| | | unbindVideoCaptureListener(handleVideoCapture) |
| | | disableVideoSecurityProtection() |
| | | clearWatchHeartbeatTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | |
| | | * 页面卸载时补提最后一段播放区间。 |
| | | */ |
| | | onUnload(() => { |
| | | stopVideoWatermarkTicker() |
| | | unbindVideoCaptureListener(handleVideoCapture) |
| | | disableVideoSecurityProtection() |
| | | unbindH5PlaybackLifecycle() |
| | | clearWatchHeartbeatTimer() |
| | | submitCurrentWatchSegment() |
| | |
| | | } |
| | | if (hasDownloadedTask(listCurrentDownloadTasks(), item.videoId)) { |
| | | uni.showToast({ |
| | | title: '当前章节正在下载中', |
| | | title: '当前章节已在本地下载列表中', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | const downloadTaskId = buildDownloadTaskId(item.videoId) |
| | | const initialTask = createDownloadTaskPayload(downloadTaskId, item) |
| | | upsertDownloadTask(currentUserId, initialTask) |
| | | uni.showLoading({ |
| | | title: '下载中...', |
| | | mask: true, |
| | | }) |
| | | const downloadTask = uni.downloadFile({ |
| | | url: item.videoUrl, |
| | | success: async (response) => { |
| | | if (response.statusCode !== 200 || !response.tempFilePath) { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | uni.showToast({ |
| | | title: '下载失败', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | startCourseVideoDownload({ |
| | | courseId: detail.value.courseId, |
| | | courseVideoId: item.videoId, |
| | | title: detail.value.title || '养生课程', |
| | | subtitle: item.title, |
| | | coverImage: detail.value.coverImage, |
| | | durationSeconds: item.durationSeconds, |
| | | durationText: item.duration, |
| | | videoUrl: item.videoUrl, |
| | | reportDownload: async () => { |
| | | const reportResponse = await reportHealthCourseDownload({ |
| | | courseId: detail.value.courseId, |
| | | courseVideoId: item.videoId, |
| | | }) |
| | | if (reportResponse?.code !== 0) { |
| | | throw new Error('report failed') |
| | | } |
| | | // 先保存到持久化目录,再同步下载记录,避免临时文件失效后“我的下载”里只有记录没有文件。 |
| | | uni.saveFile({ |
| | | tempFilePath: response.tempFilePath, |
| | | success: async () => { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | try { |
| | | const reportResponse = await reportHealthCourseDownload({ |
| | | courseId: detail.value.courseId, |
| | | courseVideoId: item.videoId, |
| | | }) |
| | | if (reportResponse?.code !== 0) { |
| | | return |
| | | } |
| | | uni.showToast({ |
| | | title: '下载完成', |
| | | icon: 'none', |
| | | }) |
| | | } catch { |
| | | uni.showToast({ |
| | | title: '下载完成,记录同步失败', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | }, |
| | | fail: () => { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | uni.showToast({ |
| | | title: '文件已下载到临时目录', |
| | | icon: 'none', |
| | | }) |
| | | }, |
| | | }) |
| | | }, |
| | | fail: () => { |
| | | clearDownloadTask(currentUserId, downloadTaskId) |
| | | uni.showToast({ |
| | | title: '下载失败', |
| | | icon: 'none', |
| | | }) |
| | | }, |
| | | complete: () => { |
| | | uni.hideLoading() |
| | | }, |
| | | }) |
| | | downloadTask.onProgressUpdate((progressEvent) => { |
| | | upsertDownloadTask(currentUserId, { |
| | | ...initialTask, |
| | | progress: progressEvent.progress, |
| | | updatedAt: new Date().toISOString(), |
| | | }) |
| | | }) |
| | | } |
| | | |
| | |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 构建下载任务快照。 |
| | | */ |
| | | function createDownloadTaskPayload(clientTaskId: string, item: LessonViewItem): LocalDownloadTask { |
| | | return { |
| | | clientTaskId, |
| | | courseId: detail.value.courseId, |
| | | courseVideoId: item.videoId, |
| | | title: detail.value.title || '养生课程', |
| | | subtitle: item.title, |
| | | coverImage: detail.value.coverImage, |
| | | durationSeconds: item.durationSeconds, |
| | | durationText: item.duration, |
| | | videoUrl: item.videoUrl, |
| | | progress: 0, |
| | | status: 'downloading', |
| | | updatedAt: new Date().toISOString(), |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构建下载任务唯一标识。 |
| | | */ |
| | | function buildDownloadTaskId(videoId: number) { |
| | | return `my-course-${detail.value.courseId}-${videoId}` |
| | | } |
| | | |
| | | /** |
| | | * 读取当前用户的下载任务列表。 |
| | | */ |
| | | function listCurrentDownloadTasks(): LocalDownloadTask[] { |
| | | const currentUserId = normalizeNumber(resolveCurrentUserId()) |
| | | if (!currentUserId) { |
| | | return [] |
| | | } |
| | | return listDownloadTasks(currentUserId) as LocalDownloadTask[] |
| | | } |
| | | |
| | | /** |
| | | * 播放器安全限制下收到截图事件时提示用户。 |
| | | */ |
| | | function handleVideoCapture() { |
| | | uni.showToast({ |
| | | title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 启动水印刷新定时器,降低截图复用价值。 |
| | | */ |
| | | function startVideoWatermarkTicker() { |
| | | if (videoSecurityTimer) { |
| | | return |
| | | } |
| | | videoSecurityTimer = setInterval(() => { |
| | | watermarkTick.value = Date.now() |
| | | }, 60000) |
| | | } |
| | | |
| | | /** |
| | | * 关闭水印刷新定时器。 |
| | | */ |
| | | function stopVideoWatermarkTicker() { |
| | | if (!videoSecurityTimer) { |
| | | return |
| | | } |
| | | clearInterval(videoSecurityTimer) |
| | | videoSecurityTimer = null |
| | | } |
| | | |
| | | /** |
| | |
| | | * 获取当前登录用户主键。 |
| | | */ |
| | | function resolveCurrentUserId() { |
| | | const userInfo = uni.getStorageSync('appUserInfo') |
| | | if (!userInfo || typeof userInfo !== 'object') { |
| | | return 0 |
| | | } |
| | | return (userInfo.id || userInfo.userId || 0) as number |
| | | return resolveCurrentAppUserId() |
| | | } |
| | | |
| | | /** |
| | |
| | | sendRegisterSmsCode, |
| | | uploadRegisterLicense |
| | | } from './service.js' |
| | | import { consumeAppInviteCode, normalizeOptionalText } from '@/utils/utils.js' |
| | | import { consumeAppInviteCode, extractInviteCode, normalizeOptionalText } from '@/utils/utils.js' |
| | | |
| | | interface RegionItem { |
| | | label?: string |
| | |
| | | } |
| | | |
| | | /** |
| | | * 扫码填充邀请码,统一转为大写。 |
| | | * 扫码填充邀请码,支持从邀请下载页 URL 中提取推广码。 |
| | | */ |
| | | const scanCode = () => { |
| | | uni.scanCode({ |
| | | success: (res) => { |
| | | form.inviteCode = (res.result || '').toUpperCase() |
| | | const inviteCode = normalizeInviteCode(res.result) |
| | | if (!inviteCode) { |
| | | showToast('未识别到有效邀请码') |
| | | return |
| | | } |
| | | form.inviteCode = inviteCode |
| | | } |
| | | }) |
| | | } |
| | |
| | | * 将邀请码归一化为 6 位大写编码。 |
| | | */ |
| | | const normalizeInviteCode = (value: unknown) => { |
| | | const normalizedValue = normalizeOptionalText(value).toUpperCase() |
| | | const normalizedValue = extractInviteCode(value) |
| | | if (!/^[A-Z0-9]{6}$/.test(normalizedValue)) { |
| | | return '' |
| | | } |
| | |
| | | title?: string |
| | | coverImage?: string |
| | | displayImage?: string |
| | | displayStatus?: string |
| | | actionStatus?: LiveActionStatus |
| | | actionSessionId?: number | string | null |
| | | videoUrl?: string |
| | |
| | | title: '', |
| | | coverImage: '', |
| | | displayImage: '', |
| | | displayStatus: '', |
| | | actionStatus: 'CLOSED', |
| | | actionSessionId: null, |
| | | videoUrl: '', |
| | |
| | | && !!liveDetail.value.playbackStartTime |
| | | && Number(liveDetail.value.durationSeconds || 0) > 0 |
| | | }) |
| | | const isEndedQuizWindow = computed(() => { |
| | | return liveDetail.value.displayStatus === 'ENDED' |
| | | && !!activeSessionId.value |
| | | && !!quizState.value.quizEnabled |
| | | }) |
| | | 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 (!liveDetail.value.courseId) { |
| | | return '直播信息不存在' |
| | | } |
| | | if (isEndedQuizWindow.value) { |
| | | return '直播已结束,请完成答题' |
| | | } |
| | | if (!liveDetail.value.videoUrl) { |
| | | return '直播视频准备中' |
| | | } |
| New file |
| | |
| | | /** |
| | | * 读取当前缓存中的 APP 用户标识。 |
| | | */ |
| | | export function resolveCurrentAppUserId() { |
| | | const cachedUser = uni.getStorageSync('appUserInfo') |
| | | if (!cachedUser || typeof cachedUser !== 'object') { |
| | | return 0 |
| | | } |
| | | const userId = Number(cachedUser.id || 0) |
| | | if (!Number.isFinite(userId) || userId <= 0) { |
| | | return 0 |
| | | } |
| | | return userId |
| | | } |
| New file |
| | |
| | | import { |
| | | findDownloadTaskByVideoId, |
| | | markDownloadTaskCompleted, |
| | | removeDownloadTasks, |
| | | upsertDownloadTask, |
| | | } from '@/utils/download-task-storage.js' |
| | | import { resolveCurrentAppUserId } from '@/utils/app-user.js' |
| | | |
| | | /** |
| | | * 构建课程视频下载任务唯一标识。 |
| | | */ |
| | | export function buildCourseDownloadTaskId(courseId, courseVideoId) { |
| | | return `health-course-video-${Number(courseId || 0)}-${Number(courseVideoId || 0)}` |
| | | } |
| | | |
| | | /** |
| | | * 判断当前小节是否已存在本地下载任务。 |
| | | */ |
| | | export function hasCourseDownloadTask(courseVideoId) { |
| | | const currentUserId = resolveCurrentAppUserId() |
| | | if (!currentUserId) { |
| | | return false |
| | | } |
| | | return !!findDownloadTaskByVideoId(currentUserId, courseVideoId) |
| | | } |
| | | |
| | | /** |
| | | * 发起课程视频下载,并同步本地持久化任务。 |
| | | */ |
| | | export function startCourseVideoDownload(options) { |
| | | const normalizedOptions = normalizeDownloadOptions(options) |
| | | if (!normalizedOptions.videoUrl) { |
| | | uni.showToast({ |
| | | title: '视频地址不存在', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | // #ifdef H5 |
| | | window.open(normalizedOptions.videoUrl, '_blank') |
| | | uni.showToast({ |
| | | title: '已打开下载地址', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | // #endif |
| | | const currentUserId = resolveCurrentAppUserId() |
| | | if (!currentUserId) { |
| | | uni.showToast({ |
| | | title: '请先登录', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | const existingTask = findDownloadTaskByVideoId(currentUserId, normalizedOptions.courseVideoId) |
| | | if (existingTask) { |
| | | uni.showToast({ |
| | | title: existingTask.status === 'downloaded' ? '当前章节已下载' : '当前章节正在下载中', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | const downloadTaskId = buildCourseDownloadTaskId(normalizedOptions.courseId, normalizedOptions.courseVideoId) |
| | | const initialTask = { |
| | | clientTaskId: downloadTaskId, |
| | | courseId: normalizedOptions.courseId, |
| | | courseVideoId: normalizedOptions.courseVideoId, |
| | | title: normalizedOptions.title, |
| | | subtitle: normalizedOptions.subtitle, |
| | | coverImage: normalizedOptions.coverImage, |
| | | durationSeconds: normalizedOptions.durationSeconds, |
| | | durationText: normalizedOptions.durationText, |
| | | videoUrl: normalizedOptions.videoUrl, |
| | | progress: 0, |
| | | status: 'downloading', |
| | | updatedAt: new Date().toISOString(), |
| | | } |
| | | upsertDownloadTask(currentUserId, initialTask) |
| | | uni.showLoading({ |
| | | title: '下载中...', |
| | | mask: true, |
| | | }) |
| | | const downloadTask = uni.downloadFile({ |
| | | url: normalizedOptions.videoUrl, |
| | | success: (downloadResult) => { |
| | | if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) { |
| | | handleDownloadFailure(currentUserId, downloadTaskId, '下载失败') |
| | | return |
| | | } |
| | | uni.saveFile({ |
| | | tempFilePath: downloadResult.tempFilePath, |
| | | success: async (saveResult) => { |
| | | markDownloadTaskCompleted(currentUserId, downloadTaskId, { |
| | | savedFilePath: normalizeText(saveResult.savedFilePath), |
| | | fileSize: normalizeNumber(saveResult.size), |
| | | }) |
| | | try { |
| | | if (typeof normalizedOptions.reportDownload === 'function') { |
| | | await normalizedOptions.reportDownload() |
| | | } |
| | | uni.showToast({ |
| | | title: '下载完成', |
| | | icon: 'none', |
| | | }) |
| | | } catch (error) { |
| | | uni.showToast({ |
| | | title: '下载完成,记录同步失败', |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | }, |
| | | fail: () => { |
| | | handleDownloadFailure(currentUserId, downloadTaskId, '文件持久化失败') |
| | | }, |
| | | }) |
| | | }, |
| | | fail: () => { |
| | | handleDownloadFailure(currentUserId, downloadTaskId, '下载失败') |
| | | }, |
| | | complete: () => { |
| | | uni.hideLoading() |
| | | }, |
| | | }) |
| | | downloadTask.onProgressUpdate((progressEvent) => { |
| | | upsertDownloadTask(currentUserId, { |
| | | ...initialTask, |
| | | progress: Number(progressEvent?.progress || 0), |
| | | updatedAt: new Date().toISOString(), |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 归一化下载配置。 |
| | | */ |
| | | function normalizeDownloadOptions(options) { |
| | | const source = options && typeof options === 'object' ? options : {} |
| | | return { |
| | | courseId: normalizeNumber(source.courseId), |
| | | courseVideoId: normalizeNumber(source.courseVideoId), |
| | | title: normalizeText(source.title) || '课程章节', |
| | | subtitle: normalizeText(source.subtitle) || '养生课程', |
| | | coverImage: normalizeText(source.coverImage) || '/static/image/home/class.png', |
| | | durationSeconds: normalizeNumber(source.durationSeconds), |
| | | durationText: normalizeText(source.durationText) || '00:00', |
| | | videoUrl: normalizeText(source.videoUrl), |
| | | reportDownload: typeof source.reportDownload === 'function' ? source.reportDownload : null, |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 处理下载失败收口。 |
| | | */ |
| | | function handleDownloadFailure(userId, clientTaskId, message) { |
| | | removeDownloadTasks(userId, [clientTaskId]) |
| | | uni.showToast({ |
| | | title: message, |
| | | icon: 'none', |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 归一化文本。 |
| | | */ |
| | | function normalizeText(value) { |
| | | return typeof value === 'string' ? value.trim() : '' |
| | | } |
| | | |
| | | /** |
| | | * 归一化数字。 |
| | | */ |
| | | function normalizeNumber(value) { |
| | | const normalizedValue = Number(value || 0) |
| | | return Number.isFinite(normalizedValue) ? normalizedValue : 0 |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 按任务主键读取单条下载任务。 |
| | | */ |
| | | export function getDownloadTask(userId, clientTaskId) { |
| | | return listDownloadTasks(userId).find((item) => item.clientTaskId === String(clientTaskId || '')) || null |
| | | } |
| | | |
| | | /** |
| | | * 根据课程小节查询下载任务。 |
| | | */ |
| | | export function findDownloadTaskByVideoId(userId, courseVideoId) { |
| | | const normalizedVideoId = Number(courseVideoId || 0) |
| | | return listDownloadTasks(userId).find((item) => Number(item.courseVideoId) === normalizedVideoId) || null |
| | | } |
| | | |
| | | /** |
| | | * 写入或刷新一条下载任务。 |
| | | */ |
| | | export function upsertDownloadTask(userId, task) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 标记下载任务已完成,并记录本地持久化文件信息。 |
| | | */ |
| | | export function markDownloadTaskCompleted(userId, clientTaskId, payload) { |
| | | const task = getDownloadTask(userId, clientTaskId) |
| | | if (!task) { |
| | | return |
| | | } |
| | | upsertDownloadTask(userId, { |
| | | ...task, |
| | | ...payload, |
| | | clientTaskId: task.clientTaskId, |
| | | progress: 100, |
| | | status: 'downloaded', |
| | | updatedAt: new Date().toISOString(), |
| | | savedAt: normalizeText(payload && payload.savedAt) || new Date().toISOString(), |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 批量移除指定下载任务。 |
| | | */ |
| | | export function removeDownloadTasks(userId, clientTaskIds) { |
| | |
| | | const taskIdSet = new Set(clientTaskIds.map((item) => String(item))) |
| | | const taskList = listDownloadTasks(userId).filter((item) => !taskIdSet.has(String(item.clientTaskId))) |
| | | saveDownloadTasks(userId, taskList) |
| | | } |
| | | |
| | | /** |
| | | * 删除本地任务及其持久化文件。 |
| | | */ |
| | | export async function removeDownloadTasksWithFiles(userId, clientTaskIds) { |
| | | if (!Array.isArray(clientTaskIds) || clientTaskIds.length === 0) { |
| | | return |
| | | } |
| | | const taskIdSet = new Set(clientTaskIds.map((item) => String(item))) |
| | | const taskList = listDownloadTasks(userId) |
| | | const targetTasks = taskList.filter((item) => taskIdSet.has(String(item.clientTaskId))) |
| | | for (const task of targetTasks) { |
| | | await removeSavedFile(task.savedFilePath) |
| | | } |
| | | removeDownloadTasks(userId, clientTaskIds) |
| | | } |
| | | |
| | | /** |
| | |
| | | durationText: normalizeText(value.durationText), |
| | | videoUrl: normalizeText(value.videoUrl), |
| | | progress: normalizeProgress(value.progress), |
| | | status: 'downloading', |
| | | status: normalizeStatus(value.status), |
| | | updatedAt: normalizeText(value.updatedAt) || new Date().toISOString(), |
| | | savedFilePath: normalizeText(value.savedFilePath), |
| | | savedAt: normalizeText(value.savedAt), |
| | | fileSize: normalizeNumber(value.fileSize), |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | return progressValue |
| | | } |
| | | |
| | | /** |
| | | * 归一化下载状态。 |
| | | */ |
| | | function normalizeStatus(value) { |
| | | return value === 'downloaded' ? 'downloaded' : 'downloading' |
| | | } |
| | | |
| | | /** |
| | | * 删除持久化文件。 |
| | | */ |
| | | function removeSavedFile(savedFilePath) { |
| | | if (!normalizeText(savedFilePath) || typeof uni.removeSavedFile !== 'function') { |
| | | return Promise.resolve() |
| | | } |
| | | return new Promise((resolve) => { |
| | | uni.removeSavedFile({ |
| | | filePath: savedFilePath, |
| | | complete: () => { |
| | | resolve() |
| | | }, |
| | | }) |
| | | }) |
| | | } |
| | |
| | | uni.showLoading({ |
| | | title: '正在保存图片...' |
| | | }); |
| | | if (normalizeOptionalText(url).startsWith('data:image/')) { |
| | | uni.hideLoading(); |
| | | uni.showToast({ |
| | | title: "当前图片暂不支持直接保存,请稍后重试!", |
| | | icon: 'none' |
| | | }); |
| | | return |
| | | } |
| | | uni.getSetting({ |
| | | success: async (res) => { |
| | | if (!res.authSetting["scope.writePhotosAlbum"]) { |
| | | uni.authorize({ |
| | | scope: "scope.writePhotosAlbum", |
| | | success: async () => { |
| | | const res = await uni.getImageInfo({ |
| | | src: url |
| | | }); |
| | | uni.saveImageToPhotosAlbum({ |
| | | filePath: res.path, |
| | | success: () => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ |
| | | title: "保存成功!" |
| | | }); |
| | | }, |
| | | fail: () => { |
| | | uni.showToast({ |
| | | title: "保存失败,请重试!", |
| | | icon: 'error' |
| | | }); |
| | | } |
| | | }); |
| | | await saveImageFile(url) |
| | | }, |
| | | fail: () => { |
| | | uni.hideLoading(); |
| | |
| | | } |
| | | }); |
| | | } else { |
| | | const res = await uni.getImageInfo({ |
| | | src: url |
| | | }); |
| | | uni.saveImageToPhotosAlbum({ |
| | | filePath: res.path, |
| | | success: () => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ |
| | | title: "保存成功!" |
| | | }); |
| | | }, |
| | | fail: () => { |
| | | uni.showToast({ |
| | | title: "保存失败,请重试!", |
| | | icon: 'error' |
| | | }); |
| | | } |
| | | }); |
| | | await saveImageFile(url) |
| | | } |
| | | }, |
| | | fail: () => { |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 下载图片并写入系统相册。 |
| | | */ |
| | | const saveImageFile = async (url) => { |
| | | try { |
| | | const imageInfo = await uni.getImageInfo({ |
| | | src: url |
| | | }); |
| | | await uni.saveImageToPhotosAlbum({ |
| | | filePath: imageInfo.path, |
| | | }); |
| | | uni.hideLoading(); |
| | | uni.showToast({ |
| | | title: "保存成功!" |
| | | }); |
| | | } catch (error) { |
| | | uni.hideLoading(); |
| | | uni.showToast({ |
| | | title: "保存失败,请重试!", |
| | | icon: 'error' |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 将可选文本归一化为空字符串。 |
| | | */ |
| | | export const normalizeOptionalText = (value) => { |
| | | return typeof value === 'string' ? value.trim() : '' |
| | | } |
| | | |
| | | /** |
| | | * 从 URL、纯文本或剪贴板内容中提取 6 位邀请码。 |
| | | */ |
| | | export const extractInviteCode = (value) => { |
| | | const normalizedValue = normalizeOptionalText(value) |
| | | if (!normalizedValue) { |
| | | return '' |
| | | } |
| | | const queryMatchedInviteCode = normalizedValue.match(/[?&]inviteCode=([^&#]+)/i) |
| | | if (queryMatchedInviteCode && queryMatchedInviteCode[1]) { |
| | | const decodedInviteCode = decodeURIComponent(queryMatchedInviteCode[1]).trim().toUpperCase() |
| | | return /^[A-Z0-9]{6}$/.test(decodedInviteCode) ? decodedInviteCode : '' |
| | | } |
| | | const matchedInviteCode = normalizedValue.toUpperCase().match(/[A-Z0-9]{6}/) |
| | | return matchedInviteCode ? matchedInviteCode[0] : '' |
| | | } |
| | | |
| | | const APP_INVITE_CODE_STORAGE_KEY = 'appAutoInviteCode' |
| | |
| | | * 缓存自动识别的邀请码,供注册页优先读取。 |
| | | */ |
| | | export const cacheAppInviteCode = (inviteCode) => { |
| | | const normalizedInviteCode = normalizeOptionalText(inviteCode).toUpperCase() |
| | | const normalizedInviteCode = extractInviteCode(inviteCode) |
| | | if (!/^[A-Z0-9]{6}$/.test(normalizedInviteCode)) { |
| | | return |
| | | } |
| | |
| | | * 读取并消费自动识别的邀请码。 |
| | | */ |
| | | export const consumeAppInviteCode = () => { |
| | | const inviteCode = normalizeOptionalText(uni.getStorageSync(APP_INVITE_CODE_STORAGE_KEY)).toUpperCase() |
| | | const inviteCode = extractInviteCode(uni.getStorageSync(APP_INVITE_CODE_STORAGE_KEY)) |
| | | if (!/^[A-Z0-9]{6}$/.test(inviteCode)) { |
| | | return '' |
| | | } |
| New file |
| | |
| | | /** |
| | | * 构建播放器动态水印文案。 |
| | | */ |
| | | export function buildVideoWatermarkText(userId, tick) { |
| | | const normalizedUserId = Number(userId || 0) |
| | | if (!Number.isFinite(normalizedUserId) || normalizedUserId <= 0) { |
| | | return '' |
| | | } |
| | | return `用户ID:${normalizedUserId} ${formatWatermarkTime(tick)}` |
| | | } |
| | | |
| | | /** |
| | | * 启用播放器安全限制。 |
| | | */ |
| | | export function enableVideoSecurityProtection() { |
| | | if (typeof plus === 'undefined' || !plus.os) { |
| | | return |
| | | } |
| | | if (String(plus.os.name || '').toLowerCase() !== 'android') { |
| | | return |
| | | } |
| | | try { |
| | | const mainActivity = plus.android.runtimeMainActivity() |
| | | if (!mainActivity) { |
| | | return |
| | | } |
| | | const window = mainActivity.getWindow() |
| | | const layoutParams = plus.android.importClass('android.view.WindowManager$LayoutParams') |
| | | window.setFlags(layoutParams.FLAG_SECURE, layoutParams.FLAG_SECURE) |
| | | } catch (error) { |
| | | // 安全限制为增强能力,失败时保持静默回退。 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 关闭播放器安全限制。 |
| | | */ |
| | | export function disableVideoSecurityProtection() { |
| | | if (typeof plus === 'undefined' || !plus.os) { |
| | | return |
| | | } |
| | | if (String(plus.os.name || '').toLowerCase() !== 'android') { |
| | | return |
| | | } |
| | | try { |
| | | const mainActivity = plus.android.runtimeMainActivity() |
| | | if (!mainActivity) { |
| | | return |
| | | } |
| | | const window = mainActivity.getWindow() |
| | | const layoutParams = plus.android.importClass('android.view.WindowManager$LayoutParams') |
| | | window.clearFlags(layoutParams.FLAG_SECURE) |
| | | } catch (error) { |
| | | // 安全限制为增强能力,失败时保持静默回退。 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 绑定系统截图监听。 |
| | | */ |
| | | export function bindVideoCaptureListener(handler) { |
| | | if (typeof handler !== 'function' || typeof uni.onUserCaptureScreen !== 'function') { |
| | | return |
| | | } |
| | | uni.onUserCaptureScreen(handler) |
| | | } |
| | | |
| | | /** |
| | | * 解绑系统截图监听。 |
| | | */ |
| | | export function unbindVideoCaptureListener(handler) { |
| | | if (typeof uni.offUserCaptureScreen !== 'function') { |
| | | return |
| | | } |
| | | if (typeof handler === 'function') { |
| | | uni.offUserCaptureScreen(handler) |
| | | return |
| | | } |
| | | uni.offUserCaptureScreen() |
| | | } |
| | | |
| | | /** |
| | | * 判断当前是否为 iOS APP。 |
| | | */ |
| | | export function isIosApp() { |
| | | if (typeof plus === 'undefined' || !plus.os) { |
| | | return false |
| | | } |
| | | return String(plus.os.name || '').toLowerCase() === 'ios' |
| | | } |
| | | |
| | | /** |
| | | * 格式化水印时间片段。 |
| | | */ |
| | | function formatWatermarkTime(value) { |
| | | const date = value instanceof Date ? value : new Date(value || Date.now()) |
| | | const month = `${date.getMonth() + 1}`.padStart(2, '0') |
| | | const day = `${date.getDate()}`.padStart(2, '0') |
| | | const hours = `${date.getHours()}`.padStart(2, '0') |
| | | const minutes = `${date.getMinutes()}`.padStart(2, '0') |
| | | return `${month}-${day} ${hours}:${minutes}` |
| | | } |