longz
2026-08-11 1846a960f86e5f59c86ba2194455924119589f19
部分优化
3个文件已添加
8个文件已修改
656 ■■■■■ 已修改文件
docs/superpowers/plans/2026-08-11-project-handover-analysis.md 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/tcm_training_admin.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/manifest.json 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/courseDetail.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/static/image/live-fullscreen.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/superpowers/plans/2026-08-11-project-handover-analysis.md
New file
@@ -0,0 +1,48 @@
# 项目交接分析 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to execute this analysis task task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 分析 tcm-training 三端仓库及最近提交、当前未提交修改,产出可供下一位工程师直接接手的中文交接文档。
**Architecture:** 先从仓库结构、入口配置和模块分布建立系统地图,再将 `master` 最近提交与工作区差异分开审阅;最后以可验证的命令、路径、接口和风险清单收敛结论,不修改业务代码。
**Tech Stack:** Git、PowerShell、Spring Boot 2.7/Maven、React 18/Umi/TypeScript、uni-app/Vue 3、MySQL、Redis、Nginx。
## Global Constraints
- 保留用户现有未提交修改,不重置、不覆盖业务代码。
- 不在文档中复制仓库内明文凭据、token、密钥或密码。
- 对未运行成功的测试如实记录阻塞原因,不把环境失败描述为代码通过。
- 仓库没有 `.codegraph/`,按普通源码与 Git 差异分析。
---
### Task 1: 建立仓库与运行基线
**Files:**
- Read: `README.md`, `tcm-training-admin-backend/pom.xml`, `tcm-training-admin/package.json`, `traditionalApp/manifest.json`
- Read: `tcm-training-admin-backend/src/main/resources/application.yml`, `doc/nginx-tcm-training.conf`
- [ ] **Step 1: 记录三端职责、入口、端口和生产路径。**
- [ ] **Step 2: 记录后端模块规模、管理端路由模块和 APP 页面入口。**
- [ ] **Step 3: 记录数据库迁移方式与部署前置条件。**
### Task 2: 审阅提交历史与工作区差异
**Files:**
- Read: `git log`, `git show ee54919`, `git show 54409d0`, `git show 24147cb`
- Read: current `git diff`, untracked files under `tcm-training-admin-backend/` and `traditionalApp/pages/watchLiveFullscreen/`
- [ ] **Step 1: 将最近已提交功能按主题归纳。**
- [ ] **Step 2: 将当前未提交修改按直播密码、全屏播放、客户端兼容性三组归纳。**
- [ ] **Step 3: 区分真实内容差异与仅由换行符造成的工作区状态。**
### Task 3: 验证与交接文档
**Files:**
- Create: `doc/项目交接文档-2026-08-11.md`
- [ ] **Step 1: 执行后端测试、管理端类型检查、配置/差异静态检查。**
- [ ] **Step 2: 在文档中写明通过项、阻塞项和未覆盖的真机验证。**
- [ ] **Step 3: 自审路径、命令、接口和风险结论后交付。**
tcm-training-admin-backend/sql/init.sql
@@ -826,6 +826,8 @@
  `start_time` DATETIME DEFAULT NULL COMMENT '开始直播时间',
  `video_url` VARCHAR(512) DEFAULT NULL COMMENT '视频地址',
  `duration_seconds` INT DEFAULT NULL COMMENT '视频时长(秒)',
  `password_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否开启观看密码:0否 1是',
  `watch_password` VARCHAR(64) DEFAULT NULL COMMENT '观看密码',
  `base_reserve_count` INT NOT NULL DEFAULT 0 COMMENT '基础预约量(初始刷基数)',
  `base_watch_count` INT NOT NULL DEFAULT 0 COMMENT '基础观看量(初始刷基数)',
  `real_reserve_count` INT NOT NULL DEFAULT 0 COMMENT '真实预约人数',
tcm-training-admin-backend/sql/tcm_training_admin.sql
@@ -4748,6 +4748,8 @@
  `start_time` datetime DEFAULT NULL COMMENT '开始直播时间',
  `video_url` varchar(512) DEFAULT NULL COMMENT '视频地址',
  `duration_seconds` int DEFAULT NULL COMMENT '视频时长(秒)',
  `password_enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否开启观看密码:0否 1是',
  `watch_password` varchar(64) DEFAULT NULL COMMENT '观看密码',
  `base_reserve_count` int NOT NULL DEFAULT '0' COMMENT '基础预约量(初始刷基数)',
  `base_watch_count` int NOT NULL DEFAULT '0' COMMENT '基础观看量(初始刷基数)',
  `real_reserve_count` int NOT NULL DEFAULT '0' COMMENT '真实预约人数',
traditionalApp/manifest.json
@@ -3,14 +3,15 @@
    "name" : "海诊康道",
    "appid" : "__UNI__B2F54BE",
    "description" : "",
    "versionName" : "1.0.18",
    "versionCode" : 118,
    "versionName" : "1.0.22",
    "versionCode" : 122,
    "transformPx" : false,
    /* 5+App特有相关 */
    "app-plus" : {
        "usingComponents" : true,
        "nvueStyleCompiler" : "uni-app",
        "compilerVersion" : 3,
        "screenOrientation" : [ "portrait-primary", "landscape-primary" ],
        "splashscreen" : {
            "alwaysShowBeforeRender" : true,
            "waiting" : true,
traditionalApp/pages.json
@@ -204,6 +204,13 @@
            }
        },
        {
            "path": "pages/watchLiveFullscreen/watchLiveFullscreen",
            "style": {
                "navigationBarTitleText": "",
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/search/search",
            "style": {
                "navigationStyle": "custom"
@@ -368,4 +375,4 @@
        "backgroundColor": "#fff"
    },
    "uniIdRouter": {}
}
}
traditionalApp/pages/courseDetail/courseDetail.vue
@@ -14,7 +14,7 @@
                <video id="course-detail-video-player" class="video-player" :src="currentVideoUrl"
                    :poster="currentVideoPoster" :controls="true" :autoplay="isAppPlus" object-fit="contain"
                    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"
                    :show-fullscreen-btn="true" :show-play-btn="true" :show-center-play-btn="true"
                    @loadedmetadata="handleVideoLoadedMetadata" @play="handleVideoPlay" @error="handleVideoError">
                </video>
                <wd-watermark v-if="videoWatermarkText" class="video-watermark" :content="videoWatermarkText"
@@ -31,6 +31,7 @@
                    </swiper-item>
                </swiper>
                <view v-if="!isReadonlyDisplayMode && allVideos.length > 0" class="poster-play-btn"
                    :class="{ 'poster-play-btn--plain': shouldUsePlainIosPlayIcon }"
                    @tap.stop="playFirstCourseVideoFromPoster">
                    <text class="poster-play-icon">▶</text>
                </view>
@@ -506,6 +507,7 @@
    })
    const currentVideoUrl = computed(() => currentVideo.value?.videoUrl || '')
    const currentVideoPoster = computed(() => courseDetail.value.bannerImages[0] || 'http://obs.haizhentong.com/file/class-01479c4bc4e04f63b0425023eb1652d5.png')
    const shouldUsePlainIosPlayIcon = computed(() => isIosApp())
    const isReadonlyDisplayMode = isH5
    const scrollContentStyle = computed(() => {
        if (isReadonlyDisplayMode) {
@@ -1646,6 +1648,18 @@
            margin-left: 8rpx;
        }
        .poster-play-btn--plain {
            background: transparent;
            border: 0;
            box-shadow: none;
            .poster-play-icon {
                font-size: 78rpx;
                font-weight: 400;
                text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.48);
            }
        }
        .video-player-wrapper,
        .video-player {
            width: 100%;
traditionalApp/pages/home/home.vue
@@ -79,7 +79,8 @@
                        <view class="course-cover-wrap">
                            <image :src="item.coverImage || defaultCourseCover" mode="aspectFill" class="course-img">
                            </image>
                            <view class="course-play-btn" @tap.stop="playFirstCourseVideo(item.id)">
                            <view class="course-play-btn" :class="{ 'course-play-btn--plain': shouldUsePlainIosPlayIcon }"
                                @tap.stop="playFirstCourseVideo(item.id)">
                                <text class="course-play-icon">▶</text>
                            </view>
                        </view>
@@ -114,7 +115,7 @@
</template>
<script setup lang="ts">
    import { ref, onMounted } from "vue";
    import { computed, ref, onMounted } from "vue";
    import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
    import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service";
@@ -171,6 +172,10 @@
    const defaultCourseCover = '/static/image/home/class.png';
    const defaultPopularizationCover = '/static/image/home/zixun.png';
    const refreshing = ref(false);
    const shouldUsePlainIosPlayIcon = computed(() => {
        const systemInfo = uni.getSystemInfoSync();
        return String(systemInfo.platform || '').toLowerCase() === 'ios';
    });
    /**
     * 初始化导航栏所需系统信息。
@@ -858,6 +863,18 @@
                        margin-left: 4rpx;
                    }
                }
                .course-play-btn--plain {
                    background: transparent;
                    border: 0;
                    box-shadow: none;
                    .course-play-icon {
                        font-size: 42rpx;
                        font-weight: 400;
                        text-shadow: 0 3rpx 10rpx rgba(0, 0, 0, 0.48);
                    }
                }
            }
            .course-info {
traditionalApp/pages/mine/mine.vue
@@ -18,7 +18,7 @@
                <view class="avatar-wrap">
                    <image class="avatar" :src="displayAvatar" mode="aspectFill"></image>
                    <view class="role-tag" :class="userRole === 'doctor' ? 'doctor-tag' : 'personal-tag'">
                        {{ userRole === 'doctor' ? '医生' : '个人' }}
                        {{ roleTagText }}
                    </view>
                </view>
                <view class="user-detail">
@@ -68,6 +68,8 @@
    phone?: string
    type?: number | null
    typeText?: string
    roleCode?: string
    roleCodes?: string[]
    memberExpireAt?: string
    pointsBalance?: number | null
    clinicId?: number | null
@@ -81,6 +83,8 @@
    phone?: string
    type?: number | null
    typeText?: string
    roleCode?: string
    roleCodes?: string[]
    memberExpireAt?: string
    clinicId?: number | null
}
@@ -103,7 +107,14 @@
const appVersionText = ref(manifestVersionName)
const userCenter = ref<UserCenterData>(createDefaultUserCenter())
const userRole = computed<UserRole>(() => {
    return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal'
    return isDoctorLikeUser(userCenter.value) ? 'doctor' : 'personal'
})
const roleTagText = computed(() => {
    const typeText = normalizeOptionalText(userCenter.value.typeText)
    if (typeText.includes('店长')) {
        return '店长'
    }
    return userRole.value === 'doctor' ? '医生' : '个人'
})
const displayName = computed(() => {
    return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || (hasStoredSession() ? '已登录用户' : '游客')
@@ -258,19 +269,64 @@
 */
function normalizeUserCenter(value: unknown): UserCenterData {
    const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData> & Record<string, unknown>
    const type = Number(source.type)
    const type = resolveUserType(source)
    return {
        id: Number.isFinite(Number(source.id)) ? Number(source.id) : null,
        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,
        type,
        typeText: normalizeOptionalText(source.typeText),
        roleCode: normalizeOptionalText(source.roleCode),
        roleCodes: Array.isArray(source.roleCodes) ? source.roleCodes.map((item) => normalizeOptionalText(item)).filter(Boolean) : [],
        memberExpireAt: normalizeOptionalText(source.memberExpireAt),
        pointsBalance: Number.isFinite(Number(source.pointsBalance)) ? Number(source.pointsBalance) : 0,
        clinicId: Number.isFinite(Number(source.clinicId)) ? Number(source.clinicId) : null,
        clinicId: resolveClinicId(source),
        clinicName: normalizeOptionalText(source.clinicName),
    }
}
/**
 * 兼容登录缓存、个人中心接口不同字段;绑定诊所/店长文案都视为医生端用户。
 */
function resolveUserType(source: Partial<UserCenterData> & Record<string, unknown>) {
    const explicitType = Number(source.type ?? source.userType ?? source.user_type)
    if (Number.isFinite(explicitType) && explicitType === 1) {
        return 1
    }
    const rawType = normalizeOptionalText(source.type || source.userType || source.user_type).toLowerCase()
    if (['doctor', 'operator', 'clinic_manager', 'manager', 'store_manager'].includes(rawType)) {
        return 1
    }
    const clinicId = Number(resolveClinicId(source) || 0)
    if (Number.isFinite(clinicId) && clinicId > 0) {
        return 1
    }
    const roleCodes = Array.isArray(source.roleCodes) ? source.roleCodes.join(',') : ''
    const typeText = normalizeOptionalText(source.typeText || source.roleName || source.roleText || source.roleCode || roleCodes).toLowerCase()
    if (typeText.includes('医生') || typeText.includes('店长') || typeText.includes('诊所')
        || typeText.includes('doctor') || typeText.includes('clinic') || typeText.includes('manager')) {
        return 1
    }
    return 0
}
function resolveClinicId(source: Record<string, unknown>) {
    const clinicId = Number(source.clinicId ?? source.clinic_id ?? source.clinicID ?? source.storeId ?? source.store_id ?? 0)
    return Number.isFinite(clinicId) && clinicId > 0 ? clinicId : null
}
function isDoctorLikeUser(value: UserCenterData) {
    return Number(value.type) === 1
        || (Number.isFinite(Number(value.clinicId)) && Number(value.clinicId) > 0)
        || normalizeOptionalText(value.typeText).includes('医生')
        || normalizeOptionalText(value.typeText).includes('店长')
        || normalizeOptionalText(value.roleCode).toLowerCase().includes('doctor')
        || normalizeOptionalText(value.roleCode).toLowerCase().includes('manager')
        || (Array.isArray(value.roleCodes) && value.roleCodes.some((item) => {
            const code = normalizeOptionalText(item).toLowerCase()
            return code.includes('doctor') || code.includes('manager') || code.includes('clinic')
        }))
}
/**
@@ -286,6 +342,8 @@
        phone: centerData.phone,
        type: centerData.type,
        typeText: centerData.typeText,
        roleCode: centerData.roleCode,
        roleCodes: centerData.roleCodes,
        memberExpireAt: centerData.memberExpireAt,
        clinicId: centerData.clinicId,
    }
traditionalApp/pages/watchLive/watchLive.vue
@@ -19,8 +19,7 @@
                    :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>
                <cover-view v-if="showVideoGestureBlocker" class="video-touch-layer"></cover-view>
                    @ended="handleVideoEnded" @error="handleVideoError"></video>
                <view v-if="showAutoPlayLoader" class="video-loader-mask">
                    <view class="video-loader-ring" :style="videoLoaderRingStyle">
                        <view class="video-loader-ring__inner">
@@ -37,15 +36,12 @@
                        </cover-view>
                    </cover-view>
                </cover-view>
                <cover-view v-if="showCustomPlayButton" class="video-cover-play-button" @tap="startManualPlayback">
                <cover-view v-if="showCustomPlayButton" class="video-cover-play-button"
                    :class="{ 'video-cover-play-button--plain': isIosApp }" @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>
                <cover-image v-if="showEnterFullscreenButton" class="video-cover-fullscreen-btn video-cover-fullscreen-btn--icon"
                    src="/static/image/live-fullscreen.png" @tap="toggleFullscreen" @click="toggleFullscreen"></cover-image>
            </view>
            <image v-else class="video-bg" :src="videoPoster" mode="aspectFill"></image>
            <view v-if="!isVideoPlayable" class="video-placeholder">
@@ -206,13 +202,6 @@
            duration ?: number
        }
    }
    type VideoFullscreenChangeEvent = {
        detail ?: {
            fullScreen ?: boolean
            fullscreen ?: boolean
        }
    }
    interface LiveDetailResponse {
        courseId ?: number | string | null
        title ?: string
@@ -290,12 +279,24 @@
    const defaultVideoPoster = 'http://obs.haizhentong.com/file/class-01479c4bc4e04f63b0425023eb1652d5.png'
    let isAppPlus = false
    let isH5 = false
    let isIosApp = false
    // #ifdef APP-PLUS
    isAppPlus = true
    isIosApp = resolveIsIosRuntime()
    // #endif
    // #ifdef H5
    isH5 = true
    // #endif
    function resolveIsIosRuntime() {
        const systemInfo = uni.getSystemInfoSync()
        const platform = String(systemInfo.platform || '').toLowerCase()
        // #ifdef APP-PLUS
        const plusOsName = typeof plus !== 'undefined' ? String(plus.os?.name || '').toLowerCase() : ''
        return platform === 'ios' || plusOsName === 'ios'
        // #endif
        return platform === 'ios'
    }
    const showAnswer = ref(false)
    const courseId = ref('')
@@ -326,6 +327,8 @@
    const hasQueuedReadonlyAutoPlay = ref(false)
    const hasStartedReadonlyAutoPlay = ref(false)
    const isVideoFullscreen = ref(false)
    const isOpeningFullscreenPlayer = ref(false)
    const shouldSuppressNextAutoFullscreen = ref(false)
    const instance = getCurrentInstance()
    let videoContext : ReturnType<typeof uni.createVideoContext> | null = null
    let playbackStartupTimer : ReturnType<typeof setTimeout> | null = null
@@ -543,12 +546,6 @@
            && 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 !== '') {
            return String(liveDetail.value.actionSessionId)
@@ -680,19 +677,10 @@
            && isVideoPlayable.value
            && !showAutoPlayLoader.value
            && !showManualPlayLoader.value
            && !showCustomPlayButton.value
            && !playbackFailed.value
            && !isReadonlyDisplayMode
            && !isVideoFullscreen.value
            && !isOpeningFullscreenPlayer.value
    })
    const showExitFullscreenButton = computed(() => {
        return isAppPlus
            && isVideoPlayable.value
            && !playbackFailed.value
            && !isReadonlyDisplayMode
            && isVideoFullscreen.value
    })
    /**
     * 进入页面时记录课程 ID 与场次 ID。
     */
@@ -771,6 +759,8 @@
     * 每次展示观看页时刷新详情,保证取到最新可观看视频与答题状态。
     */
    onShow(() => {
        isVideoFullscreen.value = false
        isOpeningFullscreenPlayer.value = false
        const shouldResumeAfterRefresh = !isReadonlyDisplayMode
            && isVideoPlayable.value
            && (isVideoPaused.value || playbackFailed.value)
@@ -814,6 +804,8 @@
            return
        }
        const shouldResumeIfPaused = !!options.resumeIfPaused
        const previousVideoUrl = liveDetail.value.videoUrl || ''
        const previousMediaDurationSeconds = mediaDurationSeconds.value
        resetVideoPlaybackState()
        resetQuizState()
        loading.value = true
@@ -848,6 +840,7 @@
            if (liveDetail.value.actionSessionId != null && liveDetail.value.actionSessionId !== '') {
                sessionId.value = String(liveDetail.value.actionSessionId)
            }
            restoreKnownMediaDuration(previousVideoUrl, previousMediaDurationSeconds)
            await nextTick()
            initVideoContext()
            startAutoPlayLoaderIfNeeded()
@@ -936,6 +929,7 @@
        hasQueuedReadonlyAutoPlay.value = false
        hasStartedReadonlyAutoPlay.value = false
        isVideoFullscreen.value = false
        isOpeningFullscreenPlayer.value = false
    }
    /**
@@ -1070,6 +1064,20 @@
    }
    /**
     * 返回全屏页后同一 video/src 不一定重新触发 metadata,复用已知真实时长来立即校准伪直播进度。
     */
    function restoreKnownMediaDuration(previousVideoUrl : string, previousDurationSeconds : number) {
        if (!supportsSimulatedPlayback.value || previousDurationSeconds <= 0) {
            return
        }
        if (!previousVideoUrl || previousVideoUrl !== liveDetail.value.videoUrl) {
            return
        }
        mediaDurationSeconds.value = previousDurationSeconds
        isVideoReady.value = true
    }
    /**
     * 判断当前场景是否需要循环视频来匹配配置的直播总时长。
     */
    function shouldLoopPlayback() : boolean {
@@ -1186,12 +1194,14 @@
     * App 端加载完成后主动进入横屏全屏,让类直播直接以大画面呈现。
     */
    function requestLandscapeFullscreenIfNeeded() {
        if (!isAppPlus || !videoContext || isVideoFullscreen.value) {
        if (!isAppPlus || !videoContext || isOpeningFullscreenPlayer.value) {
            return
        }
        videoContext.requestFullScreen({
            direction: 90,
        })
        if (shouldSuppressNextAutoFullscreen.value) {
            shouldSuppressNextAutoFullscreen.value = false
            return
        }
        openFullscreenPlayer()
    }
    /**
@@ -1836,32 +1846,70 @@
    }
    /**
     * 记录原生全屏状态,驱动自绘全屏按钮。
     */
    function handleFullscreenChange(event : VideoFullscreenChangeEvent) {
        isVideoFullscreen.value = !!(event?.detail?.fullScreen || event?.detail?.fullscreen)
    }
    /**
     * 使用原生 video 全屏,避免伪全屏放大页面;原生 controls 仍保持关闭。
     * 打开 App 专用 nvue 播放页,避免原生 controls 暴露伪直播总时长。
     */
    function toggleFullscreen() {
        if (!videoContext) {
            initVideoContext()
        }
        if (!videoContext) {
        openFullscreenPlayer()
    }
    function openFullscreenPlayer() {
        const fullscreenVideoUrl = liveDetail.value.videoUrl || ''
        if (isOpeningFullscreenPlayer.value) {
            return
        }
        if (isVideoFullscreen.value) {
            videoContext.exitFullScreen()
        if (!isAppPlus) {
            uni.showToast({
                title: '当前环境不支持全屏',
                icon: 'none',
            })
            return
        }
        videoContext.requestFullScreen({
            direction: 90,
            success: () => {
                isVideoFullscreen.value = true
        if (!fullscreenVideoUrl) {
            uni.showToast({
                title: '视频地址为空',
                icon: 'none',
            })
            return
        }
        isVideoFullscreen.value = false
        isOpeningFullscreenPlayer.value = true
        if (videoContext) {
            videoContext.pause()
        }
        isVideoPaused.value = true
        shouldSuppressNextAutoFullscreen.value = true
        const query = [
            `videoUrl=${encodeURIComponent(fullscreenVideoUrl)}`,
            `poster=${encodeURIComponent(videoPoster.value)}`,
            `initialTime=${resolveFullscreenInitialTime()}`,
        ].join('&')
        uni.navigateTo({
            url: `/pages/watchLiveFullscreen/watchLiveFullscreen?${query}`,
            fail: () => {
                isOpeningFullscreenPlayer.value = false
                shouldSuppressNextAutoFullscreen.value = false
                uni.showToast({
                    title: '全屏播放器打开失败',
                    icon: 'none',
                })
            },
            complete: () => {
                isVideoFullscreen.value = false
                setTimeout(() => {
                    isOpeningFullscreenPlayer.value = false
                }, 300)
            },
        })
    }
    function resolveFullscreenInitialTime() {
        if (!supportsSimulatedPlayback.value) {
            return Math.max(Math.floor(lastMediaCurrentTime.value || 0), 0)
        }
        if (mediaDurationSeconds.value > 0) {
            return Math.max(Math.floor(resolveSeekSeconds()), 0)
        }
        return Math.max(Math.floor(resolveInitialPlaybackSeconds()), 0)
    }
</script>
@@ -1938,15 +1986,6 @@
            background-color: #000;
        }
        .video-touch-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 2;
        }
        .video-loader-mask {
            position: absolute;
            inset: 0;
@@ -2009,6 +2048,15 @@
            font-size: 52rpx;
            font-weight: 600;
            text-align: center;
        }
        .video-cover-play-button--plain {
            background-color: transparent;
            border-radius: 0;
            color: #ffffff;
            font-size: 72rpx;
            font-weight: 400;
            text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.48);
        }
        .video-cover-loader-mask {
@@ -2074,10 +2122,10 @@
            right: 28rpx;
            bottom: 28rpx;
            z-index: 120;
            width: 88rpx;
            height: 88rpx;
            line-height: 88rpx;
            border-radius: 44rpx;
            width: 72rpx;
            height: 72rpx;
            line-height: 72rpx;
            border-radius: 36rpx;
            border-width: 2rpx;
            border-style: solid;
            border-color: rgba(255, 255, 255, 0.9);
@@ -2088,11 +2136,11 @@
            text-align: center;
        }
        .video-cover-fullscreen-btn--exit {
            position: fixed;
            right: 32rpx;
            top: 32rpx;
            z-index: 9999;
        .video-cover-fullscreen-btn--icon {
            overflow: hidden;
            border: 0;
            border-radius: 16rpx;
            background-color: transparent;
        }
        .video-placeholder {
@@ -2115,6 +2163,41 @@
            font-size: 28rpx;
            line-height: 40rpx;
            text-align: center;
        }
        .watch-password-gate {
            position: absolute;
            left: 0;
            top: 0;
            z-index: 4;
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            background: rgba(0, 0, 0, 0.18);
        }
        .watch-password-play {
            width: 124rpx;
            height: 124rpx;
            line-height: 124rpx;
            border-radius: 62rpx;
            background: rgba(0, 0, 0, 0.68);
            color: #ffffff;
            font-size: 58rpx;
            text-align: center;
            text-indent: 8rpx;
            box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.24);
        }
        .watch-password-tip {
            margin-top: 20rpx;
            color: #ffffff;
            font-size: 26rpx;
            line-height: 36rpx;
            text-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.42);
        }
        .viewer-count {
@@ -2229,6 +2312,94 @@
        align-items: center;
    }
    .watch-password-modal-mask {
        position: fixed;
        left: 0;
        top: 0;
        right: 0;
        bottom: 0;
        z-index: 1000;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 40rpx;
        box-sizing: border-box;
        background: rgba(0, 0, 0, 0.46);
    }
    .watch-password-modal {
        width: 620rpx;
        padding: 44rpx 36rpx 32rpx;
        border-radius: 20rpx;
        background: #ffffff;
        box-sizing: border-box;
    }
    .watch-password-title {
        color: #222222;
        font-size: 34rpx;
        font-weight: 600;
        line-height: 48rpx;
        text-align: center;
    }
    .watch-password-input {
        width: 100%;
        height: 88rpx;
        margin-top: 34rpx;
        padding: 0 24rpx;
        box-sizing: border-box;
        border-radius: 12rpx;
        background: #f6f6f6;
        color: #222222;
        font-size: 30rpx;
    }
    .watch-password-placeholder {
        color: #999999;
        font-size: 30rpx;
    }
    .watch-password-error {
        margin-top: 16rpx;
        min-height: 34rpx;
        color: #C63520;
        font-size: 24rpx;
        line-height: 34rpx;
    }
    .watch-password-actions {
        display: flex;
        align-items: center;
        justify-content: space-between;
        margin-top: 34rpx;
    }
    .watch-password-cancel,
    .watch-password-confirm {
        width: 260rpx;
        height: 76rpx;
        border-radius: 12rpx;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 28rpx;
    }
    .watch-password-cancel {
        background: #f2f2f2;
        color: #555555;
    }
    .watch-password-confirm {
        background: #C63520;
        color: #ffffff;
        &.disabled {
            opacity: 0.62;
        }
    }
    .answer-area {
        flex: 1;
        height: 0;
traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue
New file
@@ -0,0 +1,170 @@
<template>
    <view class="fullscreen-page" :style="pageStyle">
        <video
            id="fullscreen-live-player"
            class="fullscreen-video"
            :style="videoStyle"
            :src="videoUrl"
            :poster="poster"
            :initial-time="initialTime"
            :controls="false"
            :autoplay="true"
            :show-center-play-btn="false"
            :show-fullscreen-btn="false"
            :show-play-btn="false"
            :show-progress="false"
            :enable-progress-gesture="false"
            object-fit="contain"
            @loadedmetadata="handleVideoReady"
            @canplay="handleVideoReady"
        ></video>
        <view class="back-button" @click="closePlayer">
            <text class="back-button-text">‹</text>
        </view>
    </view>
</template>
<script>
const PLAYER_ID = 'fullscreen-live-player'
export default {
    data() {
        return {
            videoUrl: '',
            poster: '',
            initialTime: 0,
            hasPrepared: false,
            playerContext: null,
            windowWidth: 375,
            windowHeight: 667,
        }
    },
    computed: {
        pageStyle() {
            return {
                width: `${this.windowWidth}px`,
                height: `${this.windowHeight}px`,
            }
        },
        videoStyle() {
            return {
                width: `${this.windowWidth}px`,
                height: `${this.windowHeight}px`,
            }
        },
    },
    onLoad(options) {
        const systemInfo = uni.getSystemInfoSync()
        this.windowWidth = systemInfo.windowWidth || this.windowWidth
        this.windowHeight = systemInfo.windowHeight || this.windowHeight
        this.videoUrl = decodeRouteText(options && options.videoUrl)
        this.poster = decodeRouteText(options && options.poster)
        this.initialTime = normalizeNumber(options && options.initialTime)
    },
    onReady() {
        this.playerContext = uni.createVideoContext(PLAYER_ID, this)
        this.lockLandscape()
        setTimeout(() => {
            this.refreshWindowSize()
            this.playerContext && this.playerContext.play()
        }, 180)
    },
    onUnload() {
        this.playerContext && this.playerContext.pause()
        this.lockPortrait()
    },
    onBackPress() {
        this.playerContext && this.playerContext.pause()
        this.lockPortrait()
        return false
    },
    methods: {
        refreshWindowSize() {
            const systemInfo = uni.getSystemInfoSync()
            this.windowWidth = systemInfo.windowWidth || this.windowWidth
            this.windowHeight = systemInfo.windowHeight || this.windowHeight
        },
        handleVideoReady(event) {
            if (this.hasPrepared) {
                return
            }
            this.hasPrepared = true
            const duration = normalizeNumber(event && event.detail && event.detail.duration)
            if (this.initialTime > 0 && duration > 0 && this.initialTime < duration) {
                this.playerContext && this.playerContext.seek(this.initialTime)
            }
            this.playerContext && this.playerContext.play()
        },
        lockLandscape() {
            try {
                if (typeof plus !== 'undefined' && plus.screen) {
                    plus.screen.lockOrientation('landscape-primary')
                }
            } catch (error) {}
        },
        lockPortrait() {
            try {
                if (typeof plus !== 'undefined' && plus.screen) {
                    plus.screen.lockOrientation('portrait-primary')
                }
            } catch (error) {}
        },
        closePlayer() {
            this.lockPortrait()
            setTimeout(() => {
                uni.navigateBack()
            }, 120)
        },
    },
}
function decodeRouteText(value) {
    if (typeof value !== 'string') {
        return ''
    }
    try {
        return decodeURIComponent(value)
    } catch (error) {
        return value
    }
}
function normalizeNumber(value) {
    const numberValue = Number(value || 0)
    return Number.isFinite(numberValue) && numberValue > 0 ? Math.floor(numberValue) : 0
}
</script>
<style scoped>
.fullscreen-page {
    position: relative;
    background-color: #000000;
}
.fullscreen-video {
    position: absolute;
    left: 0;
    top: 0;
    background-color: #000000;
}
.back-button {
    position: absolute;
    left: 24rpx;
    top: 16rpx;
    z-index: 9999;
    width: 64rpx;
    height: 64rpx;
    border-radius: 32rpx;
    background-color: rgba(0, 0, 0, 0.72);
    align-items: center;
    justify-content: center;
}
.back-button-text {
    color: #ffffff;
    font-size: 52rpx;
    font-weight: 400;
    text-align: center;
}
</style>
traditionalApp/static/image/live-fullscreen.png