| tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseVideoVO.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| tcm-training-admin/.npmrc | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/config/index.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/courseDetail/courseDetail.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/home/home.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/liveDetail/liveDetail.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/mine/mine.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/watchLive/watchLive.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/utils/app-version.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/utils/openinstall-h5.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
@@ -1005,6 +1005,8 @@ videoVO.setLocked(Boolean.valueOf(locked)); // 已购买章节需要直接拿到视频地址,前端才能切换为下载入口。 videoVO.setVideoUrl(trimToEmpty(video.getVideoUrl())); videoVO.setCreatedAt(video.getCreatedAt()); videoVO.setUpdatedAt(video.getUpdatedAt()); result.add(videoVO); } return result; tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseVideoVO.java
@@ -4,6 +4,7 @@ import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; /** * APP 养生课程章节响应。 @@ -29,4 +30,8 @@ private Boolean locked; private String videoUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; } tcm-training-admin/.npmrc
New file @@ -0,0 +1 @@ legacy-peer-deps=true traditionalApp/config/index.js
@@ -1,6 +1,7 @@ export default { // BASE_URL: 'http://192.168.110.188:48080', BASE_URL: 'https://haizhentong.com', OPENINSTALL_URL: 'https://m.malink.cn/s/7bIzeu', APP_STORE_URL: '', uploadUrl: "https://haizhentong.com/api/open/file/upload", imageUrl: 'http://221.182.45.100:11224/images/' traditionalApp/pages/courseDetail/courseDetail.vue
@@ -13,8 +13,10 @@ <view v-if="showVideoPlayer" class="video-player-wrapper"> <video id="course-detail-video-player" class="video-player" :src="currentVideoUrl" :poster="currentVideoPoster" :controls="true" :autoplay="isAppPlus" object-fit="contain" play-btn-position="center" @loadedmetadata="handleVideoLoadedMetadata" @play="handleVideoPlay" @error="handleVideoError"></video> play-btn-position="center" :show-progress="true" :enable-progress-gesture="true" :show-fullscreen-btn="true" :show-play-btn="true" :show-center-play-btn="true" :direction="90" @loadedmetadata="handleVideoLoadedMetadata" @play="handleVideoPlay" @error="handleVideoError"> </video> <wd-watermark v-if="videoWatermarkText" class="video-watermark" :content="videoWatermarkText" :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" /> @@ -28,6 +30,10 @@ <image class="swiper-img" :src="item" mode="aspectFill"></image> </swiper-item> </swiper> <view v-if="!isReadonlyDisplayMode && allVideos.length > 0" class="poster-play-btn" @tap.stop="playLatestUploadedVideoFromPoster"> <text class="poster-play-icon">▶</text> </view> <!-- 图片指示器 --> <view v-if="swiperList.length > 0" class="indicator"> <text>{{ currentSwiperIndex + 1 }}/{{ swiperList.length }}</text> @@ -316,6 +322,7 @@ import { startCourseVideoDownload, hasCourseDownloadTask } from '@/utils/course-download.js' import { requestAppleIapPayment } from '@/utils/appleIap.js' import { resolveCurrentAppUserId } from '@/utils/app-user.js' import { wakeupOrInstallByOpeninstall } from '@/utils/openinstall-h5.js' import { bindVideoCaptureListener, buildVideoWatermarkText, @@ -345,6 +352,8 @@ appleProductId ?: string productId ?: string iosProductId ?: string createdAt ?: string updatedAt ?: string } interface CourseDetail { @@ -407,6 +416,7 @@ const activePurchaseTarget = ref<PurchaseTarget>('COURSE') const activePurchaseVideoId = ref<number | null>(null) const courseId = ref('') const shouldPlayLatestVideoOnLoad = ref(false) const courseDetail = ref<CourseDetail>(createDefaultCourseDetail()) const activeVideoId = ref<number>(0) const showVideoPlayer = ref(false) @@ -530,6 +540,7 @@ */ onLoad((options) => { courseId.value = resolveCourseId(options) shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(options, 'playLatest') loadCourseDetail() }) @@ -554,6 +565,37 @@ } // #endif return '' } function normalizeOptionalText(value : unknown) { if (value === undefined || value === null) { return '' } return String(value).trim() } /** * 读取路由布尔参数,兼容 App 路由和 H5 分享地址。 */ function resolveBooleanRouteParam(options : Record<string, unknown> | undefined, key : string) { const routeValue = normalizeOptionalText(options?.[key]) if (routeValue) { return routeValue === '1' || routeValue.toLowerCase() === 'true' } // #ifdef H5 try { const searchValue = new URLSearchParams(window.location.search).get(key)?.trim() || '' if (searchValue) { return searchValue === '1' || searchValue.toLowerCase() === 'true' } const hashQuery = window.location.hash.split('?')[1] || '' const hashValue = new URLSearchParams(hashQuery).get(key)?.trim() || '' return hashValue === '1' || hashValue.toLowerCase() === 'true' } catch (error) { console.log('解析课程播放参数失败', error) } // #endif return false } /** @@ -624,6 +666,7 @@ currentSwiperIndex.value = 0 } syncActiveVideoAfterDetailLoad() handlePlayLatestVideoAfterDetailLoad() } catch { showLoadFailedToast() } finally { @@ -674,6 +717,78 @@ return } resetVideoPlayerState() } /** * 首页播放入口要求直接播放最新上传章节,详情接口返回后再执行。 */ function handlePlayLatestVideoAfterDetailLoad() { if (!shouldPlayLatestVideoOnLoad.value) { return } shouldPlayLatestVideoOnLoad.value = false playLatestUploadedVideo() } /** * 课程介绍封面播放按钮:播放最新上传章节。 */ function playLatestUploadedVideoFromPoster() { playLatestUploadedVideo() } function playLatestUploadedVideo() { const latestVideo = resolveLatestUploadedVideo(allVideos.value) if (!latestVideo) { uni.showToast({ title: '暂无可播放课程视频', icon: 'none', }) return } currentTab.value = 'list' if (canPlayVideo(latestVideo)) { playSelectedVideo(latestVideo) return } handlePaidVideoTap(latestVideo) } /** * 优先按上传时间取最新章节;旧接口无时间字段时按自增 id 兜底。 */ function resolveLatestUploadedVideo(videos : CourseVideo[]) { const playableVideos = videos.filter((item) => !!item?.videoUrl) if (playableVideos.length === 0) { return null } return [...playableVideos].sort((a, b) => { const timeDiff = resolveVideoUploadedTime(b) - resolveVideoUploadedTime(a) if (timeDiff !== 0) { return timeDiff } return Number(b.id || 0) - Number(a.id || 0) })[0] } function resolveVideoUploadedTime(item : CourseVideo) { const createdTime = parseRouteTime(item.createdAt) if (createdTime > 0) { return createdTime } return parseRouteTime(item.updatedAt) } function parseRouteTime(value ?: string) { if (!value) { return 0 } const parsed = Date.parse(value) if (Number.isFinite(parsed)) { return parsed } const fallbackParsed = Date.parse(value.replace(/-/g, '/')) return Number.isFinite(fallbackParsed) ? fallbackParsed : 0 } /** @@ -889,13 +1004,23 @@ /** * 拉起或跳转下载 APP */ function openApp() { const openAppUrl = 'https://m.malink.cn/s/7bIzeu' async function openApp() { // #ifdef H5 window.location.href = openAppUrl const openinstallOpened = await wakeupOrInstallByOpeninstall({ page: 'courseDetail', courseId: String(resolveCurrentCourseId() || '').trim() }) if (openinstallOpened) { return } uni.showToast({ title: '打开APP失败,请稍后重试', icon: 'none', }) return // #endif // #ifdef APP-PLUS const openAppUrl = config.OPENINSTALL_URL || config.BASE_URL plus.runtime.openURL(openAppUrl) return // #endif @@ -1443,6 +1568,31 @@ height: 100%; } .poster-play-btn { position: absolute; left: 50%; top: 50%; width: 132rpx; height: 132rpx; margin-left: -66rpx; margin-top: -66rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.42); border: 4rpx solid rgba(255, 255, 255, 0.9); display: flex; align-items: center; justify-content: center; z-index: 3; box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.24); } .poster-play-icon { color: #fff; font-size: 58rpx; line-height: 1; margin-left: 8rpx; } .video-player-wrapper, .video-player { width: 100%; @@ -1924,4 +2074,4 @@ background-color: #E5E6EB; color: #86909C; } </style> </style> traditionalApp/pages/home/home.vue
@@ -78,8 +78,13 @@ <view v-if="featuredHealthCourseList.length" class="course-list"> <view v-for="item in featuredHealthCourseList" :key="item.id" class="course-item" @tap="goCourseDetail(item.id)"> <image :src="item.coverImage || defaultCourseCover" mode="aspectFill" class="course-img"> </image> <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)"> <text class="course-play-icon">▶</text> </view> </view> <view class="course-info"> <view class="course-top"> <text v-if="resolveCourseTag(item)" @@ -480,6 +485,18 @@ }; /** * 从首页精选课程直接进入详情并播放最新上传章节。 */ const playLatestCourseVideo = (courseId ?: number | string | null) => { if (!courseId) { return; } uni.navigateTo({ url: `/pages/courseDetail/courseDetail?id=${courseId}&playLatest=1` }); }; /** * 跳转健康科普详情页。 */ const goPopularizationDetail = (popularizationId ?: number | string | null) => { @@ -788,13 +805,43 @@ box-sizing: border-box; display: flex; .course-img { .course-cover-wrap { width: 160rpx; height: 160rpx; border-radius: 16rpx; margin-right: 24rpx; flex-shrink: 0; background: #eee; position: relative; .course-img { width: 160rpx; height: 160rpx; border-radius: 16rpx; background: #eee; } .course-play-btn { position: absolute; left: 50%; top: 50%; width: 64rpx; height: 64rpx; margin-left: -32rpx; margin-top: -32rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.58); border: 2rpx solid rgba(255, 255, 255, 0.86); display: flex; align-items: center; justify-content: center; box-shadow: 0 8rpx 18rpx rgba(0, 0, 0, 0.2); .course-play-icon { color: #FFF; font-size: 28rpx; line-height: 1; margin-left: 4rpx; } } } .course-info { traditionalApp/pages/liveDetail/liveDetail.vue
@@ -105,6 +105,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app' import { getLiveCourseDetail, reserveLiveCourseSession } from './service' import config from '@/config/index.js' import { wakeupOrInstallByOpeninstall } from '@/utils/openinstall-h5.js' type LiveDisplayStatus = 'NOT_STARTED' | 'LIVING' | 'ENDED' type LiveActionStatus = 'RESERVABLE' | 'RESERVED' | 'WATCHABLE' | 'CLOSED' @@ -481,13 +482,24 @@ /** * H5 分享页点击底部横条后跳转统一拉起链接。 */ function openApp() { const openAppUrl = 'https://m.malink.cn/s/7bIzeu' async function openApp() { // #ifdef H5 window.location.href = openAppUrl const openinstallOpened = await wakeupOrInstallByOpeninstall({ page: 'liveDetail', courseId: String(liveDetail.value.courseId || courseId.value || '').trim(), sessionId: String(liveDetail.value.actionSessionId || '').trim() }) if (openinstallOpened) { return } uni.showToast({ title: '打开APP失败,请稍后重试', icon: 'none', }) return // #endif // #ifdef APP-PLUS const openAppUrl = config.OPENINSTALL_URL || config.BASE_URL plus.runtime.openURL(openAppUrl) return // #endif traditionalApp/pages/mine/mine.vue
@@ -44,6 +44,7 @@ <u-icon name="arrow-right" color="#999" size="16"></u-icon> </view> </view> <view class="app-version-text">海诊康道 版本{{ appVersionText }}</view> <view class="icp-text">ICP备案号:琼ICP备2025059220号-3A</view> </view> </view> @@ -54,6 +55,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app' import { getNoticeList } from '../notice/service' import { getUserCenter } from './service' import { getRuntimeVersionName } from '@/utils/app-version.js' type UserRole = 'personal' | 'doctor' @@ -94,6 +96,7 @@ const statusBarHeight = ref(0) const unreadNoticeTotal = ref(0) const appVersionText = ref('1.0.14') const userCenter = ref<UserCenterData>(createDefaultUserCenter()) const userRole = computed<UserRole>(() => { return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal' @@ -175,6 +178,7 @@ onLoad(() => { const systemInfo = uni.getSystemInfoSync() statusBarHeight.value = systemInfo.statusBarHeight || 20 resolveAppVersion() syncCachedUserInfo() }) @@ -303,6 +307,17 @@ } uni.navigateTo({ url, }) } /** * APP 环境读取当前安装包版本,非 APP 环境使用 manifest 版本兜底。 */ function resolveAppVersion() { getRuntimeVersionName('1.0.14').then((versionName) => { appVersionText.value = versionName || '1.0.14' }).catch(() => { appVersionText.value = '1.0.14' }) } @@ -594,11 +609,19 @@ } } .app-version-text, .icp-text { font-size: 28rpx; color: #86909C; text-align: center; } .app-version-text { margin-top: 32rpx; } .icp-text { margin-top: 8rpx; padding-bottom: 20rpx; } </style> traditionalApp/pages/watchLive/watchLive.vue
@@ -12,17 +12,33 @@ <!-- 视频区域 --> <view class="video-section" :style="videoSectionStyle"> <view v-if="isVideoPlayable" class="video-player-wrapper" :style="videoSectionStyle" @tap="togglePlayback"> <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="isAppPlus" :poster="videoPoster" :initial-time="liveInitialTime" :controls="false" :autoplay="false" :show-center-play-btn="false" :show-fullscreen-btn="false" :show-play-btn="false" :show-progress="false" :enable-progress-gesture="false" object-fit="contain" :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"></video> @ended="handleVideoEnded" @error="handleVideoError" @fullscreenchange="handleFullscreenChange"></video> <view class="video-touch-layer"></view> <view v-if="showAutoPlayLoader" class="video-loader-mask"> <view class="video-loader-ring" :style="videoLoaderRingStyle"> <view class="video-loader-ring__inner"> <text class="video-loader-percent">{{ autoPlayLoaderPercentText }}</text> <text class="video-loader-label">加载中</text> </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> </view> <image v-else class="video-bg" :src="videoPoster" mode="aspectFill"></image> @@ -163,6 +179,7 @@ import { getHelpCenter } from '../helpCenter/service'; import config from '@/config/index.js'; import { hasCompletedAuthSession } from '@/utils/auth-session.js'; import { wakeupOrInstallByOpeninstall } from '@/utils/openinstall-h5.js' type LiveActionStatus = 'RESERVABLE' | 'RESERVED' | 'WATCHABLE' | 'CLOSED' type QuizViewState = 'answering' | 'success' | 'alreadyAwarded' | 'wrong' | 'unopened' @@ -181,6 +198,12 @@ detail ?: { currentTime ?: number duration ?: number } } type VideoFullscreenChangeEvent = { detail ?: { fullScreen ?: boolean fullscreen ?: boolean } } @@ -256,6 +279,8 @@ } const VIDEO_PLAYER_ID = 'live-watch-player' const AUTO_PLAY_LOADER_DURATION = 3000 const AUTO_PLAY_LOADER_TARGET = 99 const defaultVideoPoster = 'http://obs.haizhentong.com/file/class-01479c4bc4e04f63b0425023eb1652d5.png' let isAppPlus = false let isH5 = false @@ -288,11 +313,18 @@ const playbackSyncAttempts = ref(0) const lastMediaCurrentTime = ref(0) const currentWatchStartedAt = ref(0) const autoPlayLoaderPercent = ref(0) const isAutoPlayLoaderVisible = ref(false) const hasQueuedReadonlyAutoPlay = ref(false) const hasStartedReadonlyAutoPlay = ref(false) const isVideoFullscreen = ref(false) const instance = getCurrentInstance() let videoContext : ReturnType<typeof uni.createVideoContext> | null = null let playbackStartupTimer : ReturnType<typeof setTimeout> | null = null let playbackSyncTimer : ReturnType<typeof setTimeout> | null = null let quizAvailabilityTimer : ReturnType<typeof setInterval> | null = null let autoPlayLoaderTimer : ReturnType<typeof setInterval> | null = null let autoPlayLoaderFinishTimer : ReturnType<typeof setTimeout> | null = null /** * 生成观看页默认直播详情,避免模板访问空对象。 @@ -485,11 +517,15 @@ const pageTitle = computed(() => liveDetail.value.title || '直播观看') const isReadonlyDisplayMode = isH5 const isLoaderGatedPlayback = computed(() => isReadonlyDisplayMode || isAppPlus) 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 } @@ -614,6 +650,22 @@ } return '当前直播暂不可观看' }) const showAutoPlayLoader = computed(() => { return isLoaderGatedPlayback.value && isVideoPlayable.value && isAutoPlayLoaderVisible.value }) const autoPlayLoaderPercentText = computed(() => `${Math.min(autoPlayLoaderPercent.value, 100)}%`) const videoLoaderRingStyle = computed(() => { return { 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 && !showAutoPlayLoader.value && !playbackFailed.value }) /** * 进入页面时记录课程 ID 与场次 ID。 @@ -676,6 +728,7 @@ onHide(() => { clearPlaybackStartupTimer() clearPlaybackSyncTimer() clearAutoPlayLoaderTimers() clearQuizAvailabilityTimer() submitCurrentWatchSegment() }) @@ -686,6 +739,7 @@ onUnload(() => { clearPlaybackStartupTimer() clearPlaybackSyncTimer() clearAutoPlayLoaderTimers() clearQuizAvailabilityTimer() submitCurrentWatchSegment() }) @@ -734,6 +788,7 @@ } await nextTick() initVideoContext() startAutoPlayLoaderIfNeeded() if (hasLoginToken() && activeSessionId.value) { await loadQuizStatus() } @@ -787,10 +842,6 @@ return } videoContext = uni.createVideoContext(VIDEO_PLAYER_ID, instance?.proxy) if (isAppPlus) { // App 端在上下文创建完成后主动补一次拉起,避免仅依赖组件 autoplay 导致首屏停帧。 schedulePlaybackBootstrap() } } /** @@ -799,6 +850,7 @@ function resetVideoPlaybackState() { clearPlaybackStartupTimer() clearPlaybackSyncTimer() clearAutoPlayLoaderTimers() currentWatchStartedAt.value = 0 isVideoReady.value = false isVideoPaused.value = false @@ -810,6 +862,11 @@ pendingPlaybackSync.value = false playbackSyncAttempts.value = 0 lastMediaCurrentTime.value = 0 autoPlayLoaderPercent.value = 0 isAutoPlayLoaderVisible.value = false hasQueuedReadonlyAutoPlay.value = false hasStartedReadonlyAutoPlay.value = false isVideoFullscreen.value = false } /** @@ -974,6 +1031,61 @@ } /** * 清理分享页自动播放加载环的定时器。 */ function clearAutoPlayLoaderTimers() { if (autoPlayLoaderTimer) { clearInterval(autoPlayLoaderTimer) autoPlayLoaderTimer = null } if (autoPlayLoaderFinishTimer) { clearTimeout(autoPlayLoaderFinishTimer) autoPlayLoaderFinishTimer = null } } /** * 进入伪直播后先展示加载环,再进入横屏全屏并按当前直播进度播放。 */ function startAutoPlayLoaderIfNeeded() { if (!isLoaderGatedPlayback.value || !isVideoPlayable.value || hasStartedReadonlyAutoPlay.value) { return } clearAutoPlayLoaderTimers() hasStartedReadonlyAutoPlay.value = true autoPlayLoaderPercent.value = 0 isAutoPlayLoaderVisible.value = true const startTimestamp = Date.now() autoPlayLoaderTimer = setInterval(() => { const elapsed = Date.now() - startTimestamp const nextPercent = Math.min( AUTO_PLAY_LOADER_TARGET, Math.floor((elapsed / AUTO_PLAY_LOADER_DURATION) * AUTO_PLAY_LOADER_TARGET), ) autoPlayLoaderPercent.value = nextPercent if (elapsed >= AUTO_PLAY_LOADER_DURATION) { clearAutoPlayLoaderTimers() autoPlayLoaderPercent.value = AUTO_PLAY_LOADER_TARGET hasQueuedReadonlyAutoPlay.value = true requestLandscapeFullscreenIfNeeded() tryStartPlayback() } }, 60) } /** * App 端加载完成后主动进入横屏全屏,让类直播直接以大画面呈现。 */ function requestLandscapeFullscreenIfNeeded() { if (!isAppPlus || !videoContext || isVideoFullscreen.value) { return } videoContext.requestFullScreen({ direction: 90, }) } /** * 启动一次播放成功性检测,App 端若首播失败则回显轻量重试提示。 */ function schedulePlaybackStartupCheck() { @@ -1003,18 +1115,12 @@ } /** * 延迟触发一次播放引导,兼容 App 端视频上下文初始化与元数据事件的时序差。 */ function schedulePlaybackBootstrap() { setTimeout(() => { tryStartPlayback() }, 120) } /** * 按当前时刻同步模拟直播进度,恢复播放时不会沿用旧的暂停点。 */ function tryStartPlayback() { if (isLoaderGatedPlayback.value && !hasQueuedReadonlyAutoPlay.value) { return } if (!videoContext) { pendingPlaybackSync.value = true return @@ -1118,20 +1224,31 @@ schedulePlaybackSyncRetry() return } if (isH5) { if (isLoaderGatedPlayback.value) { isVideoPaused.value = true if (hasQueuedReadonlyAutoPlay.value) { requestLandscapeFullscreenIfNeeded() tryStartPlayback() } return } syncPlaybackWhenReady(true) } /** * 点击视频区域时切换暂停或按当前时间恢复播放。 * 视频区域点击仅保留给 App 端暂停/恢复,分享页不提供手动播放按钮。 */ function togglePlayback() { function handleVideoWrapperTap() { if (isReadonlyDisplayMode) { return } togglePlayback() } /** * 点击视频区域时切换暂停或按当前时间恢复播放。 */ function togglePlayback() { if (!isVideoPlayable.value || !videoContext || !isVideoReady.value) { return } @@ -1146,9 +1263,6 @@ * 手动重试当前视频播放,复用统一的自动播放拉起逻辑。 */ function retryPlayback() { if (isReadonlyDisplayMode) { return } tryStartPlayback() } @@ -1217,14 +1331,18 @@ * 记录视频开始播放状态,并开启新的观看区间。 */ function handleVideoPlay() { if (isReadonlyDisplayMode) { return } clearPlaybackStartupTimer() isStartingPlayback.value = false isPlaybackStarted.value = true playbackFailed.value = false isVideoPaused.value = false if (isLoaderGatedPlayback.value) { clearAutoPlayLoaderTimers() autoPlayLoaderPercent.value = 100 autoPlayLoaderFinishTimer = setTimeout(() => { isAutoPlayLoaderVisible.value = false }, 180) } if (pendingPlaybackSync.value && !isSyncingPlayback.value) { syncPlaybackWhenReady(false) } @@ -1252,9 +1370,6 @@ * 记录视频暂停状态,供点击恢复时重新对齐直播进度,同时结束本次观看区间。 */ function handleVideoPause() { if (isReadonlyDisplayMode) { return } if (isSyncingPlayback.value) { return } @@ -1273,9 +1388,6 @@ * 视频文件播放结束后,按直播总时长决定是否继续循环模拟直播,并结算本段观看时长。 */ function handleVideoEnded() { if (isReadonlyDisplayMode) { return } clearPlaybackStartupTimer() isStartingPlayback.value = false isPlaybackStarted.value = false @@ -1296,10 +1408,12 @@ */ function handleVideoError(event : VideoErrorEvent) { clearPlaybackStartupTimer() clearAutoPlayLoaderTimers() isStartingPlayback.value = false isPlaybackStarted.value = false playbackFailed.value = true isVideoPaused.value = true isAutoPlayLoaderVisible.value = false if (event?.detail?.errMsg) { uni.showToast({ title: '视频播放失败', @@ -1413,13 +1527,24 @@ /** * H5 分享页点击底部横条后跳转统一拉起链接。 */ function openApp() { const openAppUrl = 'https://m.malink.cn/s/7bIzeu' async function openApp() { // #ifdef H5 window.location.href = openAppUrl const openinstallOpened = await wakeupOrInstallByOpeninstall({ page: 'watchLive', courseId: String(liveDetail.value.courseId || courseId.value || '').trim(), sessionId: String(activeSessionId.value || '').trim() }) if (openinstallOpened) { return } uni.showToast({ title: '打开APP失败,请稍后重试', icon: 'none', }) return // #endif // #ifdef APP-PLUS const openAppUrl = config.OPENINSTALL_URL || config.BASE_URL plus.runtime.openURL(openAppUrl) return // #endif @@ -1543,6 +1668,29 @@ }) } } /** * 记录全屏切换状态,驱动自绘按钮文案与退出逻辑。 */ function handleFullscreenChange(event : VideoFullscreenChangeEvent) { isVideoFullscreen.value = !!(event?.detail?.fullScreen || event?.detail?.fullscreen) } /** * 使用自绘按钮触发全屏,避免依赖原生播放控制条。 */ function toggleFullscreen() { if (!videoContext) { return } if (isVideoFullscreen.value) { videoContext.exitFullScreen() return } videoContext.requestFullScreen({ direction: 90, }) } </script> <style lang="scss" scoped> @@ -1627,6 +1775,53 @@ z-index: 2; } .video-loader-mask { position: absolute; inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.32); } .video-loader-ring { width: 180rpx; height: 180rpx; border-radius: 50%; padding: 10rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: center; box-shadow: 0 16rpx 36rpx rgba(0, 0, 0, 0.18); } .video-loader-ring__inner { width: 100%; height: 100%; border-radius: 50%; background: rgba(12, 12, 12, 0.78); display: flex; flex-direction: column; align-items: center; justify-content: center; } .video-loader-percent { color: #ffffff; font-size: 40rpx; font-weight: 600; line-height: 1; } .video-loader-label { margin-top: 10rpx; color: rgba(255, 255, 255, 0.82); font-size: 22rpx; line-height: 1.2; } .video-retry-mask { position: absolute; inset: 0; @@ -1646,6 +1841,70 @@ line-height: 40rpx; } .video-fullscreen-btn { 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); } .video-fullscreen-btn__icon { position: relative; width: 32rpx; height: 32rpx; } .video-fullscreen-btn__corner { position: absolute; width: 12rpx; height: 12rpx; border-color: #ffffff; } .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-placeholder { position: absolute; top: 50%; traditionalApp/utils/app-version.js
@@ -63,6 +63,15 @@ } /** * 读取当前安装 APP 的展示版本号。 */ export function getRuntimeVersionName(fallbackVersionName = '') { return resolveRuntimeVersionInfo().then((runtimeInfo) => { return normalizeText(runtimeInfo.versionName) || normalizeText(fallbackVersionName) }) } /** * 将当前运行平台转换为后端版本检查平台值。 */ function resolvePlatform() { traditionalApp/utils/openinstall-h5.js
New file @@ -0,0 +1,95 @@ const OPENINSTALL_APP_KEY = 'ag8spw' const OPENINSTALL_WEB_SDK_URL = 'https://res.opstatistics.com/openinstall-ag8spw.js' let openinstallSdkPromise = null /** * H5 环境下按需加载 openinstall Web SDK。 */ const loadOpeninstallWebSdk = () => { if (typeof window === 'undefined') { return Promise.resolve(null) } if (window.OpenInstall) { return Promise.resolve(window.OpenInstall) } if (openinstallSdkPromise) { return openinstallSdkPromise } openinstallSdkPromise = new Promise((resolve, reject) => { const script = document.createElement('script') script.src = OPENINSTALL_WEB_SDK_URL script.async = true script.onload = () => { if (window.OpenInstall) { resolve(window.OpenInstall) return } reject(new Error('openinstall sdk unavailable')) } script.onerror = () => reject(new Error('openinstall sdk load failed')) document.head.appendChild(script) }) return openinstallSdkPromise } /** * 构造 openinstall 透传参数,保留当前页面关键信息。 */ const buildOpeninstallData = (extraData = {}) => { if (typeof window === 'undefined') { return { ...extraData } } const parsedParams = window.OpenInstall && typeof window.OpenInstall.parseUrlParams === 'function' ? window.OpenInstall.parseUrlParams() : {} return { ...(parsedParams || {}), ...extraData } } /** * H5 分享页通过 openinstall 执行唤醒或安装。 */ export const wakeupOrInstallByOpeninstall = async (extraData = {}) => { if (typeof window === 'undefined') { return false } try { const OpenInstall = await loadOpeninstallWebSdk() if (!OpenInstall) { return false } const wakeupData = buildOpeninstallData(extraData) await new Promise((resolve, reject) => { let readyHandled = false try { new OpenInstall({ appKey: OPENINSTALL_APP_KEY, fastInstall: false, onready: function () { if (readyHandled) { return } readyHandled = true try { this.wakeupOrInstall() resolve(true) } catch (error) { reject(error) } } }, wakeupData) } catch (error) { reject(error) } }) return true } catch (error) { console.log('H5 openinstall 拉起失败', error) return false } }