| docs/superpowers/plans/2026-08-11-project-handover-analysis.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| tcm-training-admin-backend/sql/init.sql | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| tcm-training-admin-backend/sql/tcm_training_admin.sql | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/manifest.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/courseDetail/courseDetail.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/home/home.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/mine/mine.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/watchLive/watchLive.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| traditionalApp/pages/watchLiveFullscreen/watchLiveFullscreen.nvue | ●●●●● 补丁 | 查看 | 原始文档 | 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