<template>
|
<view class="container">
|
<!-- 导航栏 -->
|
<view class="nav-bar">
|
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
|
<text class="nav-title">详情</text>
|
<view class="nav-right-placeholder"></view>
|
</view>
|
|
<!-- 顶部媒体区域 (轮播图) -->
|
<view class="media-section">
|
<view 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="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">
|
<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">
|
<view class="price-box">
|
<text class="price-symbol">¥</text>
|
<text class="price-integer">{{ priceParts.integer }}</text>
|
<text class="price-decimal">.{{ priceParts.decimal }}</text>
|
</view>
|
<view class="stats-box">
|
<image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image>
|
<text class="stats-text">{{ formatLearningCount(courseDetail.learnUserCount) }}人正在学</text>
|
</view>
|
</view>
|
|
<view class="desc-text">{{ courseDetail.summary || '暂无课程介绍' }}</view>
|
|
<!-- 会员横幅 -->
|
<view class="vip-banner" @tap="goToVipPage">
|
<image class="vip-bg" src="/static/image/healthCourses/openVip.png" mode="aspectFill"></image>
|
<view class="vip-content">
|
<view class="vip-left">
|
<view v-if="memberMinPriceParts" class="vip-price-box">
|
<text class="vip-price-symbol">¥</text>
|
<text class="vip-price-num">{{ memberMinPriceParts.integer }}</text>
|
<text class="vip-price-decimal">.{{ memberMinPriceParts.decimal }}</text>
|
<text class="vip-price-suffix">起</text>
|
</view>
|
<text v-else class="vip-price-empty">暂未开放</text>
|
<text class="vip-desc">开通会员尊享养生课程</text>
|
</view>
|
<view class="vip-btn">{{ vipActionText }}</view>
|
</view>
|
</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="/static/image/healthCourses/free.png" mode="widthFix"></image>
|
|
<view class="list-items">
|
<view class="list-item" v-for="(item, index) in freeVideos" :key="item.id">
|
<text class="item-title">{{ 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="/static/image/healthCourses/payToUnlock.png" mode="widthFix"></image>
|
|
<view class="list-items">
|
<view class="list-item" v-for="(item, index) in paidVideos" :key="item.id">
|
<text class="item-title">{{ freeVideos.length + index + 1 }}、{{ item.chapterName }}</text>
|
<view class="item-right-box">
|
<text class="item-time">{{ item.durationText }}</text>
|
<image
|
v-if="shouldShowVideoDownload(item)"
|
class="download-icon"
|
src="/static/image/course/redDownload@2x.png"
|
mode="aspectFit"
|
@tap.stop="handleVideoDownload(item)"
|
></image>
|
<view
|
v-else
|
class="cost-btn"
|
:class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }"
|
@tap.stop="handlePaidVideoTap(item)"
|
>
|
{{ item.purchased || courseDetail.courseOwned ? '已购买' : `¥${formatPrice(item.salePrice)}` }}
|
</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">
|
<view class="module-title">适用人群</view>
|
<view class="module-desc">{{ courseDetail.targetUsers }}</view>
|
</view>
|
|
<!-- 授课专家模块 -->
|
<view v-if="courseDetail.expert" class="module-section" @tap="goToExpertDetail">
|
<view class="module-title">授课专家</view>
|
<view class="expert-card">
|
<image class="expert-avatar" :src="courseDetail.expert.avatar || '/static/image/home/avatr.png'" mode="aspectFill"></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 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="/static/image/healthCourses/free.png" mode="widthFix"></image>
|
|
<view class="list-items">
|
<view class="list-item" v-for="(item, index) in freeVideos" :key="item.id">
|
<text class="item-title">{{ 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="/static/image/healthCourses/payToUnlock.png" mode="widthFix"></image>
|
|
<view class="list-items">
|
<view class="list-item" v-for="(item, index) in paidVideos" :key="item.id">
|
<text class="item-title">{{ freeVideos.length + index + 1 }}、{{ item.chapterName }}</text>
|
<view class="item-right-box">
|
<text class="item-time">{{ item.durationText }}</text>
|
<image
|
v-if="shouldShowVideoDownload(item)"
|
class="download-icon"
|
src="/static/image/course/redDownload@2x.png"
|
mode="aspectFit"
|
@tap.stop="handleVideoDownload(item)"
|
></image>
|
<view
|
v-else
|
class="cost-btn"
|
:class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }"
|
@tap.stop="handlePaidVideoTap(item)"
|
>
|
{{ item.purchased || courseDetail.courseOwned ? '已购买' : `¥${formatPrice(item.salePrice)}` }}
|
</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>
|
</scroll-view>
|
|
<!-- 底部悬浮按钮 -->
|
<view class="bottom-bar">
|
<view class="action-icons">
|
<view class="action-icon-item" @tap="handleConsult">
|
<image class="icon-img icon-listen" src="/static/image/healthCourses/listen.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">
|
<image class="icon-img icon-share" src="/static/image/healthCourses/share.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="amount-box">
|
<text class="amount-symbol">¥</text>
|
<text class="amount-value">{{ paymentAmount }}</text>
|
</view>
|
|
<view class="payment-methods">
|
<text class="method-title">选择支付方式</text>
|
|
<view class="method-item" @tap="selectPaymentMethod('wechat')">
|
<view class="method-left">
|
<image class="pay-icon" src="/static/image/vip/weixinPay.png" mode="aspectFit"></image>
|
<text class="method-name">微信支付</text>
|
</view>
|
<image class="radio-img" :src="selectedPaymentMethod === 'wechat' ? '/static/image/vip/check.png' : '/static/image/vip/uncheck.png'" mode="aspectFit"></image>
|
</view>
|
|
<view class="method-item" @tap="selectPaymentMethod('alipay')">
|
<view class="method-left">
|
<image class="pay-icon" src="/static/image/vip/aliPay.png" mode="aspectFit"></image>
|
<text class="method-name">支付宝支付</text>
|
</view>
|
<image class="radio-img" :src="selectedPaymentMethod === 'alipay' ? '/static/image/vip/check.png' : '/static/image/vip/uncheck.png'" mode="aspectFit"></image>
|
</view>
|
</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, ref } from 'vue'
|
import { onLoad } from '@dcloudio/uni-app'
|
import {
|
favoriteHealthCourse,
|
getHealthCourseDetail,
|
getMemberOpenPage,
|
memberJoinHealthCourse,
|
purchaseHealthCourse,
|
purchaseHealthCourseVideo,
|
reportHealthCourseDownload,
|
unfavoriteHealthCourse,
|
} from './service'
|
import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.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
|
}
|
|
interface LocalDownloadTask {
|
clientTaskId: string
|
courseId: number
|
courseVideoId: number
|
title: string
|
subtitle: string
|
coverImage: string
|
durationSeconds: number
|
durationText: string
|
videoUrl: string
|
progress: number
|
status: 'downloading'
|
updatedAt: string
|
}
|
|
interface CourseDetail {
|
id?: number | null
|
title: string
|
bannerImages: string[]
|
levelName: string
|
difficultyName: string
|
salePrice: number | string
|
learnUserCount: 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
|
}
|
|
interface MemberOpenPackage {
|
type?: string
|
title?: string
|
price?: number | string | null
|
days?: number | null
|
}
|
|
interface MemberOpenPageData {
|
minPrice: number | string | null
|
packages: MemberOpenPackage[]
|
}
|
|
interface SwiperChangeEvent {
|
detail?: {
|
current?: number
|
}
|
}
|
|
type PaymentMethod = 'wechat' | 'alipay'
|
type PurchaseTarget = 'COURSE' | 'VIDEO'
|
|
const currentTab = ref('course')
|
const currentSwiperIndex = ref(0)
|
const loading = ref(false)
|
const favoriteSubmitting = ref(false)
|
const purchaseSubmitting = ref(false)
|
const memberJoinSubmitting = ref(false)
|
const showPaymentPopup = ref(false)
|
const selectedPaymentMethod = ref<PaymentMethod>('wechat')
|
const activePurchaseTarget = ref<PurchaseTarget>('COURSE')
|
const activePurchaseVideoId = ref<number | null>(null)
|
const courseId = ref('')
|
const courseDetail = ref<CourseDetail>(createDefaultCourseDetail())
|
const memberOpenPage = ref<MemberOpenPageData>(createDefaultMemberOpenPage())
|
|
/**
|
* 创建课程详情页默认数据,避免接口返回前页面闪空。
|
*/
|
function createDefaultCourseDetail(): CourseDetail {
|
return {
|
id: null,
|
title: '',
|
bannerImages: [],
|
levelName: '',
|
difficultyName: '',
|
salePrice: 0,
|
learnUserCount: 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,
|
}
|
}
|
|
/**
|
* 创建会员开通页摘要默认数据,避免课程详情页价格区域闪烁。
|
*/
|
function createDefaultMemberOpenPage(): MemberOpenPageData {
|
return {
|
minPrice: null,
|
packages: [],
|
}
|
}
|
|
const swiperList = computed(() => {
|
if (courseDetail.value.bannerImages.length > 0) {
|
return courseDetail.value.bannerImages
|
}
|
return ['/static/image/home/class.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 formattedPrice = computed(() => formatPrice(courseDetail.value.salePrice))
|
const hasMemberMinPrice = computed(() => {
|
const normalizedPrice = Number(memberOpenPage.value.minPrice)
|
return Number.isFinite(normalizedPrice) && normalizedPrice > 0
|
})
|
const memberMinPriceText = computed(() => {
|
if (!hasMemberMinPrice.value) {
|
return ''
|
}
|
return formatPrice(memberOpenPage.value.minPrice || 0)
|
})
|
const memberMinPriceParts = computed(() => {
|
if (!hasMemberMinPrice.value) {
|
return null
|
}
|
const [integer, decimal] = memberMinPriceText.value.split('.')
|
return {
|
integer: integer || '0',
|
decimal: decimal || '00',
|
}
|
})
|
const vipActionText = computed(() => {
|
return courseDetail.value.isMember ? '立即续费' : '立即开通'
|
})
|
const courseBuyButtonText = computed(() => {
|
if (courseDetail.value.courseOwned) {
|
return '已购买'
|
}
|
if (courseDetail.value.isMember) {
|
return '加入我的课程'
|
}
|
return '立即购买'
|
})
|
const favoriteIcon = computed(() => {
|
return courseDetail.value.favorited
|
? '/static/image/healthCourses/loved.png'
|
: '/static/image/healthCourses/love.png'
|
})
|
const paymentAmount = computed(() => {
|
if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value !== null) {
|
const matchedVideo = allVideos.value.find((item) => item.id === activePurchaseVideoId.value)
|
return formatPrice(matchedVideo?.salePrice)
|
}
|
return formatPrice(courseDetail.value.salePrice)
|
})
|
|
const priceParts = computed(() => {
|
const [integer, decimal] = formattedPrice.value.split('.')
|
return {
|
integer: integer || '0',
|
decimal: decimal || '00',
|
}
|
})
|
|
const courseContentNodes = computed(() => buildRichTextNodes(courseDetail.value.content, '暂无课程详情'))
|
|
/**
|
* 页面加载时读取课程主键并拉取详情接口。
|
*/
|
onLoad((options) => {
|
courseId.value = typeof options?.id === 'string' ? options.id : ''
|
loadCourseDetail()
|
loadMemberOpenPage()
|
})
|
|
/**
|
* 拉取课程详情接口并合并到页面状态。
|
*/
|
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 (currentSwiperIndex.value >= swiperList.value.length) {
|
currentSwiperIndex.value = 0
|
}
|
} catch {
|
showLoadFailedToast()
|
} finally {
|
loading.value = false
|
}
|
}
|
|
/**
|
* 拉取会员开通页摘要数据,供课程详情横幅展示最低启用价格。
|
*/
|
async function loadMemberOpenPage() {
|
try {
|
const response = await getMemberOpenPage()
|
if (response?.code !== 0 || !response.data) {
|
memberOpenPage.value = createDefaultMemberOpenPage()
|
return
|
}
|
memberOpenPage.value = {
|
minPrice: response.data.minPrice ?? null,
|
packages: Array.isArray(response.data.packages)
|
? response.data.packages.map((item) => ({
|
...item,
|
type: typeof item?.type === 'string' ? item.type.trim() : '',
|
title: typeof item?.title === 'string' ? item.title.trim() : '',
|
days: Number.isFinite(Number(item?.days)) ? Number(item?.days) : null,
|
price: item?.price ?? null,
|
}))
|
: [],
|
}
|
} catch {
|
memberOpenPage.value = createDefaultMemberOpenPage()
|
}
|
}
|
|
/**
|
* 课程详情加载失败时给出统一提示。
|
*/
|
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 goToExpertDetail() {
|
if (!courseDetail.value.expert?.id) {
|
return
|
}
|
uni.navigateTo({
|
url: `/pages/protocol/protocol?type=expert&id=${courseDetail.value.expert.id}`
|
})
|
}
|
|
/**
|
* 跳转会员开通页,供课程详情中的会员入口复用。
|
*/
|
function goToVipPage() {
|
uni.navigateTo({
|
url: '/pages/vip/vip',
|
})
|
}
|
|
/**
|
* 拨打后台系统配置中的客服电话,未配置时给出兜底提示。
|
*/
|
function handleConsult() {
|
const servicePhone = courseDetail.value.servicePhone.trim()
|
if (!servicePhone) {
|
uni.showToast({
|
title: '暂未配置客服电话',
|
icon: 'none',
|
})
|
return
|
}
|
uni.makePhoneCall({
|
phoneNumber: servicePhone,
|
})
|
}
|
|
/**
|
* 构建适配当前价格展示区的两位小数字符串。
|
*/
|
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() {
|
const cachedUser = uni.getStorageSync('appUserInfo')
|
if (!cachedUser || typeof cachedUser !== 'object') {
|
return 0
|
}
|
const userId = Number((cachedUser as { id?: number | string }).id || 0)
|
if (!Number.isFinite(userId) || userId <= 0) {
|
return 0
|
}
|
return userId
|
}
|
|
/**
|
* 判断指定章节是否已存在下载中任务,避免重复触发原生下载。
|
*/
|
function hasActiveDownloadTask(userId: number, courseVideoId: number) {
|
return listDownloadTasks(userId).some((task) => Number(task.courseVideoId) === Number(courseVideoId))
|
}
|
|
/**
|
* 构建课程下载任务唯一标识。
|
*/
|
function buildDownloadTaskId(courseVideoId: number) {
|
return `health-course-video-${courseVideoId}`
|
}
|
|
/**
|
* 组装下载中任务的本地快照,供“我的下载”页面直接复用展示。
|
*/
|
function createDownloadTaskPayload(clientTaskId: string, item: CourseVideo): LocalDownloadTask {
|
return {
|
clientTaskId,
|
courseId: Number(courseId.value || 0),
|
courseVideoId: Number(item.id || 0),
|
title: item.chapterName || '课程章节',
|
subtitle: courseDetail.value.title || '养生课程',
|
coverImage: swiperList.value[0] || '/static/image/home/class.png',
|
durationSeconds: Number(item.durationSeconds || 0),
|
durationText: item.durationText || '00:00',
|
videoUrl: item.videoUrl || '',
|
progress: 0,
|
status: 'downloading',
|
updatedAt: new Date().toISOString(),
|
}
|
}
|
|
/**
|
* 打开整课购买支付弹窗。
|
*/
|
function handleCoursePurchase() {
|
if (!courseId.value || courseDetail.value.courseOwned || memberJoinSubmitting.value) {
|
return
|
}
|
if (courseDetail.value.isMember) {
|
submitMemberJoin()
|
return
|
}
|
activePurchaseTarget.value = 'COURSE'
|
activePurchaseVideoId.value = null
|
showPaymentPopup.value = true
|
}
|
|
/**
|
* 处理付费小节点击,未购买时进入对应支付弹窗。
|
*/
|
function handlePaidVideoTap(item: CourseVideo) {
|
if (!courseId.value || courseDetail.value.courseOwned || item.purchased) {
|
return
|
}
|
activePurchaseTarget.value = 'VIDEO'
|
activePurchaseVideoId.value = item.id
|
showPaymentPopup.value = true
|
}
|
|
/**
|
* 判断当前章节是否应展示下载按钮。
|
*/
|
function shouldShowVideoDownload(item: CourseVideo) {
|
// 只有用户已拥有该小节且后台已配置视频地址时,才允许展示下载入口。
|
return !!(item.videoUrl && (courseDetail.value.courseOwned || item.purchased))
|
}
|
|
/**
|
* 下载已购买章节视频,H5 直接打开资源地址,其它端走文件下载。
|
*/
|
function handleVideoDownload(item: CourseVideo) {
|
if (!shouldShowVideoDownload(item)) {
|
return
|
}
|
// #ifdef H5
|
window.open(item.videoUrl, '_blank')
|
uni.showToast({
|
title: '已打开下载地址',
|
icon: 'none',
|
})
|
return
|
// #endif
|
const currentUserId = resolveCurrentUserId()
|
if (!currentUserId) {
|
uni.showToast({
|
title: '请先登录',
|
icon: 'none',
|
})
|
return
|
}
|
if (hasActiveDownloadTask(currentUserId, item.id)) {
|
uni.showToast({
|
title: '当前章节正在下载中',
|
icon: 'none',
|
})
|
return
|
}
|
const downloadTaskId = buildDownloadTaskId(item.id)
|
const initialTask = createDownloadTaskPayload(downloadTaskId, item)
|
upsertDownloadTask(currentUserId, initialTask)
|
uni.showLoading({
|
title: '下载中...',
|
mask: true,
|
})
|
const downloadTask = uni.downloadFile({
|
url: item.videoUrl || '',
|
success: (downloadResult) => {
|
if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
|
clearDownloadTask(currentUserId, downloadTaskId)
|
uni.showToast({
|
title: '下载失败',
|
icon: 'none',
|
})
|
return
|
}
|
// 先持久化保存,避免临时文件被系统回收后用户无法继续使用。
|
uni.saveFile({
|
tempFilePath: downloadResult.tempFilePath,
|
success: async () => {
|
// 文件已持久化后再移除下载中状态,并把完成记录同步给后端列表。
|
clearDownloadTask(currentUserId, downloadTaskId)
|
try {
|
const response = await reportHealthCourseDownload({
|
courseId: Number(courseId.value),
|
courseVideoId: item.id,
|
})
|
if (response?.code !== 0) {
|
return
|
}
|
uni.showToast({
|
title: '下载完成',
|
icon: 'none',
|
})
|
} catch {
|
uni.showToast({
|
title: '下载完成,记录同步失败',
|
icon: 'none',
|
})
|
}
|
},
|
fail: () => {
|
clearDownloadTask(currentUserId, downloadTaskId)
|
uni.showToast({
|
title: '文件已下载到临时目录',
|
icon: 'none',
|
})
|
},
|
})
|
},
|
fail: () => {
|
clearDownloadTask(currentUserId, downloadTaskId)
|
uni.showToast({
|
title: '下载失败',
|
icon: 'none',
|
})
|
},
|
complete: () => {
|
uni.hideLoading()
|
},
|
})
|
// 下载中的进度仅在当前设备维护,避免把临时任务状态同步到服务端。
|
downloadTask.onProgressUpdate((progressEvent) => {
|
upsertDownloadTask(currentUserId, {
|
...initialTask,
|
progress: Number(progressEvent?.progress || 0),
|
updatedAt: new Date().toISOString(),
|
})
|
})
|
}
|
|
/**
|
* 切换支付方式。
|
*/
|
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() {
|
if (!courseId.value || purchaseSubmitting.value) {
|
return
|
}
|
if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value === null) {
|
return
|
}
|
purchaseSubmitting.value = true
|
try {
|
const payload = {
|
paymentMethod: selectedPaymentMethod.value === 'wechat' ? 'WECHAT' : 'ALIPAY',
|
}
|
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: '购买成功',
|
icon: 'none',
|
})
|
} finally {
|
purchaseSubmitting.value = false
|
}
|
}
|
|
/**
|
* 根据购买接口返回值回写课程与小节的已购态。
|
*/
|
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,
|
}
|
}),
|
}
|
}
|
|
/**
|
* 提交会员加课请求,并将整课直接回写为已拥有。
|
*/
|
async function submitMemberJoin() {
|
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 (!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 {
|
min-height: 100vh;
|
background-color: #f6f8fa;
|
display: flex;
|
flex-direction: column;
|
}
|
|
.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);
|
|
.swiper-container,
|
.swiper {
|
width: 100%;
|
height: 100%;
|
}
|
|
.swiper-img {
|
width: 100%;
|
height: 100%;
|
}
|
|
.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;
|
}
|
}
|
}
|
|
.tab-bar {
|
display: flex;
|
background-color: #fff;
|
height: 88rpx;
|
border-bottom: 1px solid #f0f0f0;
|
|
.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;
|
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
}
|
|
.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;
|
|
.price-box {
|
display: flex;
|
align-items: baseline;
|
color: #D32E29;
|
|
.price-symbol {
|
font-size: 24rpx;
|
}
|
|
.price-integer {
|
font-size: 40rpx;
|
font-weight: 600;
|
}
|
|
.price-decimal {
|
font-size: 28rpx;
|
font-weight: 600;
|
}
|
}
|
|
.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;
|
}
|
|
.vip-banner {
|
position: relative;
|
width: 100%;
|
height: 140rpx;
|
border-radius: 16rpx;
|
overflow: hidden;
|
|
.vip-bg {
|
position: absolute;
|
width: 100%;
|
height: 100%;
|
top: 0;
|
left: 0;
|
}
|
|
.vip-content {
|
position: relative;
|
width: 100%;
|
height: 100%;
|
box-sizing: border-box;
|
padding-top: 20rpx;
|
padding-right: 28rpx;
|
padding-bottom: 30rpx;
|
padding-left: 170rpx;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
|
.vip-left {
|
display: flex;
|
flex-direction: column;
|
justify-content: center;
|
|
.vip-price-box {
|
color: #AA5200;
|
display: flex;
|
align-items: baseline;
|
margin-bottom: 4rpx;
|
|
.vip-price-symbol {
|
font-size: 16rpx;
|
}
|
|
.vip-price-num {
|
font-size: 36rpx;
|
font-weight: bold;
|
}
|
|
.vip-price-decimal {
|
font-size: 24rpx;
|
font-weight: bold;
|
}
|
|
.vip-price-suffix {
|
font-size: 24rpx;
|
margin-left: 4rpx;
|
}
|
}
|
|
.vip-price-empty {
|
font-size: 32rpx;
|
font-weight: bold;
|
color: #AA5200;
|
margin-bottom: 4rpx;
|
}
|
|
.vip-desc {
|
font-size: 22rpx;
|
color: #AA5200;
|
}
|
}
|
|
.vip-btn {
|
width: 210rpx;
|
height: 60rpx;
|
background-color: #C63520;
|
border-radius: 20rpx;
|
color: #fff;
|
font-size: 28rpx;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
/* 为了美观可以稍微调整字体大小,原要求28rpx放在34rpx高度里可能会有点大 */
|
line-height: 1;
|
transform: scale(0.8); /* 28rpx in 34rpx height needs some scaling to look right, or adjust font-size */
|
transform-origin: right center;
|
}
|
}
|
}
|
}
|
|
.module-section {
|
background-color: #fff;
|
padding: 32rpx;
|
margin-bottom: 20rpx;
|
|
&.last-module {
|
margin-bottom: 0;
|
}
|
|
.module-title {
|
font-size: 32rpx;
|
color: #1D2129;
|
font-weight: 600;
|
margin-bottom: 24rpx;
|
}
|
|
.module-desc {
|
font-size: 26rpx;
|
color: #86909C;
|
line-height: 1.5;
|
}
|
|
.expert-card {
|
background-color: #FAF8E9;
|
padding: 24rpx;
|
border-radius: 16rpx;
|
display: flex;
|
align-items: center;
|
|
.expert-avatar {
|
width: 96rpx;
|
height: 96rpx;
|
border-radius: 50%;
|
flex-shrink: 0;
|
}
|
|
.expert-info {
|
margin-left: 16rpx;
|
flex: 1;
|
|
.expert-name {
|
font-size: 28rpx;
|
color: #A27F53;
|
margin-bottom: 8rpx;
|
}
|
|
.expert-desc {
|
font-size: 24rpx;
|
color: #86909C;
|
display: -webkit-box;
|
-webkit-box-orient: vertical;
|
-webkit-line-clamp: 2;
|
overflow: hidden;
|
line-height: 34rpx;
|
}
|
}
|
}
|
|
.detail-content {
|
font-size: 26rpx;
|
color: #1A1A1A;
|
line-height: 1.6;
|
}
|
|
.rich-detail-content {
|
width: 100%;
|
}
|
}
|
|
.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;
|
}
|
|
.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;
|
}
|
|
.amount-box {
|
display: flex;
|
justify-content: center;
|
align-items: baseline;
|
margin-bottom: 40rpx;
|
color: #C63520;
|
}
|
|
.amount-symbol {
|
font-size: 32rpx;
|
}
|
|
.amount-value {
|
font-size: 56rpx;
|
font-weight: 700;
|
}
|
|
.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;
|
}
|
|
.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;
|
}
|
</style>
|