longz
2026-06-11 54409d045636ff325f52469f05154f814e467f57
添加课程视频时间戳和播放功能优化
2个文件已添加
9个文件已修改
702 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseVideoVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/.npmrc 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/config/index.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/courseDetail.vue 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/liveDetail/liveDetail.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 329 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/app-version.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/openinstall-h5.js 95 ●●●●● 补丁 | 查看 | 原始文档 | 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
    }
}