<template>
|
<view class="container">
|
<!-- 导航栏 -->
|
<view class="nav-bar">
|
<image class="back-icon" src="http://obs.haizhentong.com/file/left-c8c6ad1c120949bab803e2b648a2e69b.png"
|
mode="aspectFit" @tap="goBack"></image>
|
<text class="nav-title">详情</text>
|
<view class="nav-right-placeholder"></view>
|
</view>
|
|
<!-- 顶部媒体区域 -->
|
<view class="media-section">
|
<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" :show-progress="true" :enable-progress-gesture="true"
|
: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"
|
: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" />
|
<view v-if="showPlaybackRetry" class="video-retry-mask" @tap.stop="retryPlayback(true)">
|
<text class="video-retry-text">点击开始播放</text>
|
</view>
|
</view>
|
<view v-else class="swiper-container">
|
<swiper class="swiper" @change="onSwiperChange">
|
<swiper-item v-for="(item, index) in swiperList" :key="index">
|
<image class="swiper-img" :src="item" mode="aspectFill"></image>
|
</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>
|
<!-- 图片指示器 -->
|
<view v-if="swiperList.length > 0" class="indicator">
|
<text>{{ currentSwiperIndex + 1 }}/{{ swiperList.length }}</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- Tab 切换 -->
|
<view class="tab-bar">
|
<view :class="['tab-item', { active: currentTab === 'course' }]" @tap="currentTab = 'course'">课程</view>
|
<view :class="['tab-item', { active: currentTab === 'list' }]" @tap="currentTab = 'list'">课程列表</view>
|
</view>
|
|
<!-- 页面滚动内容区域 -->
|
<scroll-view scroll-y class="scroll-content" :style="scrollContentStyle">
|
<template v-if="currentTab === 'course'">
|
<!-- 基本信息模块 -->
|
<view class="info-section">
|
<view class="info-title">{{ courseDetail.title || '养生课程详情' }}</view>
|
|
<view v-if="displayTags.length > 0" class="tags-box">
|
<text v-for="tag in displayTags" :key="tag" class="tag">{{ tag }}</text>
|
</view>
|
|
<view class="price-stats-box price-stats-box--plain">
|
<view class="stats-box">
|
<image class="person-icon"
|
src="http://obs.haizhentong.com/file/person-f8f5e24d3abe449c95b58cd6253aabfd.png"
|
mode="aspectFit"></image>
|
<text class="stats-text">{{ formatLearningCount(courseDetail.studyingCount) }}人正在学</text>
|
</view>
|
</view>
|
|
<view class="desc-text">{{ courseDetail.summary || '暂无课程介绍' }}</view>
|
</view>
|
|
<!-- 课程列表模块 (也在课程tab中展示) -->
|
<view class="course-list-section module-section">
|
<view class="list-header">
|
<text class="list-title">
|
课程列表
|
<text class="list-subtitle">共{{ courseDetail.videoCount || 0 }}课 |
|
{{ courseDetail.totalDurationText || '0分钟' }}</text>
|
</text>
|
<text class="expand-text">全部章节</text>
|
</view>
|
|
<template v-if="freeVideos.length > 0">
|
<image class="free-divider"
|
src="http://obs.haizhentong.com/file/free-172b4e6b23924752a06c7c13cf93237f.png"
|
mode="widthFix"></image>
|
|
<view class="list-items">
|
<view class="list-item" :class="{ 'list-item--active': isPlayingVideo(item.id) }"
|
v-for="(item, index) in freeVideos" :key="item.id" @tap="handleVideoSelect(item)">
|
<text class="item-title"
|
:class="{ 'item-title--active': isPlayingVideo(item.id) }">{{ index + 1 }}、{{ item.chapterName }}</text>
|
<text class="item-time">{{ item.durationText }}</text>
|
</view>
|
</view>
|
</template>
|
|
<template v-if="paidVideos.length > 0">
|
<image class="pay-divider"
|
src="http://obs.haizhentong.com/file/payToUnlock-b05a6aa76d9c42e6a9c532938180588b.png"
|
mode="widthFix">
|
</image>
|
|
<view class="list-items">
|
<view class="list-item" :class="{ 'list-item--active': isPlayingVideo(item.id) }"
|
v-for="(item, index) in paidVideos" :key="item.id" @tap="handleVideoSelect(item)">
|
<text class="item-title"
|
:class="{ 'item-title--active': isPlayingVideo(item.id) }">{{ freeVideos.length + index + 1 }}、{{ item.chapterName }}</text>
|
<view class="item-right-box">
|
<text class="item-time">{{ item.durationText }}</text>
|
<image v-if="!isReadonlyDisplayMode && shouldShowVideoDownload(item)"
|
class="download-icon"
|
src="http://obs.haizhentong.com/file/redDownload@2x-427aa37f68c2499f80269d5c44ff0fca.png"
|
mode="aspectFit" @tap.stop="handleVideoDownload(item)"></image>
|
<view v-else-if="!isReadonlyDisplayMode" class="cost-btn"
|
:class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }"
|
@tap.stop="handlePaidVideoTap(item)">
|
{{ item.purchased || courseDetail.courseOwned ? '已领取' : '领取' }}
|
</view>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<view v-if="!loading && allVideos.length === 0" class="empty-text">暂无课程章节</view>
|
</view>
|
|
<!-- 适用人群模块 -->
|
<view v-if="courseDetail.targetUsers" class="module-section module-section--with-padding">
|
<view class="module-title">适用人群</view>
|
<view class="module-desc">{{ courseDetail.targetUsers }}</view>
|
</view>
|
|
<!-- 授课专家模块 -->
|
<view v-if="courseDetail.expert" class="module-section module-section--with-padding" @tap="goToExpertDetail">
|
<view class="module-title">授课专家</view>
|
<view class="expert-card">
|
<image class="expert-avatar"
|
:src="courseDetail.expert.avatar || 'http://obs.haizhentong.com/file/avatr-b79ceea5dbfa4c51b247305c38a8471c.png'"
|
mode="widthFix">
|
</image>
|
<view class="expert-info">
|
<view class="expert-name">{{ courseDetail.expert.name || '专家老师' }}</view>
|
<view class="expert-desc">{{ courseDetail.expert.specialty || '点击查看专家介绍' }}</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 课程详情模块 -->
|
<view class="module-section module-section--with-padding last-module">
|
<view class="module-title">课程详情</view>
|
<rich-text :nodes="courseContentNodes" class="detail-content rich-detail-content"></rich-text>
|
</view>
|
</template>
|
|
<template v-else-if="currentTab === 'list'">
|
<!-- 课程列表模块 -->
|
<view class="course-list-section">
|
<view class="list-header">
|
<text class="list-title">
|
课程列表
|
<text class="list-subtitle">共{{ courseDetail.videoCount || 0 }}课 |
|
{{ courseDetail.totalDurationText || '0分钟' }}</text>
|
</text>
|
<text class="expand-text">全部章节</text>
|
</view>
|
|
<template v-if="freeVideos.length > 0">
|
<!-- <image class="free-divider"
|
src="http://obs.haizhentong.com/file/free-172b4e6b23924752a06c7c13cf93237f.png"
|
mode="widthFix"></image> -->
|
|
<view class="list-items">
|
<view class="list-item" :class="{ 'list-item--active': isPlayingVideo(item.id) }"
|
v-for="(item, index) in freeVideos" :key="item.id" @tap="handleVideoSelect(item)">
|
<text class="item-title"
|
:class="{ 'item-title--active': isPlayingVideo(item.id) }">{{ index + 1 }}、{{ item.chapterName }}</text>
|
<text class="item-time">{{ item.durationText }}</text>
|
</view>
|
</view>
|
</template>
|
|
<template v-if="paidVideos.length > 0">
|
<!-- <image class="pay-divider"
|
src="http://obs.haizhentong.com/file/payToUnlock-b05a6aa76d9c42e6a9c532938180588b.png"
|
mode="widthFix">
|
</image> -->
|
|
<view class="list-items">
|
<view class="list-item" :class="{ 'list-item--active': isPlayingVideo(item.id) }"
|
v-for="(item, index) in paidVideos" :key="item.id" @tap="handleVideoSelect(item)">
|
<text class="item-title"
|
:class="{ 'item-title--active': isPlayingVideo(item.id) }">{{ freeVideos.length + index + 1 }}、{{ item.chapterName }}</text>
|
<view class="item-right-box">
|
<text class="item-time">{{ item.durationText }}</text>
|
<image v-if="!isReadonlyDisplayMode && shouldShowVideoDownload(item)"
|
class="download-icon"
|
src="http://obs.haizhentong.com/file/redDownload@2x-427aa37f68c2499f80269d5c44ff0fca.png"
|
mode="aspectFit" @tap.stop="handleVideoDownload(item)"></image>
|
<view v-else-if="!isReadonlyDisplayMode" class="cost-btn"
|
:class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }"
|
@tap.stop="handlePaidVideoTap(item)">
|
{{ item.purchased || courseDetail.courseOwned ? '已领取' : '领取' }}
|
</view>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<view v-if="!loading && allVideos.length === 0" class="empty-text">暂无课程章节</view>
|
</view>
|
</template>
|
|
<view v-if="loading" class="loading-text">加载中...</view>
|
<!-- 占位随内容滚动,避免底部提示条遮挡最后一项。 -->
|
<view v-if="isReadonlyDisplayMode" class="share-bottom-spacer"></view>
|
</scroll-view>
|
|
<!-- 固定提示条放在滚动容器外,避免 iOS 滚动层裁剪。 -->
|
<view v-if="isReadonlyDisplayMode" class="open-app-banner" @tap="openApp">
|
<view class="app-logo-placeholder"></view>
|
<text class="banner-text">打开APP,了解更多</text>
|
<view class="open-btn">立即打开</view>
|
</view>
|
|
<!-- 底部悬浮按钮 -->
|
<view v-if="!isReadonlyDisplayMode" class="bottom-bar">
|
<view class="action-icons">
|
<view class="action-icon-item" @tap="handleConsult">
|
<image class="icon-img icon-listen"
|
src="http://obs.haizhentong.com/file/listen-6e8d31fd8dfa49c18d164ff6384cf2b4.png"
|
mode="aspectFit">
|
</image>
|
<text class="icon-text">咨询</text>
|
</view>
|
<view class="action-icon-item" @tap="toggleFavorite">
|
<image class="icon-img icon-love" :src="favoriteIcon" mode="aspectFit"></image>
|
<text class="icon-text">收藏</text>
|
</view>
|
<view class="action-icon-item" @tap="shareToWechatSession">
|
<image class="icon-img icon-share"
|
src="http://obs.haizhentong.com/file/share-dbafde8708c54516b1bc9ec768cc318f.png"
|
mode="aspectFit">
|
</image>
|
<text class="icon-text">分享</text>
|
</view>
|
</view>
|
<view class="buy-btn" :class="{ 'buy-btn--disabled': courseDetail.courseOwned }"
|
@tap="handleCoursePurchase">
|
{{ courseBuyButtonText }}
|
</view>
|
</view>
|
|
<u-popup v-model="showPaymentPopup" mode="bottom" border-radius="24" @close="closePaymentPopup">
|
<view class="payment-popup">
|
<view class="popup-header">
|
<wd-icon name="close" size="29rpx" color="#333" custom-class="close-icon"
|
@tap="closePaymentPopup"></wd-icon>
|
<text class="popup-title">支付</text>
|
</view>
|
|
<view class="payment-methods">
|
<text class="method-title">选择支付方式</text>
|
|
<view v-if="isIosApp" class="method-item" @tap="selectPaymentMethod('appleiap')">
|
<view class="method-left">
|
<view class="pay-icon pay-icon--apple">A</view>
|
<text class="method-name">App Store 支付</text>
|
</view>
|
<image class="radio-img"
|
:src="selectedPaymentMethod === 'appleiap' ? 'http://obs.haizhentong.com/file/check-3ced48105010464ba95e817457781857.png' : 'http://obs.haizhentong.com/file/uncheck-8fabf12f14d943feaffd74871bbdc181.png'"
|
mode="aspectFit"></image>
|
</view>
|
|
<template v-else>
|
<view class="method-item" @tap="selectPaymentMethod('wechat')">
|
<view class="method-left">
|
<image class="pay-icon"
|
src="http://obs.haizhentong.com/file/weixinPay-b323579a35244365930cd76acf068883.png"
|
mode="aspectFit"></image>
|
<text class="method-name">微信支付</text>
|
</view>
|
<image class="radio-img"
|
:src="selectedPaymentMethod === 'wechat' ? 'http://obs.haizhentong.com/file/check-3ced48105010464ba95e817457781857.png' : 'http://obs.haizhentong.com/file/uncheck-8fabf12f14d943feaffd74871bbdc181.png'"
|
mode="aspectFit"></image>
|
</view>
|
|
<view class="method-item" @tap="selectPaymentMethod('alipay')">
|
<view class="method-left">
|
<image class="pay-icon"
|
src="http://obs.haizhentong.com/file/aliPay-cd9c0e52a1fe4c5c8c7493952f20b018.png"
|
mode="aspectFit"></image>
|
<text class="method-name">支付宝支付</text>
|
</view>
|
<image class="radio-img"
|
:src="selectedPaymentMethod === 'alipay' ? 'http://obs.haizhentong.com/file/check-3ced48105010464ba95e817457781857.png' : 'http://obs.haizhentong.com/file/uncheck-8fabf12f14d943feaffd74871bbdc181.png'"
|
mode="aspectFit"></image>
|
</view>
|
</template>
|
</view>
|
|
<view class="popup-footer">
|
<view class="pay-btn" :class="{ 'pay-btn--disabled': purchaseSubmitting }" @tap="submitPurchase">
|
{{ purchaseSubmitting ? '领取中...' : '领取' }}
|
</view>
|
</view>
|
</view>
|
</u-popup>
|
</view>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
|
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
import config from '@/config/index.js'
|
import {
|
favoriteHealthCourse,
|
getHealthCourseDetail,
|
memberJoinHealthCourse,
|
purchaseHealthCourse,
|
purchaseHealthCourseVideo,
|
reportHealthCourseDownload,
|
unfavoriteHealthCourse,
|
} from './service'
|
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 { IOS_APP_STORE_URL } from '@/utils/app-version.js'
|
import {
|
bindVideoCaptureListener,
|
buildVideoWatermarkText,
|
disableVideoSecurityProtection,
|
enableVideoSecurityProtection,
|
isIosApp,
|
unbindVideoCaptureListener,
|
} from '@/utils/video-security.js'
|
|
interface CourseExpert {
|
id ?: number | null
|
name ?: string
|
avatar ?: string
|
specialty ?: string
|
}
|
|
interface CourseVideo {
|
id : number
|
chapterName : string
|
durationSeconds : number
|
durationText : string
|
salePrice : number | string
|
freeAccess : boolean
|
purchased : boolean
|
locked : boolean
|
videoUrl ?: string
|
appleProductId ?: string
|
productId ?: string
|
iosProductId ?: string
|
createdAt ?: string
|
updatedAt ?: string
|
}
|
|
interface CourseDetail {
|
id ?: number | null
|
title : string
|
bannerImages : string[]
|
levelName : string
|
difficultyName : string
|
salePrice : number | string
|
learnUserCount : number
|
/** 正在学习人数:后端在返回详情时会自增,用于「X人正在学」展示 */
|
studyingCount : number
|
summary : string
|
targetUsers : string
|
content : string
|
videoCount : number
|
totalDurationSeconds : number
|
totalDurationText : string
|
expert : CourseExpert | null
|
freeVideoCount : number
|
paidVideoCount : number
|
videos : CourseVideo[]
|
servicePhone : string
|
favorited : boolean
|
isMember : boolean
|
memberCourseJoined : boolean
|
courseOwned : boolean
|
coursePurchased : boolean
|
appleProductId ?: string
|
productId ?: string
|
iosProductId ?: string
|
}
|
|
interface SwiperChangeEvent {
|
detail ?: {
|
current ?: number
|
}
|
}
|
|
type PaymentMethod = 'wechat' | 'alipay' | 'appleiap'
|
type PurchaseTarget = 'COURSE' | 'VIDEO'
|
|
const VIDEO_PLAYER_ID = 'course-detail-video-player'
|
let isAppPlus = false
|
let isH5 = false
|
// #ifdef APP-PLUS
|
isAppPlus = true
|
// #endif
|
// #ifdef H5
|
isH5 = true
|
// #endif
|
|
const currentTab = ref('course')
|
const currentSwiperIndex = ref(0)
|
const loading = ref(false)
|
const watermarkTick = ref(Date.now())
|
const favoriteSubmitting = ref(false)
|
const purchaseSubmitting = ref(false)
|
const memberJoinSubmitting = ref(false)
|
const showPaymentPopup = ref(false)
|
const selectedPaymentMethod = ref<PaymentMethod>(isIosApp ? 'appleiap' : 'wechat')
|
const activePurchaseTarget = ref<PurchaseTarget>('COURSE')
|
const activePurchaseVideoId = ref<number | null>(null)
|
const courseId = ref('')
|
const shouldPlayLatestVideoOnLoad = ref(false)
|
const shouldPlayFirstVideoOnLoad = ref(false)
|
const courseDetail = ref<CourseDetail>(createDefaultCourseDetail())
|
const activeVideoId = ref<number>(0)
|
const showVideoPlayer = ref(false)
|
const isVideoReady = ref(false)
|
const isPlaybackStarted = ref(false)
|
const playbackFailed = ref(false)
|
const instance = getCurrentInstance()
|
let videoContext : ReturnType<typeof uni.createVideoContext> | null = null
|
let videoSecurityTimer : ReturnType<typeof setInterval> | null = null
|
|
/**
|
* 创建课程详情页默认数据,避免接口返回前页面闪空。
|
*/
|
function createDefaultCourseDetail() : CourseDetail {
|
return {
|
id: null,
|
title: '',
|
bannerImages: [],
|
levelName: '',
|
difficultyName: '',
|
salePrice: 0,
|
learnUserCount: 0,
|
studyingCount: 0,
|
summary: '',
|
targetUsers: '',
|
content: '',
|
videoCount: 0,
|
totalDurationSeconds: 0,
|
totalDurationText: '',
|
expert: null,
|
freeVideoCount: 0,
|
paidVideoCount: 0,
|
videos: [],
|
servicePhone: '',
|
favorited: false,
|
isMember: false,
|
memberCourseJoined: false,
|
courseOwned: false,
|
coursePurchased: false,
|
}
|
}
|
|
const swiperList = computed(() => {
|
if (courseDetail.value.bannerImages.length > 0) {
|
return courseDetail.value.bannerImages
|
}
|
return ['http://obs.haizhentong.com/file/class-01479c4bc4e04f63b0425023eb1652d5.png']
|
})
|
|
const displayTags = computed(() => {
|
const tags : string[] = []
|
if (courseDetail.value.levelName) {
|
tags.push(courseDetail.value.levelName)
|
}
|
if (courseDetail.value.difficultyName) {
|
tags.push(courseDetail.value.difficultyName)
|
}
|
return tags
|
})
|
|
const allVideos = computed(() => courseDetail.value.videos || [])
|
|
const freeVideos = computed(() => {
|
return allVideos.value.filter((item) => item.freeAccess)
|
})
|
|
const paidVideos = computed(() => {
|
return allVideos.value.filter((item) => !item.freeAccess)
|
})
|
|
const courseBuyButtonText = computed(() => {
|
if (courseDetail.value.courseOwned) {
|
return '已加入'
|
}
|
if (isFreeCourse()) {
|
return '加入我的课程'
|
}
|
return '领取'
|
})
|
const favoriteIcon = computed(() => {
|
return courseDetail.value.favorited
|
? 'http://obs.haizhentong.com/file/loved-a189e628d5c94db193c5673e6e068ea8.png'
|
: 'http://obs.haizhentong.com/file/love-5526045c892e4e2c8e2093467ac9121f.png'
|
})
|
const currentVideo = computed(() => {
|
return allVideos.value.find((item) => item.id === activeVideoId.value) || null
|
})
|
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) {
|
return {
|
paddingBottom: '0px',
|
}
|
}
|
if (!isAppPlus) {
|
return {
|
paddingBottom: 'calc(260rpx + env(safe-area-inset-bottom))',
|
}
|
}
|
return {}
|
})
|
const videoWatermarkText = computed(() => {
|
const currentUserId = resolveCurrentAppUserId()
|
if (!currentUserId) {
|
return ''
|
}
|
return buildVideoWatermarkText(currentUserId, watermarkTick.value)
|
})
|
const showPlaybackRetry = computed(() => {
|
if (!showVideoPlayer.value || !currentVideoUrl.value || !isVideoReady.value) {
|
return false
|
}
|
return playbackFailed.value || (isH5 && !isPlaybackStarted.value)
|
})
|
|
const courseContentNodes = computed(() => buildRichTextNodes(courseDetail.value.content, '暂无课程详情'))
|
|
/**
|
* 页面加载时读取课程主键并拉取详情接口。
|
*/
|
onLoad((options) => {
|
courseId.value = resolveCourseId(options)
|
shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(options, 'playLatest')
|
shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(options, 'playFirst')
|
loadCourseDetail()
|
})
|
|
onMounted(() => {
|
setTimeout(() => {
|
// onLoad 已解析出课程 id 时不再重复请求,避免详情接口被调用两次导致学习人数重复自增
|
if (courseId.value) {
|
return
|
}
|
const pageOptions = getCurrentPageOptions()
|
courseId.value = resolveCourseId(pageOptions)
|
shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playLatest')
|
shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playFirst')
|
loadCourseDetail()
|
}, 0)
|
})
|
|
function getCurrentPageOptions() {
|
const pages = getCurrentPages()
|
const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined
|
return currentPage?.options || currentPage?.$page?.options || {}
|
}
|
|
/**
|
* 统一解析课程 id,兼容 App 路由参数和 H5 分享页地址参数。
|
*/
|
function resolveCourseId(options ?: Record<string, unknown>) {
|
const routeCourseId = normalizeOptionalText(options?.id)
|
if (routeCourseId) {
|
return routeCourseId
|
}
|
// #ifdef H5
|
try {
|
const searchCourseId = new URLSearchParams(window.location.search).get('id')?.trim() || ''
|
if (searchCourseId) {
|
return searchCourseId
|
}
|
const hashQuery = window.location.hash.split('?')[1] || ''
|
return new URLSearchParams(hashQuery).get('id')?.trim() || ''
|
} catch (error) {
|
console.log('解析课程分享参数失败', error)
|
}
|
// #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
|
}
|
|
/**
|
* 页面显示时开启播放器安全能力。
|
*/
|
onShow(() => {
|
enableVideoSecurityProtection()
|
bindVideoCaptureListener(handleVideoCapture)
|
startVideoWatermarkTicker()
|
})
|
|
/**
|
* 页面隐藏时关闭播放器安全能力。
|
*/
|
onHide(() => {
|
stopVideoWatermarkTicker()
|
unbindVideoCaptureListener(handleVideoCapture)
|
disableVideoSecurityProtection()
|
})
|
|
/**
|
* 页面销毁时释放播放器安全能力。
|
*/
|
onUnload(() => {
|
stopVideoWatermarkTicker()
|
unbindVideoCaptureListener(handleVideoCapture)
|
disableVideoSecurityProtection()
|
})
|
|
/**
|
* 拉取课程详情接口并合并到页面状态。
|
*/
|
async function loadCourseDetail() {
|
if (!courseId.value) {
|
courseDetail.value = createDefaultCourseDetail()
|
return
|
}
|
loading.value = true
|
try {
|
const response = await getHealthCourseDetail(courseId.value)
|
if (response?.code !== 0 || !response.data) {
|
showLoadFailedToast()
|
return
|
}
|
// 后端已按免费/付费规则整理好数据,前端仅负责展示,避免重复业务分叉。
|
courseDetail.value = {
|
...createDefaultCourseDetail(),
|
...response.data,
|
bannerImages: Array.isArray(response.data.bannerImages) ? response.data.bannerImages : [],
|
servicePhone: typeof response.data.servicePhone === 'string' ? response.data.servicePhone.trim() : '',
|
isMember: !!response.data.isMember,
|
memberCourseJoined: !!response.data.memberCourseJoined,
|
courseOwned: !!response.data.courseOwned,
|
coursePurchased: !!response.data.coursePurchased,
|
videos: Array.isArray(response.data.videos)
|
? response.data.videos.map((item) => ({
|
...item,
|
purchased: !!item?.purchased,
|
locked: !!item?.locked,
|
videoUrl: typeof item?.videoUrl === 'string' ? item.videoUrl.trim() : '',
|
}))
|
: [],
|
}
|
if (!courseId.value && response.data.id !== undefined && response.data.id !== null) {
|
courseId.value = String(response.data.id)
|
}
|
if (currentSwiperIndex.value >= swiperList.value.length) {
|
currentSwiperIndex.value = 0
|
}
|
syncActiveVideoAfterDetailLoad()
|
handlePlayFirstVideoAfterDetailLoad()
|
handlePlayLatestVideoAfterDetailLoad()
|
} catch {
|
showLoadFailedToast()
|
} finally {
|
loading.value = false
|
}
|
}
|
|
/**
|
* 课程详情加载失败时给出统一提示。
|
*/
|
function showLoadFailedToast() {
|
uni.showToast({
|
title: '课程详情加载失败',
|
icon: 'none',
|
})
|
}
|
|
/**
|
* 返回上一页;若当前页没有历史栈,则回到首页避免留在空页。
|
*/
|
function goBack() {
|
const currentPages = getCurrentPages()
|
if (currentPages.length > 1) {
|
uni.navigateBack()
|
return
|
}
|
uni.switchTab({
|
url: '/pages/home/home'
|
})
|
}
|
|
/**
|
* 同步轮播当前索引,驱动右下角页码指示器。
|
*/
|
function onSwiperChange(event : SwiperChangeEvent) {
|
currentSwiperIndex.value = event.detail?.current || 0
|
}
|
|
/**
|
* 详情刷新后同步当前选中的小节,避免章节被删除或数据刷新后播放器指向失效。
|
*/
|
function syncActiveVideoAfterDetailLoad() {
|
if (!activeVideoId.value) {
|
return
|
}
|
const matchedVideo = allVideos.value.find((item) => item.id === activeVideoId.value)
|
if (matchedVideo) {
|
return
|
}
|
resetVideoPlayerState()
|
}
|
|
/**
|
* 首页播放入口要求直接播放最新上传章节,详情接口返回后再执行。
|
*/
|
function handlePlayLatestVideoAfterDetailLoad() {
|
if (!shouldPlayLatestVideoOnLoad.value) {
|
return
|
}
|
shouldPlayLatestVideoOnLoad.value = false
|
playLatestUploadedVideo()
|
}
|
|
/**
|
* 首页精选课程播放按钮要求直接播放第一个章节,详情接口返回后再执行。
|
*/
|
function handlePlayFirstVideoAfterDetailLoad() {
|
if (!shouldPlayFirstVideoOnLoad.value) {
|
return
|
}
|
shouldPlayFirstVideoOnLoad.value = false
|
playFirstCourseVideo()
|
}
|
|
/**
|
* 课程介绍封面播放按钮:播放第一个章节。
|
*/
|
function playFirstCourseVideoFromPoster() {
|
playFirstCourseVideo()
|
}
|
|
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)
|
}
|
|
function playFirstCourseVideo() {
|
const firstVideo = resolveFirstCourseVideo(allVideos.value)
|
if (!firstVideo) {
|
uni.showToast({
|
title: '暂无可播放课程视频',
|
icon: 'none',
|
})
|
return
|
}
|
currentTab.value = 'list'
|
if (canPlayVideo(firstVideo)) {
|
playSelectedVideo(firstVideo)
|
return
|
}
|
handlePaidVideoTap(firstVideo)
|
}
|
|
function resolveFirstCourseVideo(videos : CourseVideo[]) {
|
return videos.find((item) => !!item?.videoUrl) || null
|
}
|
|
/**
|
* 优先按上传时间取最新章节;旧接口无时间字段时按自增 id 兜底。
|
*/
|
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
|
}
|
|
/**
|
* 判断当前章节是否允许在详情页直接播放。
|
*/
|
function canPlayVideo(item : CourseVideo) {
|
if (!item.videoUrl) {
|
return false
|
}
|
if (item.freeAccess) {
|
return true
|
}
|
return !!(courseDetail.value.courseOwned || item.purchased)
|
}
|
|
/**
|
* 判断指定章节是否为当前正在播放的小节。
|
*/
|
function isPlayingVideo(videoId : number) {
|
return showVideoPlayer.value && activeVideoId.value === videoId
|
}
|
|
/**
|
* 统一处理章节点击,可播放时切换顶部播放器,否则保留原有购买弹窗。
|
*/
|
function handleVideoSelect(item : CourseVideo) {
|
// 暂时放开 H5 章节点击,仍由 canPlayVideo 校验观看权限。
|
// if (isReadonlyDisplayMode) {
|
// return
|
// }
|
if (canPlayVideo(item)) {
|
// 点击章节属于用户手势,需透传给播放逻辑,否则 H5 会被浏览器自动播放策略拦截
|
playSelectedVideo(item, true)
|
return
|
}
|
handlePaidVideoTap(item)
|
}
|
|
/**
|
* 切换顶部播放器到指定小节,并在可自动播放的环境中立即开始播放。
|
* @param fromUserGesture 是否由用户点击章节触发,H5 需要借助该手势放行自动播放
|
*/
|
async function playSelectedVideo(item : CourseVideo, fromUserGesture = false) {
|
if (!canPlayVideo(item)) {
|
return
|
}
|
if (activeVideoId.value === item.id && showVideoPlayer.value) {
|
retryPlayback(fromUserGesture)
|
return
|
}
|
activeVideoId.value = item.id
|
showVideoPlayer.value = true
|
resetVideoPlaybackFlags()
|
await nextTick()
|
initVideoContext()
|
retryPlayback(fromUserGesture)
|
}
|
|
/**
|
* 初始化视频上下文,便于章节切换后主动触发播放。
|
*/
|
function initVideoContext() {
|
videoContext = uni.createVideoContext(VIDEO_PLAYER_ID, instance?.proxy)
|
}
|
|
/**
|
* 重置播放器展示与播放状态。
|
*/
|
function resetVideoPlayerState() {
|
showVideoPlayer.value = false
|
activeVideoId.value = 0
|
resetVideoPlaybackFlags()
|
videoContext = null
|
}
|
|
/**
|
* 重置当前播放器的就绪与重试标记。
|
*/
|
function resetVideoPlaybackFlags() {
|
isVideoReady.value = false
|
isPlaybackStarted.value = false
|
playbackFailed.value = false
|
}
|
|
/**
|
* 视频元数据准备完成后再次触发播放,兼容播放器节点刚挂载完成的场景。
|
*/
|
function handleVideoLoadedMetadata() {
|
isVideoReady.value = true
|
retryPlayback()
|
}
|
|
/**
|
* 视频开始播放后清理重试遮罩。
|
*/
|
function handleVideoPlay() {
|
isPlaybackStarted.value = true
|
playbackFailed.value = false
|
}
|
|
/**
|
* 视频播放失败时展示点击重试入口,兼容 H5 自动播放限制。
|
*/
|
function handleVideoError() {
|
playbackFailed.value = true
|
}
|
|
/**
|
* H5 下取出播放器内部的原生 video 节点。
|
* uni 的 videoContext.play() 在 H5 下拿不到 Promise,无法感知“被浏览器拦截”的情况,这里直接操作原生节点。
|
*/
|
function resolveNativeVideoElement() {
|
// #ifdef H5
|
const container = document.getElementById(VIDEO_PLAYER_ID)
|
if (!container) {
|
return null
|
}
|
// id 可能挂在播放器外层节点上,也可能直接落在原生 video 节点上,两种结构都兼容
|
if (container instanceof HTMLVideoElement) {
|
return container
|
}
|
return container.querySelector('video')
|
// #endif
|
return null
|
}
|
|
/**
|
* 对当前播放器执行播放重试,避免首次切换章节时无感失败。
|
* @param fromUserGesture 是否由用户点击触发:H5 有自动播放限制,只有用户手势内调用 play 才可靠
|
*/
|
function retryPlayback(fromUserGesture = false) {
|
// H5 下非用户手势触发的自动播放会被浏览器拦截,这里直接跳过;点击章节或遮罩属于用户手势,需要真正执行
|
if (isReadonlyDisplayMode && !fromUserGesture) {
|
return
|
}
|
if (!currentVideoUrl.value) {
|
return
|
}
|
if (isReadonlyDisplayMode) {
|
const nativeVideo = resolveNativeVideoElement()
|
if (!nativeVideo) {
|
playbackFailed.value = true
|
return
|
}
|
const playPromise = nativeVideo.play()
|
if (playPromise) {
|
// 播放被拦截(如非用户手势)时展示“点击开始播放”遮罩兜底
|
playPromise.catch(() => {
|
playbackFailed.value = true
|
})
|
}
|
return
|
}
|
if (!videoContext) {
|
return
|
}
|
try {
|
videoContext.play()
|
} catch {
|
playbackFailed.value = true
|
}
|
}
|
|
/**
|
* 收到截图时给出安全提示。
|
*/
|
function handleVideoCapture() {
|
uni.showToast({
|
title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏',
|
icon: 'none',
|
})
|
}
|
|
/**
|
* 刷新播放器水印时间片段。
|
*/
|
function startVideoWatermarkTicker() {
|
if (videoSecurityTimer) {
|
return
|
}
|
videoSecurityTimer = setInterval(() => {
|
watermarkTick.value = Date.now()
|
}, 60000)
|
}
|
|
/**
|
* 关闭播放器水印刷新定时器。
|
*/
|
function stopVideoWatermarkTicker() {
|
if (!videoSecurityTimer) {
|
return
|
}
|
clearInterval(videoSecurityTimer)
|
videoSecurityTimer = null
|
}
|
|
/**
|
* 跳转专家介绍页,并携带专家主键加载真实内容。
|
*/
|
function goToExpertDetail() {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!courseDetail.value.expert?.id) {
|
return
|
}
|
uni.navigateTo({
|
url: `/pages/protocol/protocol?type=expert&id=${courseDetail.value.expert.id}`
|
})
|
}
|
|
/**
|
* 拨打后台系统配置中的客服电话,APP 端优先走原生拨号页。
|
*/
|
function handleConsult() {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
const servicePhone = courseDetail.value.servicePhone.trim()
|
if (!servicePhone) {
|
uni.showToast({
|
title: '暂未配置客服电话',
|
icon: 'none',
|
})
|
return
|
}
|
if (openNativeDialer(servicePhone)) {
|
return
|
}
|
uni.makePhoneCall({
|
phoneNumber: servicePhone,
|
})
|
}
|
|
/**
|
* APP 端优先使用原生能力打开系统拨号页。
|
*/
|
function openNativeDialer(phoneNumber : string) {
|
// #ifdef APP-PLUS
|
try {
|
plus.runtime.openURL(`tel:${phoneNumber}`)
|
return true
|
} catch (error) {
|
try {
|
plus.device.dial(phoneNumber, false)
|
return true
|
} catch (dialError) {
|
return false
|
}
|
}
|
// #endif
|
return false
|
}
|
|
/**
|
* 拉起或跳转下载 APP
|
*/
|
async function openApp() {
|
// #ifdef H5
|
// iOS 直接在点击事件内跳转,避免异步 SDK 调用影响商店跳转。
|
const userAgent = window.navigator.userAgent
|
const isIosBrowser = /iPad|iPhone|iPod/i.test(userAgent)
|
|| (/Macintosh/i.test(userAgent) && window.navigator.maxTouchPoints > 1)
|
if (isIosBrowser) {
|
window.location.assign(IOS_APP_STORE_URL)
|
return
|
}
|
const openinstallOpened = await wakeupOrInstallByOpeninstall({
|
page: 'courseDetail',
|
courseId: String(resolveShareCourseId() || '').trim()
|
})
|
if (openinstallOpened) {
|
return
|
}
|
if (config.OPENINSTALL_URL) {
|
window.location.assign(config.OPENINSTALL_URL)
|
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
|
uni.showToast({
|
title: '当前环境不支持打开链接',
|
icon: 'none',
|
})
|
}
|
|
/**
|
* 生成课程分享文案,优先带上标题和摘要。
|
*/
|
function buildCourseShareText() {
|
const title = courseDetail.value.title.trim() || '养生课程'
|
const summary = courseDetail.value.summary.trim()
|
return summary ? `给你推荐一个课程:${title}。${summary}` : `给你推荐一个课程:${title}`
|
}
|
|
/**
|
* 生成课程 H5 分享链接,供微信好友打开页面详情。
|
*/
|
function buildCourseShareUrl() {
|
const shareBaseUrl = 'https://haizhentong.com/share/'
|
const shareCourseId = resolveShareCourseId()
|
if (!shareCourseId) {
|
return shareBaseUrl
|
}
|
return `${shareBaseUrl}?id=${encodeURIComponent(shareCourseId)}`
|
}
|
|
/**
|
* 生成分享使用的课程 id,优先使用路由参数,详情数据兜底。
|
*/
|
function resolveShareCourseId() {
|
if (courseId.value) {
|
return courseId.value
|
}
|
if (courseDetail.value.id !== undefined && courseDetail.value.id !== null) {
|
return String(courseDetail.value.id)
|
}
|
return ''
|
}
|
|
/**
|
* 通过微信好友聊天场景分享当前课程。
|
*/
|
function shareToWechatSession() {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
// #ifdef APP-PLUS
|
plus.share.getServices((services) => {
|
const weixinService = services.find((service) => service.id === 'weixin')
|
if (!weixinService) {
|
uni.showToast({
|
title: '当前环境不支持微信分享',
|
icon: 'none',
|
})
|
return
|
}
|
if (!weixinService.nativeClient) {
|
uni.showToast({
|
title: '请先安装微信客户端',
|
icon: 'none',
|
})
|
return
|
}
|
weixinService.send({
|
title: courseDetail.value.title.trim() || '养生课程',
|
content: buildCourseShareText(),
|
href: buildCourseShareUrl(),
|
thumbs: courseDetail.value.bannerImages[0] ? [courseDetail.value.bannerImages[0]] : undefined,
|
extra: {
|
scene: 'WXSceneSession',
|
},
|
}, () => {
|
uni.showToast({
|
title: '分享成功',
|
icon: 'success',
|
})
|
}, (error) => {
|
console.log('课程分享失败', error)
|
uni.showToast({
|
title: error?.message || '分享失败',
|
icon: 'none',
|
})
|
})
|
}, (error) => {
|
console.log('获取微信分享服务失败', error)
|
uni.showToast({
|
title: '微信分享不可用',
|
icon: 'none',
|
})
|
})
|
return
|
// #endif
|
|
uni.showToast({
|
title: '请在App中使用微信分享',
|
icon: 'none',
|
})
|
}
|
|
/**
|
* 构建适配当前价格展示区的两位小数字符串。
|
*/
|
function formatPrice(price : number | string | undefined) {
|
const normalizedNumber = Number(price || 0)
|
if (!Number.isFinite(normalizedNumber)) {
|
return '0.00'
|
}
|
return normalizedNumber.toFixed(2)
|
}
|
|
/**
|
* 格式化学习人数,保持首页列表已有展示习惯。
|
*/
|
function formatLearningCount(count ?: number) {
|
const normalizedCount = Number(count || 0)
|
if (normalizedCount >= 10000) {
|
return `${(normalizedCount / 10000).toFixed(1).replace(/\.0$/, '')}W`
|
}
|
return `${normalizedCount}`
|
}
|
|
/**
|
* 将富文本正文转换为 `rich-text` 可直接渲染的节点。
|
*/
|
function buildRichTextNodes(content ?: string, fallbackText ?: string) {
|
if (typeof content === 'string' && content.trim()) {
|
return content
|
}
|
if (typeof fallbackText === 'string' && fallbackText.trim()) {
|
return `<p>${fallbackText.trim()}</p>`
|
}
|
return '<p></p>'
|
}
|
|
/**
|
* 读取当前缓存中的 APP 用户标识,供本地下载任务按账号隔离。
|
*/
|
function resolveCurrentUserId() {
|
return resolveCurrentAppUserId()
|
}
|
|
/**
|
* 判断指定章节是否已存在下载中任务,避免重复触发原生下载。
|
*/
|
function hasActiveDownloadTask(userId : number, courseVideoId : number) {
|
return userId > 0 && hasCourseDownloadTask(courseVideoId)
|
}
|
|
/**
|
* 直接领取整课,不展示支付方式弹窗。
|
*/
|
function handleCoursePurchase() {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!courseId.value || courseDetail.value.courseOwned || memberJoinSubmitting.value || purchaseSubmitting.value) {
|
return
|
}
|
if (courseDetail.value.isMember) {
|
submitMemberJoin()
|
return
|
}
|
activePurchaseTarget.value = 'COURSE'
|
activePurchaseVideoId.value = null
|
void submitZeroPricePurchase()
|
}
|
|
/**
|
* 处理付费小节点击,未购买时直接走 0 元领取流程。
|
*/
|
function handlePaidVideoTap(item : CourseVideo) {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!courseId.value || courseDetail.value.courseOwned || item.purchased || purchaseSubmitting.value) {
|
return
|
}
|
activePurchaseTarget.value = 'VIDEO'
|
activePurchaseVideoId.value = item.id
|
void submitZeroPricePurchase()
|
}
|
|
/**
|
* 判断当前章节是否应展示下载按钮。
|
*/
|
function shouldShowVideoDownload(item : CourseVideo) {
|
// 只有用户已拥有该小节且后台已配置视频地址时,才允许展示下载入口。
|
return !!(item.videoUrl && (courseDetail.value.courseOwned || item.purchased))
|
}
|
|
/**
|
* 下载已购买章节视频,H5 直接打开资源地址,其它端走文件下载。
|
*/
|
function handleVideoDownload(item : CourseVideo) {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!shouldShowVideoDownload(item)) {
|
return
|
}
|
const currentUserId = resolveCurrentUserId()
|
if (!currentUserId) {
|
uni.showToast({
|
title: '请先登录',
|
icon: 'none',
|
})
|
return
|
}
|
if (hasActiveDownloadTask(currentUserId, item.id)) {
|
uni.showToast({
|
title: '当前章节已在本地下载列表中',
|
icon: 'none',
|
})
|
return
|
}
|
startCourseVideoDownload({
|
courseId: Number(courseId.value || 0),
|
courseVideoId: item.id,
|
title: item.chapterName || '课程章节',
|
subtitle: courseDetail.value.title || '养生课程',
|
coverImage: swiperList.value[0] || 'http://obs.haizhentong.com/file/class-01479c4bc4e04f63b0425023eb1652d5.png',
|
durationSeconds: Number(item.durationSeconds || 0),
|
durationText: item.durationText || '00:00',
|
videoUrl: item.videoUrl || '',
|
reportDownload: async () => {
|
const response = await reportHealthCourseDownload({
|
courseId: Number(courseId.value),
|
courseVideoId: item.id,
|
})
|
if (response?.code !== 0) {
|
throw new Error('report failed')
|
}
|
},
|
})
|
}
|
|
/**
|
* 切换支付方式。
|
*/
|
function selectPaymentMethod(method : PaymentMethod) {
|
selectedPaymentMethod.value = method
|
}
|
|
/**
|
* 关闭支付弹窗并清理当前购买目标。
|
*/
|
function closePaymentPopup() {
|
if (purchaseSubmitting.value) {
|
return
|
}
|
showPaymentPopup.value = false
|
activePurchaseTarget.value = 'COURSE'
|
activePurchaseVideoId.value = null
|
}
|
|
/**
|
* 提交当前购买目标的直付订单。
|
*/
|
async function submitPurchase() {
|
const payload = selectedPaymentMethod.value === 'appleiap'
|
? await buildAppleIapPurchasePayload()
|
: {
|
paymentMethod: selectedPaymentMethod.value === 'wechat' ? 'WECHAT' : 'ALIPAY',
|
}
|
if (!payload) {
|
return
|
}
|
await submitPurchaseWithPayload(payload, '购买成功')
|
}
|
|
/**
|
* 提交当前领取目标的 0 元订单,占位支付方式仅用于兼容后端校验。
|
*/
|
async function submitZeroPricePurchase() {
|
const successTitle = activePurchaseTarget.value === 'COURSE' && isFreeCourse()
|
? '已加入我的课程'
|
: '领取成功'
|
await submitPurchaseWithPayload({
|
paymentMethod: 'WECHAT',
|
}, successTitle)
|
}
|
|
/**
|
* 判断整课是否为 0 元课程,用于区分按钮文案和加入提示。
|
*/
|
function isFreeCourse() {
|
const salePrice = Number(courseDetail.value.salePrice || 0)
|
return Number.isFinite(salePrice) && salePrice <= 0
|
}
|
|
/**
|
* 使用指定支付载荷提交当前购买或领取目标。
|
*/
|
async function submitPurchaseWithPayload(payload : Record<string, unknown>, successTitle : string) {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!courseId.value || purchaseSubmitting.value) {
|
return
|
}
|
if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value === null) {
|
return
|
}
|
purchaseSubmitting.value = true
|
try {
|
const response = activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value !== null
|
? await purchaseHealthCourseVideo(courseId.value, activePurchaseVideoId.value, payload)
|
: await purchaseHealthCourse(courseId.value, payload)
|
if (response?.code !== 0 || !response.data) {
|
return
|
}
|
applyPurchaseState(response.data)
|
showPaymentPopup.value = false
|
activePurchaseTarget.value = 'COURSE'
|
activePurchaseVideoId.value = null
|
uni.showToast({
|
title: successTitle,
|
icon: 'none',
|
})
|
} finally {
|
purchaseSubmitting.value = false
|
}
|
}
|
|
/**
|
* 拉起苹果内购并组装课程/章节购买所需的验单参数。
|
*/
|
async function buildAppleIapPurchasePayload() {
|
const appleProductId = resolveActiveAppleProductId()
|
if (!appleProductId) {
|
uni.showToast({
|
title: '当前商品未配置苹果内购商品',
|
icon: 'none',
|
})
|
return null
|
}
|
const iapResult = await requestAppleIapPayment({
|
productId: appleProductId,
|
username: resolveAppleIapUsername(),
|
})
|
if (!normalizeOptionalText(iapResult?.transactionReceipt)) {
|
uni.showToast({
|
title: '苹果内购凭证缺失',
|
icon: 'none',
|
})
|
return null
|
}
|
return {
|
paymentMethod: 'APPLE_IAP',
|
appleProductId,
|
transactionReceipt: iapResult.transactionReceipt,
|
transactionIdentifier: iapResult.transactionIdentifier,
|
}
|
}
|
|
/**
|
* 根据购买接口返回值回写课程与小节的已购态。
|
*/
|
function applyPurchaseState(purchaseResult ?: { coursePurchased ?: boolean; purchasedVideoIds ?: Array<number | string | null> }) {
|
const purchasedVideoIds = new Set(
|
Array.isArray(purchaseResult?.purchasedVideoIds)
|
? purchaseResult!.purchasedVideoIds
|
.map((item) => Number(item))
|
.filter((item) => Number.isFinite(item))
|
: []
|
)
|
const coursePurchased = !!purchaseResult?.coursePurchased
|
courseDetail.value = {
|
...courseDetail.value,
|
courseOwned: courseDetail.value.courseOwned || coursePurchased,
|
coursePurchased,
|
videos: courseDetail.value.videos.map((item) => {
|
const purchased = coursePurchased || purchasedVideoIds.has(Number(item.id)) || !!item.purchased
|
return {
|
...item,
|
purchased,
|
locked: !item.freeAccess && !purchased,
|
}
|
}),
|
}
|
// 用户直接购买当前小节后,立即切到可播放状态,减少重复点击成本。
|
if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value !== null) {
|
const purchasedVideo = courseDetail.value.videos.find((item) => item.id === activePurchaseVideoId.value)
|
if (purchasedVideo && canPlayVideo(purchasedVideo)) {
|
playSelectedVideo(purchasedVideo)
|
}
|
}
|
}
|
|
function resolveActiveAppleProductId() {
|
if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value !== null) {
|
const currentVideo = courseDetail.value.videos.find((item) => Number(item.id) === Number(activePurchaseVideoId.value))
|
return normalizeOptionalText(currentVideo?.appleProductId || currentVideo?.productId || currentVideo?.iosProductId)
|
}
|
return normalizeOptionalText(courseDetail.value.appleProductId || courseDetail.value.productId || courseDetail.value.iosProductId)
|
}
|
|
function resolveAppleIapUsername() {
|
const userId = resolveCurrentAppUserId()
|
if (userId > 0) {
|
return String(userId)
|
}
|
return 'guest'
|
}
|
|
/**
|
* 提交会员加课请求,并将整课直接回写为已拥有。
|
*/
|
async function submitMemberJoin() {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!courseId.value || memberJoinSubmitting.value || courseDetail.value.courseOwned || !courseDetail.value.isMember) {
|
return
|
}
|
memberJoinSubmitting.value = true
|
try {
|
const response = await memberJoinHealthCourse(courseId.value)
|
if (response?.code !== 0) {
|
return
|
}
|
applyMemberJoinState()
|
uni.showToast({
|
title: '已加入我的课程',
|
icon: 'none',
|
})
|
} finally {
|
memberJoinSubmitting.value = false
|
}
|
}
|
|
/**
|
* 根据会员加课结果回写整课拥有态,并解锁当前课程全部付费小节。
|
*/
|
function applyMemberJoinState() {
|
courseDetail.value = {
|
...courseDetail.value,
|
memberCourseJoined: true,
|
courseOwned: true,
|
videos: courseDetail.value.videos.map((item) => ({
|
...item,
|
purchased: true,
|
locked: false,
|
})),
|
}
|
}
|
|
/**
|
* 切换课程收藏状态,并直接回写详情页当前展示数据。
|
*/
|
async function toggleFavorite() {
|
if (isReadonlyDisplayMode) {
|
return
|
}
|
if (!courseId.value || favoriteSubmitting.value) {
|
return
|
}
|
favoriteSubmitting.value = true
|
try {
|
const response = courseDetail.value.favorited
|
? await unfavoriteHealthCourse(courseId.value)
|
: await favoriteHealthCourse(courseId.value)
|
if (response?.code !== 0 || !response.data) {
|
return
|
}
|
courseDetail.value = {
|
...courseDetail.value,
|
favorited: !!response.data.favorited,
|
}
|
uni.showToast({
|
title: response.data.favorited ? '收藏成功' : '已取消收藏',
|
icon: 'none',
|
})
|
} finally {
|
favoriteSubmitting.value = false
|
}
|
}
|
</script>
|
|
<style lang="scss" scoped>
|
.container {
|
height: 100vh;
|
min-height: 100vh;
|
background-color: #f6f8fa;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
/* #ifdef H5 */
|
/* 贴合微信可视区域;旧版浏览器使用上下边界约束高度。 */
|
position: fixed;
|
top: 0;
|
right: 0;
|
bottom: 0;
|
left: 0;
|
height: auto;
|
min-height: 0;
|
height: 100dvh;
|
/* #endif */
|
}
|
|
.nav-bar {
|
position: fixed;
|
top: 0;
|
left: 0;
|
width: 100%;
|
height: calc(var(--status-bar-height, 0px) + 88rpx);
|
padding-top: var(--status-bar-height, 0px);
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding-left: 32rpx;
|
padding-right: 32rpx;
|
box-sizing: border-box;
|
z-index: 100;
|
background-color: #fff;
|
|
.back-icon {
|
flex-shrink: 0;
|
width: 19rpx;
|
height: 32rpx;
|
}
|
|
.nav-title {
|
flex: 1;
|
text-align: center;
|
font-size: 32rpx;
|
color: #1A1A1A;
|
font-weight: 600;
|
}
|
|
.nav-right-placeholder {
|
width: 19rpx;
|
/* Same as back icon to balance */
|
}
|
}
|
|
.media-section {
|
width: 100%;
|
height: 600rpx;
|
position: relative;
|
margin-top: calc(var(--status-bar-height, 0px) + 88rpx);
|
flex-shrink: 0;
|
|
.swiper-container,
|
.swiper {
|
width: 100%;
|
height: 100%;
|
}
|
|
.swiper-img {
|
width: 100%;
|
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;
|
}
|
|
.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%;
|
height: 100%;
|
}
|
|
.video-player-wrapper {
|
position: relative;
|
background: #000;
|
}
|
|
.video-watermark {
|
position: absolute;
|
inset: 0;
|
}
|
|
.video-retry-mask {
|
position: absolute;
|
inset: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: rgba(0, 0, 0, 0.36);
|
}
|
|
.video-retry-text {
|
padding: 18rpx 28rpx;
|
border-radius: 999rpx;
|
background: rgba(255, 255, 255, 0.18);
|
color: #fff;
|
font-size: 26rpx;
|
}
|
|
.indicator {
|
position: absolute;
|
right: 20rpx;
|
bottom: 20rpx;
|
width: 86rpx;
|
height: 42rpx;
|
background: rgba(0, 0, 0, 0.4);
|
border-radius: 8rpx;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
|
text {
|
color: #fff;
|
font-size: 24rpx;
|
}
|
}
|
}
|
|
.list-item--active {
|
background: rgba(198, 53, 32, 0.06);
|
border-radius: 16rpx;
|
}
|
|
.item-title--active {
|
color: #C63520 !important;
|
font-weight: 600;
|
}
|
|
.tab-bar {
|
display: flex;
|
background-color: #fff;
|
height: 88rpx;
|
border-bottom: 1px solid #f0f0f0;
|
flex-shrink: 0;
|
|
.tab-item {
|
flex: 1;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
font-size: 30rpx;
|
color: #333;
|
position: relative;
|
|
&.active {
|
color: #C63520;
|
font-weight: bold;
|
}
|
}
|
}
|
|
.scroll-content {
|
flex: 1;
|
height: 0;
|
min-height: 0;
|
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
box-sizing: border-box;
|
}
|
|
.info-section {
|
background-color: #fff;
|
padding: 32rpx;
|
margin-bottom: 20rpx;
|
|
.info-title {
|
font-size: 36rpx;
|
color: #1A1A1A;
|
font-weight: 600;
|
margin-bottom: 16rpx;
|
}
|
|
.tags-box {
|
display: flex;
|
margin-bottom: 24rpx;
|
|
.tag {
|
height: 36rpx;
|
line-height: 36rpx;
|
padding: 0 12rpx;
|
background-color: #C63520;
|
color: #fff;
|
font-size: 22rpx;
|
border-radius: 8rpx;
|
margin-right: 16rpx;
|
}
|
}
|
|
.price-stats-box {
|
display: flex;
|
justify-content: space-between;
|
align-items: flex-end;
|
margin-bottom: 24rpx;
|
|
.stats-box {
|
display: flex;
|
align-items: center;
|
|
.person-icon {
|
width: 24rpx;
|
height: 24rpx;
|
margin-right: 8rpx;
|
}
|
|
.stats-text {
|
font-size: 24rpx;
|
color: #86909C;
|
}
|
}
|
}
|
|
.desc-text {
|
font-size: 26rpx;
|
color: #666;
|
line-height: 1.5;
|
margin-bottom: 32rpx;
|
}
|
|
}
|
|
.course-list-section {
|
background-color: #fff;
|
padding: 32rpx;
|
|
.list-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 24rpx;
|
|
.list-title {
|
font-size: 32rpx;
|
font-weight: 600;
|
color: #1A1A1A;
|
}
|
|
.list-subtitle {
|
font-size: 24rpx;
|
color: rgba(26, 26, 26, 0.8);
|
font-weight: normal;
|
margin-left: 12rpx;
|
}
|
|
.expand-text {
|
font-size: 24rpx;
|
color: #8C8C8C;
|
}
|
}
|
|
.free-divider,
|
.pay-divider {
|
width: 100%;
|
margin: 16rpx 0;
|
}
|
|
.list-items {
|
display: flex;
|
flex-direction: column;
|
}
|
|
.list-item {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
height: 96rpx;
|
padding: 0 16rpx;
|
|
&.active {
|
height: 110rpx;
|
background-color: #FFEFED;
|
border-radius: 8rpx;
|
}
|
|
.item-title-box {
|
display: flex;
|
align-items: center;
|
}
|
|
.stat-icon {
|
width: 32rpx;
|
height: 32rpx;
|
margin-right: 12rpx;
|
}
|
|
.item-title {
|
font-size: 28rpx;
|
color: #1A1A1A;
|
}
|
|
.item-time {
|
font-size: 24rpx;
|
color: #999;
|
}
|
|
.active-text {
|
color: #C63520 !important;
|
}
|
|
.item-right-box {
|
display: flex;
|
align-items: center;
|
|
.item-time {
|
margin-right: 16rpx;
|
}
|
|
.cost-btn {
|
width: 140rpx;
|
height: 56rpx;
|
background-color: #C63520;
|
border-radius: 8rpx;
|
color: #fff;
|
font-size: 24rpx;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
}
|
|
.cost-btn--purchased {
|
background-color: #E5E6EB;
|
color: #86909C;
|
}
|
|
.download-icon {
|
width: 30rpx;
|
height: 30rpx;
|
flex-shrink: 0;
|
}
|
}
|
}
|
}
|
|
.loading-text,
|
.empty-text {
|
padding: 40rpx 32rpx;
|
text-align: center;
|
font-size: 26rpx;
|
color: #999;
|
}
|
|
.module-section--with-padding {
|
padding-left: 32rpx;
|
padding-right: 32rpx;
|
box-sizing: border-box;
|
}
|
|
.share-bottom-spacer {
|
height: 140rpx;
|
height: calc(140rpx + constant(safe-area-inset-bottom));
|
height: calc(140rpx + env(safe-area-inset-bottom));
|
}
|
|
.open-app-banner {
|
position: fixed;
|
left: 32rpx;
|
right: 32rpx;
|
bottom: 32rpx;
|
bottom: calc(32rpx + constant(safe-area-inset-bottom));
|
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
height: 96rpx;
|
background-color: #FFF1F0;
|
border-radius: 16rpx;
|
display: flex;
|
align-items: center;
|
padding: 0 24rpx;
|
box-sizing: border-box;
|
z-index: 90;
|
|
.app-logo-placeholder {
|
width: 48rpx;
|
height: 48rpx;
|
background-color: #FFFFFF;
|
border-radius: 8rpx;
|
margin-right: 16rpx;
|
}
|
|
.banner-text {
|
flex: 1;
|
font-size: 26rpx;
|
color: #C63520;
|
}
|
|
.open-btn {
|
width: 140rpx;
|
height: 56rpx;
|
background-color: #C63520;
|
border-radius: 8rpx;
|
color: #FFFFFF;
|
font-size: 24rpx;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
}
|
}
|
|
.bottom-bar {
|
position: fixed;
|
bottom: 0;
|
left: 0;
|
width: 100%;
|
background-color: #fff;
|
padding: 16rpx 32rpx;
|
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
box-sizing: border-box;
|
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
|
.action-icons {
|
display: flex;
|
gap: 40rpx;
|
padding-left: 10rpx;
|
}
|
|
.action-icon-item {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
|
.icon-img {
|
margin-bottom: 6rpx;
|
}
|
|
.icon-listen {
|
width: 40rpx;
|
height: 40rpx;
|
}
|
|
.icon-love {
|
width: 46rpx;
|
height: 40rpx;
|
}
|
|
.icon-share {
|
width: 40rpx;
|
height: 44rpx;
|
}
|
|
.icon-text {
|
font-size: 24rpx;
|
color: #1D2129;
|
}
|
}
|
|
.buy-btn {
|
width: 340rpx;
|
height: 96rpx;
|
line-height: 80rpx;
|
text-align: center;
|
background-color: #C63520;
|
color: #fff;
|
font-size: 32rpx;
|
border-radius: 16rpx;
|
}
|
|
.buy-btn--disabled {
|
background-color: #E5E6EB;
|
color: #86909C;
|
}
|
}
|
|
.payment-popup {
|
padding: 24rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
background-color: #fff;
|
border-top-left-radius: 24rpx;
|
border-top-right-radius: 24rpx;
|
}
|
|
.popup-header {
|
position: relative;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
height: 72rpx;
|
margin-bottom: 24rpx;
|
|
:deep(.close-icon) {
|
position: absolute;
|
left: 0;
|
top: 50%;
|
transform: translateY(-50%);
|
}
|
}
|
|
.popup-title {
|
font-size: 32rpx;
|
color: #1A1A1A;
|
font-weight: 600;
|
}
|
|
.payment-methods {
|
margin-bottom: 40rpx;
|
}
|
|
.method-title {
|
display: block;
|
font-size: 28rpx;
|
color: #1A1A1A;
|
margin-bottom: 24rpx;
|
}
|
|
.method-item {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 24rpx 0;
|
border-bottom: 1px solid #F2F3F5;
|
}
|
|
.method-left {
|
display: flex;
|
align-items: center;
|
}
|
|
.pay-icon {
|
width: 48rpx;
|
height: 48rpx;
|
margin-right: 16rpx;
|
}
|
|
.pay-icon--apple {
|
border-radius: 12rpx;
|
background: #111111;
|
color: #ffffff;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 28rpx;
|
font-weight: 600;
|
}
|
|
.method-name {
|
font-size: 28rpx;
|
color: #1A1A1A;
|
}
|
|
.radio-img {
|
width: 36rpx;
|
height: 36rpx;
|
}
|
|
.popup-footer {
|
padding-top: 8rpx;
|
}
|
|
.pay-btn {
|
height: 88rpx;
|
background-color: #C63520;
|
border-radius: 44rpx;
|
color: #fff;
|
font-size: 30rpx;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.pay-btn--disabled {
|
background-color: #E5E6EB;
|
color: #86909C;
|
}
|
|
.expert-avatar {
|
width: 100%;
|
}
|
</style>
|