.tmp_watchlive_frames/video.html
New file @@ -0,0 +1 @@ <!doctype html><html><body style="margin:0;background:#111"><video id="v" src="file:///C:/Users/PC/Videos/%E5%B1%8F%E5%B9%95%E5%BD%95%E5%88%B6/%E5%B1%8F%E5%B9%95%E5%BD%95%E5%88%B6%202026-06-23%20201151.mp4" controls style="width:100vw;height:100vh;object-fit:contain"></video></body></html> tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java
New file @@ -0,0 +1,31 @@ package com.tcm.training.admin.app.auth.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; /** * APP 登录认证配置。 */ @Component @ConfigurationProperties(prefix = "app.auth") public class AppAuthProperties { /** * APP 登录 token 有效期,默认 30 天;设置为 -1 时表示永久有效。 */ private long tokenTimeoutSeconds = 30L * 24 * 60 * 60; /** * 获取 APP 登录 token 有效期秒数。 */ public long getTokenTimeoutSeconds() { return tokenTimeoutSeconds; } /** * 设置 APP 登录 token 有效期秒数。 */ public void setTokenTimeoutSeconds(long tokenTimeoutSeconds) { this.tokenTimeoutSeconds = tokenTimeoutSeconds; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
@@ -1,5 +1,6 @@ package com.tcm.training.admin.app.auth.service; import com.tcm.training.admin.app.auth.config.AppAuthProperties; import com.tcm.training.admin.app.auth.support.StpAppUserUtil; import cn.dev33.satoken.stp.SaTokenInfo; import org.springframework.stereotype.Service; @@ -10,11 +11,17 @@ @Service public class AppAuthSessionService { private final AppAuthProperties appAuthProperties; public AppAuthSessionService(AppAuthProperties appAuthProperties) { this.appAuthProperties = appAuthProperties; } /** * 为指定 APP 用户建立永久登录态。 * 为指定 APP 用户建立登录态。 */ public void login(Long userId) { StpAppUserUtil.login(userId); StpAppUserUtil.login(userId, appAuthProperties.getTokenTimeoutSeconds()); } /** tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java
@@ -10,16 +10,14 @@ public final class StpAppUserUtil { private static final StpLogic STP_LOGIC = new StpLogic("app-user"); private static final long NEVER_EXPIRE_TIMEOUT = -1L; private StpAppUserUtil() { } /** * 为 APP 用户建立永久登录态。 * 为 APP 用户建立指定有效期的登录态。 */ public static void login(Object loginId) { STP_LOGIC.login(loginId, new SaLoginModel().setTimeout(NEVER_EXPIRE_TIMEOUT)); public static void login(Object loginId, long timeoutSeconds) { STP_LOGIC.login(loginId, new SaLoginModel().setTimeout(timeoutSeconds)); } /** tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
@@ -58,4 +58,6 @@ @NotNull(message = "请选择是否首页推荐") private Integer isHomeRecommend; private Integer sort; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
@@ -120,7 +120,6 @@ course.setFavoriteCount(0); course.setTotalDurationSeconds(0); course.setVideoCount(0); course.setSort(0); course.setDeleted(0); healthCourseMapper.insert(course); return detail(course.getId()); @@ -421,6 +420,7 @@ course.setBaseSalesCount(req.getBaseSalesCount() == null ? 0 : req.getBaseSalesCount()); course.setStatus(req.getStatus()); course.setIsHomeRecommend(req.getIsHomeRecommend()); course.setSort(defaultNumber(req.getSort())); } private void fillVideo(HealthCourseVideo video, HealthCourseVideoSaveRequest req) { @@ -596,6 +596,7 @@ vo.setCoverImg(row.getCoverImg()); vo.setStatus(row.getStatus()); vo.setIsHomeRecommend(row.getIsHomeRecommend()); vo.setSort(defaultNumber(row.getSort())); vo.setCreatedAt(row.getCreatedAt()); return vo; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java
@@ -24,5 +24,6 @@ private String coverImg; private Integer status; private Integer isHomeRecommend; private Integer sort; private LocalDateTime createdAt; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java
@@ -27,5 +27,6 @@ private String coverImg; private Integer status; private Integer isHomeRecommend; private Integer sort; private LocalDateTime createdAt; } tcm-training-admin-backend/src/main/resources/application.yml
@@ -79,6 +79,8 @@ # 活动到期积分结算扫描间隔(毫秒) settlement-interval-ms: 60000 auth: # APP 登录 token 有效期,默认 30 天;如需永久有效可改为 -1 token-timeout-seconds: 2592000 sms: # 短信验证码统一有效期 300 秒,即 5 分钟 expire-seconds: 300 tcm-training-admin/src/pages/live/health/detail.tsx
@@ -30,6 +30,7 @@ favoriteCount?: number; totalDurationSeconds?: number; status: number; sort?: number; expert?: { id: number; name: string; @@ -273,6 +274,7 @@ <div>课程介绍:{course?.summary || '-'}</div> <div>适用人群:{course?.targetUsers || '-'}</div> <div>状态:{course?.status === 1 ? '启用' : '禁用'}</div> <div>排序:{course?.sort ?? 0}</div> <div>基础购买量:{course?.baseSalesCount || 0}</div> <div>销量:{course?.salesCount || 0}</div> <div>收藏量:{course?.favoriteCount || 0}</div> tcm-training-admin/src/pages/live/health/index.tsx
@@ -36,6 +36,7 @@ favoriteCount?: number; coverImg?: string; status: number; sort?: number; expert?: { id: number; name: string; @@ -210,6 +211,12 @@ render: (value) => value?.name || '-', }, { title: '排序', dataIndex: 'sort', width: 90, render: (value) => value ?? 0, }, { title: '状态', dataIndex: 'status', width: 90, tcm-training-admin/src/pages/live/health/manage.tsx
@@ -74,6 +74,7 @@ isHomeRecommend: false, salePrice: 0, baseSalesCount: 0, sort: 0, summary: '', targetUsers: '', bannerImgs: [], @@ -96,6 +97,7 @@ coverImg: course.coverImg, bannerImgs: course.bannerImgs || [], baseSalesCount: course.baseSalesCount, sort: course.sort ?? 0, status: course.status === 1, isHomeRecommend: course.isHomeRecommend === 1, content: course.content, @@ -117,6 +119,7 @@ coverImg, bannerImgs, content: values.content, sort: Number(values.sort || 0), status: values.status ? 1 : 0, isHomeRecommend: values.isHomeRecommend ? 1 : 0, }; @@ -171,6 +174,9 @@ disabled={loading} /> </Form.Item> <Form.Item label="排序" name="sort"> <InputNumber min={0} precision={0} placeholder="数值越小越靠前" className="health-course-edit-form__number" /> </Form.Item> <div className="health-course-edit-form__switch-group"> <div className="health-course-edit-form__switch-item"> <span className="health-course-edit-form__switch-label">是否启用</span> traditionalApp/manifest.json
@@ -1,10 +1,10 @@ { "sassImplementationName" : "node-sass", "name" : "海诊康道", "appid" : "__UNI__1A90541", "appid" : "__UNI__B2F54BE", "description" : "", "versionName" : "1.0.14", "versionCode" : 114, "versionName" : "1.0.18", "versionCode" : 118, "transformPx" : false, /* 5+App特有相关 */ "app-plus" : { traditionalApp/pages/articleDetail/articleDetail.vue
@@ -85,16 +85,36 @@ statusBarHeight.value = res.statusBarHeight || 0 } }) setTimeout(() => { const pageOptions = getCurrentPageOptions() if (!popularizationId.value) { popularizationId.value = normalizeRouteParam(pageOptions?.id) } loadDetail() }, 0) }) /** * 页面加载时读取科普主键并拉取详情接口。 */ onLoad((options) => { popularizationId.value = typeof options?.id === 'string' ? options.id : '' popularizationId.value = normalizeRouteParam(options?.id) loadDetail() }) function normalizeRouteParam(value : unknown) { if (value === undefined || value === null) { return '' } return String(value).trim() } function getCurrentPageOptions() { const pages = getCurrentPages() const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined return currentPage?.options || currentPage?.$page?.options || {} } /** * 拉取健康科普详情接口并更新页面状态。 */ traditionalApp/pages/articleDetail/service.js
@@ -1,8 +1,8 @@ import request from '@/utils/request.js' import { publicGet } from '@/utils/public-request.js' /** * 获取 APP 健康科普详情。 */ export const getHealthPopularizationDetail = (popularizationId) => { return request.get(`/api/app/health-popularizations/${popularizationId}`, {}, false, false) return publicGet(`/api/app/health-popularizations/${popularizationId}`) } traditionalApp/pages/courseDetail/courseDetail.vue
@@ -31,7 +31,7 @@ </swiper-item> </swiper> <view v-if="!isReadonlyDisplayMode && allVideos.length > 0" class="poster-play-btn" @tap.stop="playLatestUploadedVideoFromPoster"> @tap.stop="playFirstCourseVideoFromPoster"> <text class="poster-play-icon">▶</text> </view> <!-- 图片指示器 --> @@ -307,7 +307,7 @@ </template> <script setup lang="ts"> import { computed, getCurrentInstance, nextTick, ref } from 'vue' import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue' import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app' import config from '@/config/index.js' import { @@ -417,6 +417,7 @@ const activePurchaseVideoId = ref<number | null>(null) const courseId = ref('') const shouldPlayLatestVideoOnLoad = ref(false) const shouldPlayFirstVideoOnLoad = ref(false) const courseDetail = ref<CourseDetail>(createDefaultCourseDetail()) const activeVideoId = ref<number>(0) const showVideoPlayer = ref(false) @@ -541,14 +542,33 @@ onLoad((options) => { courseId.value = resolveCourseId(options) shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(options, 'playLatest') shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(options, 'playFirst') loadCourseDetail() }) onMounted(() => { setTimeout(() => { const pageOptions = getCurrentPageOptions() if (!courseId.value) { courseId.value = resolveCourseId(pageOptions) shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playLatest') shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playFirst') } loadCourseDetail() }, 0) }) function getCurrentPageOptions() { const pages = getCurrentPages() const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined return currentPage?.options || currentPage?.$page?.options || {} } /** * 统一解析课程 id,兼容 App 路由参数和 H5 分享页地址参数。 */ function resolveCourseId(options ?: Record<string, unknown>) { const routeCourseId = typeof options?.id === 'string' ? options.id.trim() : '' const routeCourseId = normalizeOptionalText(options?.id) if (routeCourseId) { return routeCourseId } @@ -666,6 +686,7 @@ currentSwiperIndex.value = 0 } syncActiveVideoAfterDetailLoad() handlePlayFirstVideoAfterDetailLoad() handlePlayLatestVideoAfterDetailLoad() } catch { showLoadFailedToast() @@ -731,10 +752,21 @@ } /** * 课程介绍封面播放按钮:播放最新上传章节。 * 首页精选课程播放按钮要求直接播放第一个章节,详情接口返回后再执行。 */ function playLatestUploadedVideoFromPoster() { playLatestUploadedVideo() function handlePlayFirstVideoAfterDetailLoad() { if (!shouldPlayFirstVideoOnLoad.value) { return } shouldPlayFirstVideoOnLoad.value = false playFirstCourseVideo() } /** * 课程介绍封面播放按钮:播放第一个章节。 */ function playFirstCourseVideoFromPoster() { playFirstCourseVideo() } function playLatestUploadedVideo() { @@ -754,6 +786,27 @@ handlePaidVideoTap(latestVideo) } function playFirstCourseVideo() { const firstVideo = resolveFirstCourseVideo(allVideos.value) if (!firstVideo) { uni.showToast({ title: '暂无可播放课程视频', icon: 'none', }) return } currentTab.value = 'list' if (canPlayVideo(firstVideo)) { playSelectedVideo(firstVideo) return } handlePaidVideoTap(firstVideo) } function resolveFirstCourseVideo(videos : CourseVideo[]) { return videos.find((item) => !!item?.videoUrl) || null } /** * 优先按上传时间取最新章节;旧接口无时间字段时按自增 id 兜底。 */ traditionalApp/pages/courseDetail/service.js
@@ -1,10 +1,11 @@ import request from '@/utils/request.js' import { publicGet } from '@/utils/public-request.js' /** * 获取 APP 养生课程详情。 */ export const getHealthCourseDetail = (courseId) => { return request.get(`/api/app/health-courses/${courseId}`, {}, false, false) return publicGet(`/api/app/health-courses/${courseId}`) } /** traditionalApp/pages/home/home.vue
@@ -4,16 +4,10 @@ <image src="/static/image/home/bg.png" mode="widthFix" class="bg-img"></image> </view> <!-- Custom Navigation Bar --> <view class="custom-navbar" :style="{ backgroundColor: navbarBackgroundColor }"> <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view> <view class="nav-content"> <view class="home-nav-bar"> <view class="nav-title">海诊康道</view> </view> </view> <!-- Spacer to push the content down below the fixed custom-navbar --> <view class="custom-navbar-spacer" :style="{ height: navbarHeightPx + 'px' }"></view> <view class="content"> <!-- Search Bar --> @@ -36,9 +30,13 @@ </view> </view> <view class="AI" style="width: 702rpx;height: 160rpx;margin: 0 auto;margin-top: 26rpx;" <view class="health-test-entry" hover-class="health-test-entry--pressed" hover-stay-time="80" @tap="goMiniProgram"> <image src="/static/image/home/AI.png" mode="aspectFill" class="w100 h100"></image> <image src="/static/image/home/AI.png" mode="aspectFill" class="health-test-entry__bg"></image> <view class="health-test-entry__cta"> <text>立即测一测</text> <text class="health-test-entry__arrow">›</text> </view> </view> @@ -81,7 +79,7 @@ <view class="course-cover-wrap"> <image :src="item.coverImage || defaultCourseCover" mode="aspectFill" class="course-img"> </image> <view class="course-play-btn" @tap.stop="playLatestCourseVideo(item.id)"> <view class="course-play-btn" @tap.stop="playFirstCourseVideo(item.id)"> <text class="course-play-icon">▶</text> </view> </view> @@ -116,8 +114,8 @@ </template> <script setup lang="ts"> import { ref, onMounted, computed } from "vue"; import { onPageScroll, onPullDownRefresh, onShow } from "@dcloudio/uni-app"; import { ref, onMounted } from "vue"; import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service"; interface HomeBannerItem { @@ -164,7 +162,6 @@ } const statusBarHeight = ref(0); const scrollY = ref(0); const bannerList = ref<HomeBannerItem[]>([]); const bannerActiveIndex = ref(0); const liveCourseList = ref<HomeLiveCourseItem[]>([]); @@ -184,6 +181,9 @@ statusBarHeight.value = res.statusBarHeight || 0; }, }); setTimeout(() => { refreshHomeData(); }, 0); }); /** @@ -202,33 +202,6 @@ } finally { uni.stopPullDownRefresh(); } }); /** * 监听页面滚动以驱动导航栏渐变。 */ onPageScroll((e) => { scrollY.value = e.scrollTop; }); /** * 计算自定义导航栏占位高度。 */ const navbarHeightPx = computed(() => { const navContentHeightRpx = 88; const screenWidthPx = uni.getSystemInfoSync().screenWidth; const navContentHeightPx = (navContentHeightRpx / 750) * screenWidthPx; return statusBarHeight.value + navContentHeightPx; }); /** * 根据滚动距离切换导航栏背景色。 */ const navbarBackgroundColor = computed(() => { const thresholdRpx = 50; const screenWidthPx = uni.getSystemInfoSync().screenWidth; const thresholdPx = (thresholdRpx / 750) * screenWidthPx; return scrollY.value > thresholdPx ? 'white' : 'transparent'; }); /** @@ -304,7 +277,8 @@ liveCourseList.value = []; return; } liveCourseList.value = res.data.filter((item : HomeLiveCourseItem) => !!item?.courseId); const nextList = res.data.filter((item : HomeLiveCourseItem) => !!item?.courseId); liveCourseList.value = nextList; } catch { liveCourseList.value = []; } @@ -322,12 +296,14 @@ return; } const responseData = res.data as HomeFeaturedContentResponse; featuredHealthCourseList.value = Array.isArray(responseData.healthCourses) const nextCourses = Array.isArray(responseData.healthCourses) ? responseData.healthCourses.filter((item : HomeFeaturedHealthCourseItem) => !!item?.id) : []; featuredPopularizationList.value = Array.isArray(responseData.healthPopularizations) const nextPopularizations = Array.isArray(responseData.healthPopularizations) ? responseData.healthPopularizations.filter((item : HomeFeaturedPopularizationItem) => !!item?.id) : []; featuredHealthCourseList.value = nextCourses; featuredPopularizationList.value = nextPopularizations; } catch { featuredHealthCourseList.value = []; featuredPopularizationList.value = []; @@ -343,7 +319,7 @@ } refreshing.value = true; try { await Promise.all([ await Promise.allSettled([ loadBannerList(), loadHomeLiveCourseList(), loadFeaturedContent() @@ -470,8 +446,9 @@ * 跳转直播详情页。 */ const goLiveDetail = (courseId ?: number | string | null) => { const url = courseId ? `/pages/liveDetail/liveDetail?id=${courseId}` : '/pages/liveDetail/liveDetail'; uni.navigateTo({ url: courseId ? `/pages/liveDetail/liveDetail?id=${courseId}` : '/pages/liveDetail/liveDetail' url }); }; @@ -479,20 +456,21 @@ * 跳转养生课程详情页。 */ const goCourseDetail = (courseId ?: number | string | null) => { const url = courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail'; uni.navigateTo({ url: courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail' url }); }; /** * 从首页精选课程直接进入详情并播放最新上传章节。 * 从首页精选课程直接进入详情并播放第一个章节。 */ const playLatestCourseVideo = (courseId ?: number | string | null) => { const playFirstCourseVideo = (courseId ?: number | string | null) => { if (!courseId) { return; } uni.navigateTo({ url: `/pages/courseDetail/courseDetail?id=${courseId}&playLatest=1` url: `/pages/courseDetail/courseDetail?id=${courseId}&playFirst=1` }); }; @@ -500,10 +478,11 @@ * 跳转健康科普详情页。 */ const goPopularizationDetail = (popularizationId ?: number | string | null) => { uni.navigateTo({ url: popularizationId const url = popularizationId ? `/pages/articleDetail/articleDetail?id=${popularizationId}` : '/pages/articleDetail/articleDetail' : '/pages/articleDetail/articleDetail'; uni.navigateTo({ url }); }; @@ -550,33 +529,24 @@ } } .custom-navbar { position: fixed; top: 0; left: 0; width: 100%; z-index: 100; transition: background-color 0.3s ease-in-out; } .status-bar-placeholder { width: 100%; /* height is set dynamically via style binding */ position: relative; z-index: 10; } .nav-content { .home-nav-bar { display: flex; align-items: center; justify-content: center; height: 88rpx; /* Assuming 88rpx for the nav content part */ width: 100%; background-color: transparent; /* Or specific color if needed */ position: relative; z-index: 10; .nav-title { font-size: 36rpx; font-weight: bold; font-weight: 600; color: #333; } } @@ -588,8 +558,6 @@ z-index: 10; // padding: 0 24rpx; padding-top: 24rpx; margin-top: -30rpx; /* Re-added for overlap with top-bg */ } /* Search Box */ @@ -668,6 +636,54 @@ } } .health-test-entry { position: relative; width: 702rpx; height: 160rpx; margin: 26rpx auto 0; border-radius: 20rpx; overflow: hidden; background: #fff7de; border: 2rpx solid rgba(214, 151, 57, 0.22); box-shadow: 0 14rpx 28rpx rgba(156, 103, 31, 0.16); transform: translateZ(0); transition: transform 0.12s ease, box-shadow 0.12s ease; } .health-test-entry--pressed { transform: scale(0.985); box-shadow: 0 8rpx 18rpx rgba(156, 103, 31, 0.12); } .health-test-entry__bg { width: 702rpx; height: 160rpx; display: block; } .health-test-entry__cta { position: absolute; right: 24rpx; bottom: 22rpx; height: 48rpx; padding: 0 18rpx 0 22rpx; border-radius: 999rpx; background: linear-gradient(90deg, #D94436 0%, #BA241F 100%); color: #fff; font-size: 24rpx; font-weight: 600; display: flex; align-items: center; box-shadow: 0 8rpx 18rpx rgba(198, 53, 32, 0.28); } .health-test-entry__arrow { margin-left: 8rpx; font-size: 36rpx; line-height: 1; transform: translateY(-1rpx); } /* Sections */ .section { traditionalApp/pages/home/service.js
@@ -1,24 +1,32 @@ import request from '@/utils/request.js' import { publicGet as rawPublicGet } from '@/utils/public-request.js' /** * 首页是游客可见模块,公共接口不要经过登录态刷新、请求队列或鉴权拦截逻辑。 */ function publicGet(url, data = {}) { return rawPublicGet(url, data) } /** * 获取 APP 首页顶部 Banner 列表。 */ export const getBannerList = () => { return request.get('/api/app/home/banners', {}, false, false) return publicGet('/api/app/home/banners') } /** * 获取 APP 首页养生直播模块列表。 */ export const getHomeLiveCourseList = () => { return request.get('/api/app/home/live-courses', {}, false, false) return publicGet('/api/app/home/live-courses') } /** * 获取 APP 首页精选课程介绍模块数据。 */ export const getHomeFeaturedContent = () => { return request.get('/api/app/home/featured-content', {}, false, false) return publicGet('/api/app/home/featured-content') } /** traditionalApp/pages/liveDetail/liveDetail.vue
@@ -26,9 +26,6 @@ <image v-if="isWatchable" class="play-btn" src="http://obs.haizhentong.com/file/play-bc5effa8a858450a8851f4304a083fb0.png" mode="aspectFit" @tap="handlePrimaryAction"></image> <image v-if="isWatchable" class="fullscreen-icon" src="http://obs.haizhentong.com/file/screen-d29662792d3f4890b5f094333feca7ca.png" mode="aspectFit" @tap="goToWatchLive"></image> </view> </view> @@ -101,7 +98,7 @@ </template> <script setup lang="ts"> import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue' import { onLoad, onShow } from '@dcloudio/uni-app' import { getLiveCourseDetail, reserveLiveCourseSession } from './service' import config from '@/config/index.js' @@ -294,13 +291,30 @@ */ onLoad((options) => { courseId.value = resolveCourseId(options) loadLiveDetail() }) onMounted(() => { setTimeout(() => { const pageOptions = getCurrentPageOptions() if (!courseId.value) { courseId.value = resolveCourseId(pageOptions) } loadLiveDetail() }, 0) }) function getCurrentPageOptions() { const pages = getCurrentPages() const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined return currentPage?.options || currentPage?.$page?.options || {} } /** * 统一解析直播课程 id,兼容 App 路由参数和 H5 分享地址参数。 */ function resolveCourseId(options ?: Record<string, unknown>) { const routeCourseId = typeof options?.id === 'string' ? options.id.trim() : '' const routeCourseId = normalizeRouteParam(options?.id) if (routeCourseId) { return routeCourseId } @@ -317,6 +331,13 @@ } // #endif return '' } function normalizeRouteParam(value : unknown) { if (value === undefined || value === null) { return '' } return String(value).trim() } /** @@ -658,13 +679,6 @@ height: 140rpx; } .fullscreen-icon { position: absolute; right: 24rpx; bottom: 24rpx; width: 48rpx; height: 48rpx; } } .scroll-content { traditionalApp/pages/liveDetail/service.js
@@ -1,10 +1,11 @@ import request from '@/utils/request.js' import { publicGet } from '@/utils/public-request.js' /** * 获取 APP 养生直播详情。 */ export const getLiveCourseDetail = (courseId) => { return request.get(`/api/app/live-courses/${courseId}`, {}, false, false) return publicGet(`/api/app/live-courses/${courseId}`) } /** @@ -18,5 +19,5 @@ * 获取 APP 养生直播专家详情。 */ export const getLiveExpertDetail = (expertId) => { return request.get(`/api/app/live-experts/${expertId}`, {}, false, false) return publicGet(`/api/app/live-experts/${expertId}`) } traditionalApp/pages/login/login.vue
@@ -229,7 +229,7 @@ }) return } saveLoginSession(res.data) saveLoginSession(res.data, normalizedPhone) uni.showToast({ title: '登录成功', icon: 'success' @@ -608,11 +608,14 @@ /** * 将登录响应写入本地缓存,供后续请求与页面读取。 */ const saveLoginSession = (loginData : AppLoginPayload) => { const saveLoginSession = (loginData : AppLoginPayload, fallbackPhone = '') => { if (!loginData) { return } saveAuthSession(loginData, { userOverrides: { phone: normalizeOptionalText(loginData.user?.phone) || fallbackPhone }, redirectMeta: { inviteUserId: inviteUserId.value, inviteType: inviteType.value traditionalApp/pages/mine/mine.vue
@@ -36,7 +36,7 @@ </view> <view class="list-section card-box"> <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`" @tap="goPage(item.url)"> <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`" @tap="goPage(item)"> <view class="list-left"> <image class="list-icon" :class="item.iconClass" :src="item.icon" mode="aspectFit"></image> <text class="list-title">{{ item.title }}</text> @@ -56,6 +56,8 @@ import { getNoticeList } from '../notice/service' import { getUserCenter } from './service' import { getRuntimeVersionName } from '@/utils/app-version.js' import { hasStoredSession } from '@/utils/auth-session.js' import manifestConfig from '../../manifest.json' type UserRole = 'personal' | 'doctor' @@ -88,6 +90,7 @@ icon: string url: string iconClass?: string requiresLogin?: boolean } interface NoticeSummaryItem { @@ -96,13 +99,14 @@ const statusBarHeight = ref(0) const unreadNoticeTotal = ref(0) const appVersionText = ref('1.0.14') const manifestVersionName = String(manifestConfig.versionName || '') const appVersionText = ref(manifestVersionName) const userCenter = ref<UserCenterData>(createDefaultUserCenter()) const userRole = computed<UserRole>(() => { return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal' }) const displayName = computed(() => { return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || '游客' return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || (hasStoredSession() ? '已登录用户' : '游客') }) const displayAvatar = computed(() => { return normalizeOptionalText(userCenter.value.avatar) || '/static/image/mine/header.png' @@ -117,26 +121,26 @@ 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/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' }, { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo' }, { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser' }, { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true }, { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true }, { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true }, { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients', requiresLogin: true }, { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo', requiresLogin: true }, { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser', requiresLogin: true }, ] } return [ { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' }, { 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' }, { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point' }, { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true }, { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord', requiresLogin: true }, { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true }, { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true }, { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point', requiresLogin: true }, ] }) const bottomMenuList = computed<MenuItem[]>(() => { const baseList = [ { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass' }, { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass', requiresLogin: true }, { title: '帮助中心', icon: '/static/image/mine/help.png', url: '/pages/helpCenter/helpCenter', iconClass: 'icon-help' }, { title: '意见反馈', icon: '/static/image/mine/message.png', url: '/pages/feedback/feedback', iconClass: 'icon-feedback' }, ] @@ -253,13 +257,13 @@ * 将接口或缓存中的用户信息归一化为页面可直接消费的结构。 */ function normalizeUserCenter(value: unknown): UserCenterData { const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData> const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData> & Record<string, unknown> const type = Number(source.type) return { id: Number.isFinite(Number(source.id)) ? Number(source.id) : null, nickname: normalizeOptionalText(source.nickname), avatar: normalizeOptionalText(source.avatar), phone: normalizeOptionalText(source.phone), nickname: normalizeOptionalText(source.nickname) || normalizeOptionalText(source.nickName) || normalizeOptionalText(source.name), avatar: normalizeOptionalText(source.avatar) || normalizeOptionalText(source.headImgUrl), phone: normalizeOptionalText(source.phone) || normalizeOptionalText(source.mobile), type: Number.isFinite(type) ? type : 0, typeText: normalizeOptionalText(source.typeText), memberExpireAt: normalizeOptionalText(source.memberExpireAt), @@ -295,14 +299,19 @@ if (!item.url) { return } goPage(item.url) goPage(item) } /** * 统一跳转个人中心相关页面。 */ function goPage(url: string) { function goPage(target: string | MenuItem) { const url = typeof target === 'string' ? target : target.url if (!url) { return } if (typeof target !== 'string' && target.requiresLogin && !hasStoredSession()) { redirectToLogin() return } uni.navigateTo({ @@ -311,13 +320,28 @@ } /** * 未登录访问个人资产类页面时,先进入登录页。 */ function redirectToLogin() { uni.showToast({ title: '请先登录', icon: 'none', }) setTimeout(() => { uni.navigateTo({ url: '/pages/login/login', }) }, 200) } /** * APP 环境读取当前安装包版本,非 APP 环境使用 manifest 版本兜底。 */ function resolveAppVersion() { getRuntimeVersionName('1.0.14').then((versionName) => { appVersionText.value = versionName || '1.0.14' getRuntimeVersionName(manifestVersionName).then((versionName) => { appVersionText.value = versionName || manifestVersionName }).catch(() => { appVersionText.value = '1.0.14' appVersionText.value = manifestVersionName }) } @@ -375,7 +399,7 @@ display: flex; justify-content: flex-end; align-items: center; padding-top: 20rpx; padding-top: 80rpx; gap: 32rpx; .notice-action { @@ -410,7 +434,7 @@ .user-info { display: flex; align-items: center; margin-top: 40rpx; margin-top: 24rpx; .avatar-wrap { position: relative; traditionalApp/pages/protocol/protocol.vue
@@ -11,36 +11,53 @@ <rich-text :nodes="expertIntroNodes" class="section-rich-content"></rich-text> </view> </view> <rich-text v-else :nodes="htmlContent" class="rich-content"></rich-text> <view v-else class="protocol-content"> <rich-text :nodes="htmlContent" class="rich-content"></rich-text> </view> </view> </template> <script setup lang="ts"> import { ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' <script> import { getLiveExpertDetail } from '../liveDetail/service' import { getAgreementContentDetail } from './service' interface ExpertDetail { id?: number | string | null name: string avatar: string specialty: string intro: string } const DEFAULT_USER_AGREEMENT_TITLE = '用户服务协议' const DEFAULT_PRIVACY_TITLE = '隐私协议' const pageType = ref('') const htmlContent = ref('') const expertDetail = ref<ExpertDetail>(createFallbackExpertDetail()) const expertIntroNodes = ref(buildRichTextNodes(createFallbackExpertDetail().intro, '暂无专家介绍')) export default { data() { return { pageType: '', htmlContent: '', expertDetail: this.createFallbackExpertDetail(), expertIntroNodes: this.buildRichTextNodes(this.createFallbackExpertDetail().intro, '暂无专家介绍') } }, onLoad(options) { this.pageType = typeof options?.type === 'string' ? options.type : '' if (this.pageType === 'user') { this.loadAgreementContent(this.pageType) return } if (this.pageType === 'privacy') { this.loadAgreementContent(this.pageType) return } if (this.pageType === 'expert') { uni.setNavigationBarTitle({ title: '专家老师介绍' }) this.loadExpertDetail(typeof options?.id === 'string' ? options.id : '') } }, methods: { /** * 创建专家介绍页的兜底静态数据,避免旧入口无参时页面空白。 */ function createFallbackExpertDetail(): ExpertDetail { createFallbackExpertDetail() { return { id: null, name: '张雨泽', @@ -48,114 +65,102 @@ specialty: '中医文化传播', intro: '专家老师详细介绍内容。张雨泽老师在相关领域拥有多年的丰富经验,致力于传播中医文化,培养优秀的专业人才。', } } }, /** * 加载协议详情页真实接口,未配置时返回空富文本兜底。 */ async function loadAgreementContent(type: string) { async loadAgreementContent(type) { uni.setNavigationBarTitle({ title: resolveAgreementTitle(type), title: this.resolveAgreementTitle(type), }) htmlContent.value = buildRichTextNodes('', '暂无协议内容') this.htmlContent = this.buildRichTextNodes('', '暂无协议内容') try { const response = await getAgreementContentDetail(type) if (response?.code !== 0 || !response.data) { if (!response || response.code !== 0 || !response.data) { return } const agreementName = normalizeText(response.data.agreementName) || resolveAgreementTitle(type) const agreementName = this.normalizeText(response.data.agreementName) || this.resolveAgreementTitle(type) uni.setNavigationBarTitle({ title: agreementName, }) htmlContent.value = buildRichTextNodes(normalizeText(response.data.contentHtml), '暂无协议内容') } catch { const contentHtml = this.normalizeText(response.data.contentHtml) if (!contentHtml) { return } this.htmlContent = this.buildRichTextNodes(contentHtml, '暂无协议内容') } catch (error) { // 请求层会统一处理异常提示,这里保留兜底空内容即可。 } } }, /** * 将接口返回的富文本正文转换为 `rich-text` 可直接渲染的节点字符串。 */ function buildRichTextNodes(content?: string, fallbackText?: string): string { buildRichTextNodes(content, fallbackText) { if (typeof content === 'string' && content.trim()) { return content .replace(/<img/gi, '<img style="max-width:100%;height:auto;margin:0 auto;display:block"') .replace(/<p/gi, '<p style="margin:10px 0;line-height:1.8;word-break:break-all"') .replace(/<div/gi, '<div style="word-break:break-all"') } if (typeof fallbackText === 'string' && fallbackText.trim()) { return `<p>${fallbackText.trim()}</p>` } return '<p></p>' } }, /** * 根据页面类型返回协议页默认标题。 */ function resolveAgreementTitle(type: string) { resolveAgreementTitle(type) { if (type === 'privacy') { return DEFAULT_PRIVACY_TITLE } return DEFAULT_USER_AGREEMENT_TITLE } }, /** * 将可选文本归一化为空字符串。 */ function normalizeText(value: unknown) { normalizeText(value) { if (typeof value !== 'string') { return '' } return value.trim() } }, /** * 加载专家介绍页真实接口,失败时回退到静态内容。 */ async function loadExpertDetail(expertId?: string) { async loadExpertDetail(expertId) { if (!expertId) { expertDetail.value = createFallbackExpertDetail() expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍') this.expertDetail = this.createFallbackExpertDetail() this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍') return } try { const response = await getLiveExpertDetail(expertId) if (response?.code !== 0 || !response.data) { expertDetail.value = createFallbackExpertDetail() expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍') this.expertDetail = this.createFallbackExpertDetail() this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍') return } expertDetail.value = { ...createFallbackExpertDetail(), this.expertDetail = { ...this.createFallbackExpertDetail(), ...response.data, } expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍') this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍') } catch { expertDetail.value = createFallbackExpertDetail() expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍') this.expertDetail = this.createFallbackExpertDetail() this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍') } } /** * 页面加载时根据 type 参数分发到协议页或专家介绍页。 */ onLoad((options) => { pageType.value = typeof options?.type === 'string' ? options.type : '' if (pageType.value === 'user') { loadAgreementContent(pageType.value) return } if (pageType.value === 'privacy') { loadAgreementContent(pageType.value) return } if (pageType.value === 'expert') { uni.setNavigationBarTitle({ title: '专家老师介绍' }) loadExpertDetail(typeof options?.id === 'string' ? options.id : '') } }) </script> <style lang="scss" scoped> @@ -165,8 +170,15 @@ box-sizing: border-box; } .protocol-content { padding: 32rpx; } .rich-content { width: 100%; font-size: 28rpx; line-height: 1.8; color: #333; } .expert-page { traditionalApp/pages/protocol/service.js
@@ -1,8 +1,8 @@ import request from '@/utils/request.js' import { publicGet } from '@/utils/public-request.js' /** * 获取协议内容详情。 */ export const getAgreementContentDetail = (type) => { return request.get(`/api/app/agreement-contents/${type}`, {}, false, false) return publicGet(`/api/app/agreement-contents/${type}`) } traditionalApp/pages/watchLive/watchLive.vue
@@ -15,12 +15,12 @@ <view v-if="isVideoPlayable" class="video-player-wrapper" :style="videoSectionStyle" @tap="handleVideoWrapperTap"> <video id="live-watch-player" class="video-player" :style="videoSectionStyle" :src="liveDetail.videoUrl" :poster="videoPoster" :initial-time="liveInitialTime" :controls="false" :autoplay="false" :show-center-play-btn="false" :show-fullscreen-btn="false" :show-play-btn="false" :show-center-play-btn="showNativeCenterPlayButton" :show-fullscreen-btn="false" :show-play-btn="false" :show-progress="false" :enable-progress-gesture="false" :muted="isReadonlyDisplayMode" object-fit="contain" play-btn-position="center" @loadedmetadata="handleVideoLoadedMetadata" @canplay="handleVideoCanPlay" @timeupdate="handleVideoTimeUpdate" @play="handleVideoPlay" @pause="handleVideoPause" @ended="handleVideoEnded" @error="handleVideoError" @fullscreenchange="handleFullscreenChange"></video> <view class="video-touch-layer"></view> <cover-view v-if="showVideoGestureBlocker" class="video-touch-layer"></cover-view> <view v-if="showAutoPlayLoader" class="video-loader-mask"> <view class="video-loader-ring" :style="videoLoaderRingStyle"> <view class="video-loader-ring__inner"> @@ -29,17 +29,23 @@ </view> </view> </view> <view v-if="showPlaybackRetry" class="video-retry-mask" @tap.stop="retryPlayback"> <text class="video-retry-text">点击开始播放</text> </view> <view v-if="showCustomFullscreenButton" class="video-fullscreen-btn" @tap.stop="toggleFullscreen"> <view class="video-fullscreen-btn__icon"> <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--lt"></view> <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--rt"></view> <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--lb"></view> <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--rb"></view> </view> </view> <cover-view v-if="showManualPlayLoader" class="video-cover-loader-mask"> <cover-view class="video-cover-loader-ring" :style="videoLoaderRingStyle"> <cover-view class="video-cover-loader-ring__inner"> <cover-view class="video-cover-loader-percent">{{ autoPlayLoaderPercentText }}</cover-view> <cover-view class="video-cover-loader-label">加载中</cover-view> </cover-view> </cover-view> </cover-view> <cover-view v-if="showCustomPlayButton" class="video-cover-play-button" @tap="startManualPlayback"> ▶ </cover-view> <cover-view v-if="showEnterFullscreenButton" class="video-cover-fullscreen-btn" @tap="toggleFullscreen"> 全屏 </cover-view> <cover-view v-if="showExitFullscreenButton" class="video-cover-fullscreen-btn video-cover-fullscreen-btn--exit" @tap="toggleFullscreen"> 退出 </cover-view> </view> <image v-else class="video-bg" :src="videoPoster" mode="aspectFill"></image> <view v-if="!isVideoPlayable" class="video-placeholder"> @@ -173,7 +179,7 @@ </template> <script setup lang="ts"> import { computed, getCurrentInstance, nextTick, ref } from 'vue'; import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'; import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'; import { getLiveCourseDetail, getLiveQuizStatus, submitLiveQuiz, uploadLiveWatchRecord } from './service'; import { getHelpCenter } from '../helpCenter/service'; @@ -311,6 +317,8 @@ const playbackFailed = ref(false) const pendingPlaybackSync = ref(false) const playbackSyncAttempts = ref(0) const isManualPlayLoaderVisible = ref(false) const shouldResyncOnNextPlay = ref(false) const lastMediaCurrentTime = ref(0) const currentWatchStartedAt = ref(0) const autoPlayLoaderPercent = ref(0) @@ -322,6 +330,7 @@ let videoContext : ReturnType<typeof uni.createVideoContext> | null = null let playbackStartupTimer : ReturnType<typeof setTimeout> | null = null let playbackSyncTimer : ReturnType<typeof setTimeout> | null = null let manualPlayLoaderTimer : ReturnType<typeof setTimeout> | null = null let quizAvailabilityTimer : ReturnType<typeof setInterval> | null = null let autoPlayLoaderTimer : ReturnType<typeof setInterval> | null = null let autoPlayLoaderFinishTimer : ReturnType<typeof setTimeout> | null = null @@ -517,23 +526,28 @@ const pageTitle = computed(() => liveDetail.value.title || '直播观看') const isReadonlyDisplayMode = isH5 const isLoaderGatedPlayback = computed(() => isReadonlyDisplayMode || isAppPlus) const isLoaderGatedPlayback = computed(() => isReadonlyDisplayMode) const showNativeCenterPlayButton = computed(() => false) const videoPoster = computed(() => liveDetail.value.displayImage || liveDetail.value.coverImage || defaultVideoPoster) const videoSectionStyle = computed(() => ({ height: showAnswer.value ? '500rpx' : '1234rpx', })) const showPlaybackRetry = computed(() => { if (isReadonlyDisplayMode) { return false } if (!isVideoPlayable.value || !isVideoReady.value) { return false } if (playbackFailed.value) { return true } // H5 浏览器禁止未交互自动播放时,直接展示点击播放提示,避免首屏触发 NotAllowedError。 return isH5 && !isPlaybackStarted.value const showCustomPlayButton = computed(() => { return !isReadonlyDisplayMode && isVideoPlayable.value && !isManualPlayLoaderVisible.value && (!isPlaybackStarted.value || isVideoPaused.value || playbackFailed.value) }) const showManualPlayLoader = computed(() => { return !isReadonlyDisplayMode && isVideoPlayable.value && isManualPlayLoaderVisible.value }) const showVideoGestureBlocker = computed(() => { return isVideoPlayable.value && !showAutoPlayLoader.value && !showManualPlayLoader.value && !showCustomPlayButton.value }) const activeSessionId = computed(() => { if (liveDetail.value.actionSessionId != null && liveDetail.value.actionSessionId !== '') { @@ -646,7 +660,7 @@ return '直播已结束,请完成答题' } if (!liveDetail.value.videoUrl) { return '直播视频准备中' return liveDetail.value.displayStatus === 'ENDED' ? '直播已结束,请完成答题' : '直播视频准备中' } return '当前直播暂不可观看' }) @@ -661,10 +675,22 @@ background: `conic-gradient(#ffffff ${Math.min(autoPlayLoaderPercent.value, 100) * 3.6}deg, rgba(255, 255, 255, 0.2) 0deg)`, } }) const showCustomFullscreenButton = computed(() => { return isVideoPlayable.value const showEnterFullscreenButton = computed(() => { return isAppPlus && isVideoPlayable.value && !showAutoPlayLoader.value && !showManualPlayLoader.value && !showCustomPlayButton.value && !playbackFailed.value && !isReadonlyDisplayMode && !isVideoFullscreen.value }) const showExitFullscreenButton = computed(() => { return isAppPlus && isVideoPlayable.value && !playbackFailed.value && !isReadonlyDisplayMode && isVideoFullscreen.value }) /** @@ -676,12 +702,31 @@ sessionId.value = resolvedParams.sessionId }) onMounted(() => { setTimeout(() => { if (courseId.value) { loadLiveDetail() return } const resolvedParams = resolveWatchRouteParams(getCurrentPageOptions()) courseId.value = resolvedParams.courseId sessionId.value = resolvedParams.sessionId loadLiveDetail() }, 0) }) function getCurrentPageOptions() { const pages = getCurrentPages() const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined return currentPage?.options || currentPage?.$page?.options || {} } /** * 统一解析观看页参数,兼容 App 路由和 H5 分享地址。 */ function resolveWatchRouteParams(options ?: Record<string, unknown>) { const routeCourseId = typeof options?.courseId === 'string' ? options.courseId.trim() : '' const routeSessionId = typeof options?.sessionId === 'string' ? options.sessionId.trim() : '' const routeCourseId = normalizeRouteParam(options?.courseId) const routeSessionId = normalizeRouteParam(options?.sessionId) if (routeCourseId || routeSessionId) { return { courseId: routeCourseId, @@ -715,11 +760,23 @@ } } function normalizeRouteParam(value : unknown) { if (value === undefined || value === null) { return '' } return String(value).trim() } /** * 每次展示观看页时刷新详情,保证取到最新可观看视频与答题状态。 */ onShow(() => { loadLiveDetail() const shouldResumeAfterRefresh = !isReadonlyDisplayMode && isVideoPlayable.value && (isVideoPaused.value || playbackFailed.value) loadLiveDetail({ resumeIfPaused: shouldResumeAfterRefresh, }) }) /** @@ -728,7 +785,9 @@ onHide(() => { clearPlaybackStartupTimer() clearPlaybackSyncTimer() clearManualPlayLoaderTimer() clearAutoPlayLoaderTimers() isManualPlayLoaderVisible.value = false clearQuizAvailabilityTimer() submitCurrentWatchSegment() }) @@ -739,7 +798,9 @@ onUnload(() => { clearPlaybackStartupTimer() clearPlaybackSyncTimer() clearManualPlayLoaderTimer() clearAutoPlayLoaderTimers() isManualPlayLoaderVisible.value = false clearQuizAvailabilityTimer() submitCurrentWatchSegment() }) @@ -747,11 +808,12 @@ /** * 拉取直播详情并回填观看页视频信息。 */ async function loadLiveDetail() { async function loadLiveDetail(options : { resumeIfPaused ?: boolean } = {}) { if (!courseId.value) { loadFailed.value = true return } const shouldResumeIfPaused = !!options.resumeIfPaused resetVideoPlaybackState() resetQuizState() loading.value = true @@ -789,6 +851,10 @@ await nextTick() initVideoContext() startAutoPlayLoaderIfNeeded() if (shouldResumeIfPaused && isVideoPlayable.value) { shouldResyncOnNextPlay.value = true retryPlayback() } if (hasLoginToken() && activeSessionId.value) { await loadQuizStatus() } @@ -850,6 +916,7 @@ function resetVideoPlaybackState() { clearPlaybackStartupTimer() clearPlaybackSyncTimer() clearManualPlayLoaderTimer() clearAutoPlayLoaderTimers() currentWatchStartedAt.value = 0 isVideoReady.value = false @@ -861,6 +928,8 @@ playbackFailed.value = false pendingPlaybackSync.value = false playbackSyncAttempts.value = 0 isManualPlayLoaderVisible.value = false shouldResyncOnNextPlay.value = false lastMediaCurrentTime.value = 0 autoPlayLoaderPercent.value = 0 isAutoPlayLoaderVisible.value = false @@ -1021,6 +1090,48 @@ } /** * 清理手动播放加载遮罩的进度定时器。 */ function clearManualPlayLoaderTimer() { if (manualPlayLoaderTimer) { clearTimeout(manualPlayLoaderTimer) manualPlayLoaderTimer = null } } /** * 推进手动播放加载环到 99%,真实播放成功后再收尾到 100%。 */ function updateManualPlayLoaderProgress(startTimestamp : number) { const elapsed = Date.now() - startTimestamp autoPlayLoaderPercent.value = Math.min( AUTO_PLAY_LOADER_TARGET, Math.floor((elapsed / AUTO_PLAY_LOADER_DURATION) * AUTO_PLAY_LOADER_TARGET), ) if (!isManualPlayLoaderVisible.value) { manualPlayLoaderTimer = null return } manualPlayLoaderTimer = setTimeout(() => { updateManualPlayLoaderProgress(startTimestamp) }, 60) } /** * 播放真正成功后,把手动加载环补到 100% 并立即关闭遮罩。 */ function finishManualPlayLoader() { if (!isManualPlayLoaderVisible.value) { return } clearManualPlayLoaderTimer() autoPlayLoaderPercent.value = 100 setTimeout(() => { isManualPlayLoaderVisible.value = false }, 120) } /** * 清理播放进度同步重试定时器,避免页面隐藏后继续 seek。 */ function clearPlaybackSyncTimer() { @@ -1045,7 +1156,7 @@ } /** * 进入伪直播后先展示加载环,再进入横屏全屏并按当前直播进度播放。 * 进入伪直播后先展示加载环,加载完成后交给用户手动播放。 */ function startAutoPlayLoaderIfNeeded() { if (!isLoaderGatedPlayback.value || !isVideoPlayable.value || hasStartedReadonlyAutoPlay.value) { @@ -1066,9 +1177,7 @@ if (elapsed >= AUTO_PLAY_LOADER_DURATION) { clearAutoPlayLoaderTimers() autoPlayLoaderPercent.value = AUTO_PLAY_LOADER_TARGET hasQueuedReadonlyAutoPlay.value = true requestLandscapeFullscreenIfNeeded() tryStartPlayback() isAutoPlayLoaderVisible.value = false } }, 60) } @@ -1095,6 +1204,8 @@ isStartingPlayback.value = false playbackFailed.value = true isVideoPaused.value = true clearManualPlayLoaderTimer() isManualPlayLoaderVisible.value = false } }, 1200) } @@ -1232,17 +1343,19 @@ } return } syncPlaybackWhenReady(true) isVideoPaused.value = true if (pendingPlaybackSync.value || hasQueuedReadonlyAutoPlay.value) { syncPlaybackWhenReady(hasQueuedReadonlyAutoPlay.value) } } /** * 视频区域点击仅保留给 App 端暂停/恢复,分享页不提供手动播放按钮。 */ function handleVideoWrapperTap() { if (isReadonlyDisplayMode) { if (!isReadonlyDisplayMode) { return } togglePlayback() } /** @@ -1262,7 +1375,39 @@ /** * 手动重试当前视频播放,复用统一的自动播放拉起逻辑。 */ function startManualPlayback() { if (!isVideoPlayable.value || isManualPlayLoaderVisible.value) { return } clearManualPlayLoaderTimer() autoPlayLoaderPercent.value = 0 playbackFailed.value = false isVideoPaused.value = false isManualPlayLoaderVisible.value = true updateManualPlayLoaderProgress(Date.now()) retryPlayback() } /** * 按当前伪直播进度拉起播放。 */ function retryPlayback() { if (!videoContext) { initVideoContext() } hasQueuedReadonlyAutoPlay.value = true isVideoPaused.value = false playbackFailed.value = false if (!supportsSimulatedPlayback.value || !isVideoReady.value || mediaDurationSeconds.value <= 0) { pendingPlaybackSync.value = true triggerVideoPlay() return } if (shouldResyncOnNextPlay.value) { shouldResyncOnNextPlay.value = false syncPlaybackWhenReady(true) return } tryStartPlayback() } @@ -1336,6 +1481,17 @@ isPlaybackStarted.value = true playbackFailed.value = false isVideoPaused.value = false finishManualPlayLoader() if (shouldResyncOnNextPlay.value && supportsSimulatedPlayback.value && isVideoReady.value && mediaDurationSeconds.value > 0) { shouldResyncOnNextPlay.value = false seekToExpectedPlayback(false, false) } if (!hasQueuedReadonlyAutoPlay.value) { hasQueuedReadonlyAutoPlay.value = true if (supportsSimulatedPlayback.value && isVideoReady.value && mediaDurationSeconds.value > 0) { seekToExpectedPlayback(false, false) } } if (isLoaderGatedPlayback.value) { clearAutoPlayLoaderTimers() autoPlayLoaderPercent.value = 100 @@ -1373,6 +1529,14 @@ if (isSyncingPlayback.value) { return } if (isVideoFullscreen.value) { isVideoPaused.value = false if (!isStartingPlayback.value) { shouldResyncOnNextPlay.value = true retryPlayback() } return } if (isStartingPlayback.value && !currentWatchStartedAt.value) { clearPlaybackStartupTimer() isStartingPlayback.value = false @@ -1408,11 +1572,13 @@ */ function handleVideoError(event : VideoErrorEvent) { clearPlaybackStartupTimer() clearManualPlayLoaderTimer() clearAutoPlayLoaderTimers() isStartingPlayback.value = false isPlaybackStarted.value = false playbackFailed.value = true isVideoPaused.value = true isManualPlayLoaderVisible.value = false isAutoPlayLoaderVisible.value = false if (event?.detail?.errMsg) { uni.showToast({ @@ -1670,16 +1836,19 @@ } /** * 记录全屏切换状态,驱动自绘按钮文案与退出逻辑。 * 记录原生全屏状态,驱动自绘全屏按钮。 */ function handleFullscreenChange(event : VideoFullscreenChangeEvent) { isVideoFullscreen.value = !!(event?.detail?.fullScreen || event?.detail?.fullscreen) } /** * 使用自绘按钮触发全屏,避免依赖原生播放控制条。 * 使用原生 video 全屏,避免伪全屏放大页面;原生 controls 仍保持关闭。 */ function toggleFullscreen() { if (!videoContext) { initVideoContext() } if (!videoContext) { return } @@ -1689,6 +1858,9 @@ } videoContext.requestFullScreen({ direction: 90, success: () => { isVideoFullscreen.value = true }, }) } </script> @@ -1822,87 +1994,105 @@ line-height: 1.2; } .video-retry-mask { .video-cover-play-button { position: absolute; inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.38); } .video-retry-text { padding: 20rpx 36rpx; border-radius: 999rpx; background: rgba(255, 255, 255, 0.18); left: 315rpx; top: 50%; z-index: 101; width: 120rpx; height: 120rpx; line-height: 120rpx; margin-top: -60rpx; border-radius: 60rpx; background-color: rgba(0, 0, 0, 0.68); color: #ffffff; font-size: 28rpx; line-height: 40rpx; font-size: 52rpx; font-weight: 600; text-align: center; } .video-fullscreen-btn { .video-cover-loader-mask { position: absolute; right: 24rpx; bottom: 24rpx; z-index: 4; width: 76rpx; height: 76rpx; border-radius: 20rpx; background: rgba(0, 0, 0, 0.52); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(8rpx); left: 0; top: 0; z-index: 102; width: 750rpx; height: 100%; background-color: rgba(0, 0, 0, 0.28); } .video-fullscreen-btn__icon { position: relative; width: 32rpx; .video-cover-loader-ring { position: absolute; left: 285rpx; top: 50%; width: 180rpx; height: 180rpx; margin-top: -90rpx; border-radius: 90rpx; padding: 10rpx; box-sizing: border-box; background-color: rgba(255, 255, 255, 0.2); } .video-cover-loader-ring__inner { position: absolute; left: 10rpx; top: 10rpx; width: 160rpx; height: 160rpx; border-radius: 80rpx; background-color: rgba(12, 12, 12, 0.78); } .video-cover-loader-percent { position: absolute; left: 0; top: 42rpx; width: 160rpx; height: 48rpx; line-height: 48rpx; color: #ffffff; font-size: 40rpx; font-weight: 600; text-align: center; } .video-cover-loader-label { position: absolute; left: 0; top: 96rpx; width: 160rpx; height: 32rpx; line-height: 32rpx; color: rgba(255, 255, 255, 0.82); font-size: 22rpx; text-align: center; } .video-fullscreen-btn__corner { .video-cover-fullscreen-btn { position: absolute; width: 12rpx; height: 12rpx; border-color: #ffffff; right: 28rpx; bottom: 28rpx; z-index: 120; width: 88rpx; height: 88rpx; line-height: 88rpx; border-radius: 44rpx; border-width: 2rpx; border-style: solid; border-color: rgba(255, 255, 255, 0.9); background-color: rgba(0, 0, 0, 0.86); color: #ffffff; font-size: 42rpx; font-weight: 600; text-align: center; } .video-fullscreen-btn__corner--lt { top: 0; left: 0; border-top-width: 3rpx; border-left-width: 3rpx; border-top-style: solid; border-left-style: solid; } .video-fullscreen-btn__corner--rt { top: 0; right: 0; border-top-width: 3rpx; border-right-width: 3rpx; border-top-style: solid; border-right-style: solid; } .video-fullscreen-btn__corner--lb { left: 0; bottom: 0; border-bottom-width: 3rpx; border-left-width: 3rpx; border-bottom-style: solid; border-left-style: solid; } .video-fullscreen-btn__corner--rb { right: 0; bottom: 0; border-right-width: 3rpx; border-bottom-width: 3rpx; border-right-style: solid; border-bottom-style: solid; .video-cover-fullscreen-btn--exit { position: fixed; right: 32rpx; top: 32rpx; z-index: 9999; } .video-placeholder { traditionalApp/utils/public-request.js
New file @@ -0,0 +1,26 @@ import config from '@/config/index.js' /** * 游客可访问接口专用请求,避免被本地登录态刷新、旧 token、请求队列影响。 */ export function publicGet(url, data = {}) { const requestUrl = `${config.BASE_URL}${url}` return new Promise((resolve, reject) => { uni.request({ url: requestUrl, method: 'GET', data, timeout: 10000, header: { 'content-type': 'application/json', lang: uni.getStorageSync('locale') === 'zh-Hans' ? 'zh_CN' : 'zh-tw', }, success: (res) => { resolve(res.data || {}) }, fail: (error) => { reject(error) }, }) }) } traditionalApp/utils/push.js
@@ -93,7 +93,7 @@ return null } if (typeof jpushModule.setLoggerEnable === 'function') { jpushModule.setLoggerEnable(true) jpushModule.setLoggerEnable(false) } if (typeof jpushModule.initJPushService === 'function') { jpushModule.initJPushService() @@ -101,7 +101,6 @@ if (typeof jpushModule.addConnectEventListener === 'function') { jpushModule.addConnectEventListener((result) => { const connectEnable = !!(result && result.connectEnable) console.log('极光连接状态', result) if (!connectEnable) { return } @@ -110,7 +109,6 @@ } if (typeof jpushModule.addNotificationListener === 'function') { jpushModule.addNotificationListener((result) => { console.log('48948949841weqwe', result); handleJPushNotification(result) }) } traditionalApp/utils/request.js
@@ -18,9 +18,14 @@ const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000 const SUCCESS_CODES = [0, 200] const UNAUTHORIZED_CODES = [401, 504, 505, 510] const SESSION_INVALID_MESSAGES = ['用户不存在', '用户已禁用'] const PENDING_BIND_ALLOWED_URLS = [ '/api/app/settings/phone/code', '/api/app/settings/phone/bind', ] const PUBLIC_APP_URL_PREFIXES = [ '/api/app/home/', '/api/v1/app/home/', ] const waitingList = [] @@ -49,6 +54,17 @@ } /** * 后端部分已登录接口会以业务错误返回“用户不存在/已禁用”,这里统一视为登录态失效。 */ function isSessionInvalidPayload(payload) { if (!getStoredToken()) { return false } const message = resolveMessage(payload).trim() return SESSION_INVALID_MESSAGES.includes(message) } /** * 读取当前语言请求头值。 */ function resolveLangHeader() { @@ -59,21 +75,35 @@ * 构造当前请求头。 */ function buildHeaders(type, url) { return { const authorization = resolveAuthorizationHeader(url) const headers = { 'content-type': type ? 'application/x-www-form-urlencoded;charset=UTF-8' : 'application/json', Authorization: resolveAuthorizationHeader(url), lang: resolveLangHeader(), } if (authorization) { headers.Authorization = authorization } return headers } /** * 解析请求鉴权头,待绑定手机号临时 token 仅允许访问绑定手机号接口。 */ function resolveAuthorizationHeader(url) { if (isPublicAppUrl(url)) { return '' } if (!hasPendingPhoneBindSession()) { return getStoredToken() } return isPendingBindAllowedUrl(url) ? getStoredToken() : '' } /** * 首页公共接口允许游客访问,不能被本地旧登录态或 token 刷新失败影响。 */ function isPublicAppUrl(url) { return PUBLIC_APP_URL_PREFIXES.some((prefix) => url.startsWith(prefix)) } /** @@ -122,6 +152,11 @@ } if (payload.code === 502 || payload.code === 501) { showErrorToast(payload) resolve(payload) return } if (isSessionInvalidPayload(payload)) { handleUnauthorized(payload) resolve(payload) return } @@ -252,6 +287,9 @@ * 判断当前请求是否需要在发送前尝试刷新 token。 */ function shouldAttemptTokenRefresh(url) { if (isPublicAppUrl(url)) { return false } const token = getStoredToken() if (!token || !hasBoundPhone()) { return false @@ -287,7 +325,7 @@ resolve(payload) return } if (UNAUTHORIZED_CODES.includes(payload.code)) { if (UNAUTHORIZED_CODES.includes(payload.code) || isSessionInvalidPayload(payload)) { handleUnauthorized(payload) } reject(payload)