hookehuyr

feat(课程): 添加课程评论功能并优化课程详情页

- 添加课程评论功能,支持评论的增删改查
- 优化课程详情页,显示课程评论列表和评分
- 修改课程卡片组件,支持自定义跳转链接
- 更新路由配置,支持带参数的课程学习页面
- 修复部分页面样式和逻辑问题
/*
* @Date: 2025-04-15 09:32:07
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-15 12:58:17
* @LastEditTime: 2025-04-18 14:43:46
* @FilePath: /mlaj/src/api/course.js
* @Description: 课程模块相关接口
*/
......@@ -10,7 +10,11 @@ import { fn, fetch } from './fn'
const Api = {
GET_COURSE_LIST: '/srv/?a=schedule&t=list',
GET_COURSE_DETAIL: '/srv/?a=schedule&t=detail',
GET_SCHEDULE_COURSE_LIST: '/srv/?a=schedule&t=course',
GET_SCHEDULE_COURSE: '/srv/?a=schedule&t=course',
GET_GROUP_COMMENT_LIST: '/srv/?a=group_comment_list',
GROUP_COMMENT_ADD: '/srv/?a=group_comment_add',
GROUP_COMMENT_EDIT: '/srv/?a=group_comment_edit',
GROUP_COMMENT_DEL: '/srv/?a=group_comment_del',
}
/**
......@@ -31,8 +35,42 @@ export const getCourseListAPI = (params) => fn(fetch.get(Api.GET_COURSE_LIST, p
export const getCourseDetailAPI = (params) => fn(fetch.get(Api.GET_COURSE_DETAIL, params))
/**
* @description: 获取特定学习课程的目录
* @description: 获取特定学习课程的详情
* @param: i 课程 ID
* @return: data: [{ id, schedule_time, seq, title, duration, course_id, file}]
*/
export const getScheduleCourseListAPI = (params) => fn(fetch.get(Api.GET_SCHEDULE_COURSE_LIST, params))
export const getScheduleCourseAPI = (params) => fn(fetch.get(Api.GET_SCHEDULE_COURSE, params))
/**
* @description: 获取课程评论列表
* @param: i 课程 ID
* @param: limit 每页数量 默认10
* @param: page 页码
* @return: data: { comment_score 课程评论分数, comment_count 评论数量, comment_list [{ id 评论id, created_by 评论人ID, name 评论人姓名, note 评论内容, score 分数, create_time 评论时间}] 评论列表}
*/
export const getGroupCommentListAPI = (params) => fn(fetch.get(Api.GET_GROUP_COMMENT_LIST, params))
/**
* @description: 添加课程评论
* @param: i 课程 ID
* @param: note 评论内容
* @param: score 分数
* @return: data: ''
*/
export const addGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_ADD, params))
/**
* @description: 编辑课程评论
* @param: i 课程 ID
* @param: note 评论内容
* @param: score 分数
* @return: data: ''
*/
export const editGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_EDIT, params))
/**
* @description: 删除课程评论
* @param: i 课程 ID
* @return: data: ''
*/
export const delGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_DEL, params))
......
......@@ -27,6 +27,7 @@ declare module 'vue' {
SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default']
TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default']
UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanButton: typeof import('vant/es')['Button']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanCheckbox: typeof import('vant/es')['Checkbox']
......
<!--
* @Date: 2025-04-18
* @Description: 评论编辑组件
-->
<template>
<van-popup :show="show" @update:show="emit('update:show', $event)" position="bottom" round>
<div class="p-4">
<div class="text-lg font-bold text-center mb-4">编辑评价</div>
<div class="flex justify-center mb-4">
<van-rate v-model="score" :size="24" color="#ffd21e" void-icon="star" void-color="#eee" />
</div>
<van-field v-model="note" rows="3" type="textarea" placeholder="请输入您的评价内容" class="mb-4" />
<div class="flex justify-end space-x-3">
<van-button round plain @click="handleCancel">取消</van-button>
<van-button round type="primary" color="#4CAF50" @click="handleSubmit">提交</van-button>
</div>
</div>
</van-popup>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Popup, Rate, Field, Button } from 'vant'
const props = defineProps({
show: {
type: Boolean,
default: false
},
initialScore: {
type: Number,
default: 5
},
initialNote: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:show', 'submit'])
const score = ref(props.initialScore)
const note = ref(props.initialNote)
watch(() => props.show, (newVal) => {
if (newVal) {
score.value = props.initialScore
note.value = props.initialNote
}
})
const handleCancel = () => {
emit('update:show', false)
}
const handleSubmit = () => {
if (score.value === 0) {
return
}
if (!note.value.trim()) {
return
}
emit('submit', {
score: score.value,
note: note.value.trim()
})
emit('update:show', false)
}
</script>
......@@ -6,7 +6,7 @@
* @Description: 文件描述
-->
<template>
<router-link :to="`/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm">
<router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm">
<div class="w-1/3 h-28">
<img
:src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
......@@ -37,6 +37,10 @@ defineProps({
course: {
type: Object,
required: true
},
linkTo: {
type: String,
default: ''
}
})
</script>
......
<!--
* @Date: 2025-03-24 16:57:55
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-24 17:17:44
* @LastEditTime: 2025-04-18 14:12:14
* @FilePath: /mlaj/src/components/ui/ReviewPopup.vue
* @Description: 文件描述
-->
......@@ -91,8 +91,7 @@ const handleSubmit = () => {
rating: rating.value,
content: content.value.trim(),
});
show_toast.value = true;
message.value = "评论提交成功";
// 提交成功后关闭弹窗
handleCancel();
};
</script>
......
/*
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-08 16:09:21
* @LastEditTime: 2025-04-18 10:08:35
* @FilePath: /mlaj/src/router/routes.js
* @Description: 路由地址映射配置
*/
......@@ -213,7 +213,7 @@ export const routes = [
}
},
{
path: '/studyCourse',
path: '/studyCourse/:id',
component: () => import('@/views/study/studyCoursePage.vue'),
meta: {
title: '课程集合页面',
......
......@@ -118,38 +118,23 @@
<FrostedGlass class="mb-6 p-4 rounded-xl">
<h3 class="text-lg font-bold text-gray-800 mb-3">学员评价</h3>
<div class="flex items-center mb-3">
<div class="flex text-yellow-400 mr-2">
<svg v-for="star in 5" :key="star" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<div class="flex items-center mr-2">
<van-rate v-model="commentScore" readonly allow-half color="#facc15" void-color="#e5e7eb" size="20" />
</div>
<div class="text-gray-700">4.9 (126条评论)</div>
<div class="text-gray-700">{{ commentScore }} ({{ commentTotal }}条评论)</div>
</div>
<div class="space-y-4">
<div class="border-b border-gray-100 pb-3">
<div v-for="(item, index) in commentList" :key="index" class="border-b border-gray-100 pb-3">
<div class="flex justify-between">
<div class="font-medium text-gray-800">王小明</div>
<div class="text-xs text-gray-500">2024-06-15</div>
<div class="font-medium text-gray-800">{{ item.name }}</div>
<div class="text-xs text-gray-500">{{ formatDate(item.created_time) }}</div>
</div>
<p class="text-sm text-gray-600 mt-1">
课程内容非常实用,老师讲解清晰,帮助我和孩子度过了考前紧张期。
</p>
</div>
<div class="border-b border-gray-100 pb-3">
<div class="flex justify-between">
<div class="font-medium text-gray-800">李晓华</div>
<div class="text-xs text-gray-500">2024-06-10</div>
</div>
<p class="text-sm text-gray-600 mt-1">
老师提供的减压方法很有效,孩子学习状态明显改善,感谢这个课程!
{{ item.note }}
</p>
</div>
</div>
<button @click="router.push(`/courses/${course?.id}/reviews`)"
class="w-full text-center text-green-600 mt-3 text-sm">
查看全部评价
......@@ -199,7 +184,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300"
:fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
d="M4.318 6.318 a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682 a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318 a4.5 4.5 0 00-6.364 0z" />
</svg>
收藏
</button>
......@@ -235,16 +220,17 @@
<script setup lang="jsx">
import { ref, onMounted, defineComponent, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import ReviewPopup from '@/components/ui/ReviewPopup.vue'
// import { courses } from '@/utils/mockData'
import { useCart } from '@/contexts/cart'
import { useTitle } from '@vueuse/core';
import { showToast } from 'vant';
import { formatDate } from '@/utils/tools'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import ReviewPopup from '@/components/ui/ReviewPopup.vue'
// 导入接口
import { getCourseDetailAPI } from "@/api/course";
import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course";
import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
const $route = useRoute();
......@@ -328,7 +314,7 @@ const RightContent = defineComponent({
const rightContent = h(RightContent)
// Handle purchase
// 立即购买操作
const handlePurchase = () => {
if (course.value) {
addToCart({
......@@ -342,20 +328,45 @@ const handlePurchase = () => {
}
}
// Handle review submit
const handleReviewSubmit = (review) => {
// TODO: 对接评论提交接口
console.log('Review submitted:', review)
isReviewed.value = true
// 提交评论操作
const handleReviewSubmit = async (review) => {
const { code, msg } = await addGroupCommentAPI({
group_id: course.value?.id,
note: review.content,
score: review.rating
})
if (code) {
showToast('评论提交成功')
isReviewed.value = true
await fetchCommentList()
}
}
const commentList = ref([])
const commentScore = ref(0)
const commentTotal = ref(0)
// 获取评论列表
const fetchCommentList = async () => {
const { code, data } = await getGroupCommentListAPI({
group_id: course.value?.id,
page: 0,
limit: 5
})
if (code) {
commentList.value = data.comment_list
commentScore.value = data.comment_score || 0
commentTotal.value = data.comment_count || 0
}
}
// Fetch course data
// 初始化
onMounted(async () => {
const id = route.params.id
// 调用接口获取课程详情
const res = await getCourseDetailAPI({ i: id });
if (res.code) {
const foundCourse = res.data;
const { code, data } = await getCourseDetailAPI({ i: id });
if (code) {
const foundCourse = data;
if (foundCourse) {
course.value = foundCourse;
teacher.value = {
......@@ -364,10 +375,13 @@ onMounted(async () => {
position: '',
description: '',
}
isPurchased.value = foundCourse.isPurchased;
isReviewed.value = foundCourse.isReviewed;
isFavorite.value = foundCourse.is_favorite;
isPurchased.value = foundCourse.is_buy;
isReviewed.value = foundCourse.is_comment;
// 获取评论列表
await fetchCommentList()
} else {
// Course not found, redirect to courses page
// 课程不存在,跳转到课程主页面
showToast('课程不存在')
router.push('/courses')
}
......
<!--
* @Date: 2025-03-21 11:33:26
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-21 16:02:21
* @LastEditTime: 2025-04-18 14:55:53
* @FilePath: /mlaj/src/views/courses/CourseReviewsPage.vue
* @Description: 文件描述
-->
......@@ -11,81 +11,136 @@
<!-- Overall Rating -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<div class="text-2xl font-bold mr-2">4.9</div>
<div class="text-2xl font-bold mr-2">{{ overallRating }}</div>
<van-rate v-model="overallRating" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" />
</div>
<div class="text-gray-500 text-sm">126条评论</div>
<div class="text-gray-500 text-sm">{{ commentTotal }}条评论</div>
</div>
<!-- Reviews List -->
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<div v-for="(review, index) in reviews" :key="index" class="mb-4 pb-4 border-b border-gray-100 last:border-0">
<div class="flex justify-between items-center mb-2">
<div class="flex items-center flex-1 min-w-0 mr-2">
<div class="flex-grow">
<span class="font-medium text-sm block">{{ review.username }}</span>
<span class="font-medium text-sm block">{{ review.name }}</span>
</div>
</div>
<van-rate v-model="review.rating" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" />
<van-rate v-model="review.score" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" />
</div>
<p class="text-gray-600 text-sm mb-2">{{ review.note }}</p>
<div class="flex justify-between items-center">
<div class="text-gray-400 text-xs">{{ formatDate(review.created_time) }}</div>
<van-icon v-if="review.is_my" name="ellipsis" class="text-gray-400" @click="showActionSheet(review)" />
</div>
<p class="text-gray-600 text-sm mb-2">{{ review.content }}</p>
<div class="text-gray-400 text-xs">{{ review.date }}</div>
</div>
</van-list>
</div>
<!-- Action Sheet -->
<van-action-sheet v-model:show="showActions" :actions="actions" cancel-text="取消" close-on-click-action
@select="onSelect" />
<!-- Review Edit Popup -->
<ReviewPopup v-model:show="showReviewPopup" :initial-score="currentReview?.score"
:initial-note="currentReview?.note" @submit="handleReviewEdit" />
</AppLayout>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import { Rate, List } from 'vant'
import { Rate, List, Icon, ActionSheet, showConfirmDialog, showToast } from 'vant'
import { formatDate } from '@/utils/tools'
import ReviewPopup from '@/components/courses/ReviewPopup.vue'
// 导入接口
import { getGroupCommentListAPI, editGroupCommentAPI, delGroupCommentAPI } from '@/api/course'
const route = useRoute()
const overallRating = ref(4.9)
const overallRating = ref(0)
const loading = ref(false)
const finished = ref(false)
const reviews = ref([])
const commentTotal = ref(0)
const limit = ref(5)
const page = ref(0)
// Mock data for demonstration
const mockReviews = [
{
username: '王小明',
avatar: '',
rating: 5,
content: '课程内容非常实用,老师讲解清晰,帮助我和孩子度过了考前紧张期。',
date: '2024-06-15'
},
{
username: '李晓华',
avatar: '',
rating: 4.5,
content: '老师提供的减压方法很有效,孩子学习状态明显改善,感谢这个课程!',
date: '2024-06-10'
},
// Add more mock reviews here
// 动作面板相关
const showActions = ref(false)
const currentReview = ref(null)
const actions = [
{ name: '编辑', color: '#2563eb' },
{ name: '删除', color: '#ef4444' },
]
const onLoad = () => {
// Simulate async data loading
setTimeout(() => {
const newReviews = mockReviews.map(review => ({
...review,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${Math.random()}`
}))
reviews.value.push(...newReviews)
loading.value = false
// 评论编辑相关
const showReviewPopup = ref(false)
// Set finished when no more data
if (reviews.value.length >= 20) {
finished.value = true
const fetchComments = async () => {
const { code, data } = await getGroupCommentListAPI({
group_id: route.params.id,
page: page.value,
limit: limit.value
})
if (code) {
if (page.value === 0) {
reviews.value = data.comment_list
overallRating.value = data.comment_score || 0
commentTotal.value = data.comment_count || 0
} else {
reviews.value.push(...data.comment_list)
}
}, 1000)
finished.value = data.comment_list.length < limit.value
page.value += 1
}
loading.value = false
}
const onLoad = () => {
fetchComments()
}
const showActionSheet = (review) => {
currentReview.value = review
showActions.value = true
}
const onSelect = (action) => {
if (action.name === '编辑') {
showReviewPopup.value = true
} else if (action.name === '删除') {
showConfirmDialog({
title: '温馨提示',
message: '确定要删除这条评论吗?',
}).then(() => {
handleReviewDelete()
})
}
}
const handleReviewEdit = async ({ score, note }) => {
const { code } = await editGroupCommentAPI({
i: currentReview.value.id,
score,
note
})
if (code) {
showToast('评论修改成功')
page.value = 0
await fetchComments()
}
}
const handleReviewDelete = async () => {
const { code } = await delGroupCommentAPI({
i: currentReview.value.id
})
if (code) {
showToast('评论删除成功')
page.value = 0
await fetchComments()
}
}
</script>
......
<!--
* @Date: 2025-03-21 12:17:03
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-18 10:05:47
* @FilePath: /mlaj/src/views/courses/MyCoursesPage.vue
* @Description: 文件描述
-->
<template>
<div class="bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20">
<!-- Course List -->
......@@ -8,7 +15,7 @@
@load="onLoad"
class="px-4 py-3 space-y-4"
>
<CourseCard v-for="course in courses" :key="course.id" :course="course" />
<CourseCard v-for="course in courses" :key="course.id" :course="course" :linkTo="`/studyCourse/${course.id}`" />
</van-list>
<!-- 无数据提示 -->
......@@ -28,28 +35,46 @@ import CourseCard from '@/components/ui/CourseCard.vue';
import { courses as mockCourses } from '@/utils/mockData';
import { useTitle } from '@vueuse/core';
// 导入接口
import { getOrderListAPI } from '@/api/order'
const $route = useRoute();
const $router = useRouter();
useTitle($route.meta.title);
const courses = ref([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const pageSize = 10
const page = ref(0)
const limit = ref(10)
const onLoad = () => {
loading.value = true
// 模拟异步加载数据
setTimeout(() => {
const start = (page.value - 1) * pageSize
const end = start + pageSize
const newCourses = mockCourses.slice(start, end)
courses.value.push(...newCourses)
loading.value = false
if (courses.value.length >= mockCourses.length) {
finished.value = true
const onLoad = async () => {
const nextPage = page.value
try {
const res = await getOrderListAPI({
limit: limit.value,
page: nextPage,
status: 'PAY' // 只获取已支付的订单
})
if (res.code) {
// 从订单中提取所有课程信息
const newCourses = res.data.reduce((acc, order) => {
if (order.details && Array.isArray(order.details)) {
const coursesWithTitle = order.details.map(detail => ({
...detail,
title: detail.product_name
}))
acc.push(...coursesWithTitle)
}
return acc
}, [])
courses.value = [...courses.value, ...newCourses]
finished.value = newCourses.length < limit.value
page.value = nextPage + 1
}
page.value++
}, 1000)
} catch (error) {
console.error('获取课程列表失败:', error)
}
loading.value = false
}
</script>
......
......@@ -8,13 +8,13 @@
<!-- 固定区域:课程封面和标签页 -->
<div class="fixed top-0 left-0 right-0 z-10 top-wrapper bg-white">
<!-- 课程封面区域 -->
<van-image class="w-full aspect-video object-cover" :src="course?.coverImage" :alt="course?.title" />
<van-image class="w-full aspect-video object-cover" :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title" />
<div class="p-4">
<h1 class="text-black text-xl font-bold mb-2">{{ course?.title }}</h1>
<div class="flex items-center text-gray-500 text-sm">
<span>已更新 20期</span>
<span>已更新 没有字段 期</span>
<span class="mx-2">|</span>
<span>116人订阅</span>
<span>没有字段 人订阅</span>
</div>
</div>
......@@ -38,32 +38,34 @@
:style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }">
<!-- 详情区域 -->
<div id="detail" class="py-4 px-4">
<div class="text-gray-700 text-sm leading-relaxed" v-html="course?.description"></div>
<van-empty description="暂无详情" />
<div v-if="course?.feature" class="text-gray-700 text-sm leading-relaxed" v-html="course?.feature"></div>
<div v-if="course?.highlights" class="text-gray-700 text-sm leading-relaxed" v-html="course?.highlights"></div>
<van-empty v-else description="暂无详情" />
</div>
<div class="h-2 bg-gray-100"></div>
<!-- 目录区域 -->
<div id="catalog" class="py-4">
<div class="space-y-4">
<div v-for="(lesson, index) in course?.lessons" :key="index"
<div v-if="course_lessons.length" class="space-y-4">
<div v-for="(lesson, index) in course_lessons" :key="index"
@click="router.push(`/studyDetail/${lesson.id}`)"
class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
<div v-if="lesson.progress > 0 && lesson.progress < 100"
class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
上次看到</div>
没有字段上次看到</div>
<div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div>
<div class="flex items-center text-sm text-gray-500">
<span>视频</span>&nbsp;
<span>2024-10-22</span>
<span>{{ course_type_maps[lesson.course_type] }}</span>&nbsp;
<span>{{ dayjs(course.schedule_time).format('YYYY-MM-DD') }}</span>
<span class="mx-2">|</span>
<span>1897次学习</span>
<span>没有字段 次学习</span>
<span class="mx-2">|</span>
<span>已学习{{ lesson.progress }}%</span>
<span>没有字段 已学习{{ lesson?.progress }}%</span>
</div>
</div>
</div>
<van-empty v-else description="暂无目录" />
</div>
<div class="h-2 bg-gray-100"></div>
......@@ -94,6 +96,10 @@
import { ref, onMounted, nextTick, onUnmounted } from 'vue';
import { useTitle } from '@vueuse/core';
import { useRouter } from "vue-router";
import dayjs from 'dayjs';
// 导入接口
import { getCourseDetailAPI } from '@/api/course'
const router = useRouter();
......@@ -103,6 +109,7 @@ useTitle('课程详情');
// 当前激活的标签页
const activeTab = ref('detail');
const topWrapperHeight = ref(0);
const resizeObserver = ref(null);
// 计算topWrapperHeight的函数
const updateTopWrapperHeight = () => {
......@@ -110,21 +117,38 @@ const updateTopWrapperHeight = () => {
const topWrapper = document.querySelector('.top-wrapper');
if (topWrapper) {
// 使用 ResizeObserver 监听元素尺寸变化
const resizeObserver = new ResizeObserver(() => {
resizeObserver.value = new ResizeObserver(() => {
topWrapperHeight.value = `${topWrapper.offsetHeight}px`;
});
resizeObserver.observe(topWrapper);
// 组件卸载时取消监听
onUnmounted(() => {
resizeObserver.disconnect();
});
resizeObserver.value.observe(topWrapper);
}
});
};
// 初始化时计算topWrapperHeight
onMounted(() => {
const course = ref([]);
const course_lessons = ref([]);
const course_type_maps = ref({
video: '视频',
audio: '录播课',
image: '图片',
file: '文件',
})
onMounted(async () => {
/**
* 组件挂载时获取课程详情
*/
// 获取课程ID
const courseId = router.currentRoute.value.params.id;
// 调用接口获取课程详情
const { code, data } = await getCourseDetailAPI({ i: courseId });
if (code) {
course.value = data;
course_lessons.value = data.schedule || [];
}
/**
* 初始化时计算topWrapperHeight
*/
// 添加滚动监听
window.addEventListener('scroll', handleScroll);
// 添加窗口大小变化监听
......@@ -137,6 +161,8 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', updateTopWrapperHeight);
// 组件卸载时取消监听
resizeObserver.value.disconnect();
});
// 处理滚动事件
......@@ -175,30 +201,30 @@ const handleTabChange = (name) => {
};
// 课程数据
const course = ref({
title: '开学礼·止的智慧·心法老师·20241001',
coverImage: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
updateTime: '2024.01.17',
viewCount: 1897,
description: '这是一门关于心法的课程,帮助学员掌握止的智慧...',
lessons: [
{
title: '第一课:止的基础',
duration: '45分钟',
progress: 100
},
{
title: '第二课:止的技巧',
duration: '50分钟',
progress: 60
},
{
title: '第三课:止的应用',
duration: '40分钟',
progress: 0
}
]
});
// const course = ref({
// title: '开学礼·止的智慧·心法老师·20241001',
// coverImage: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
// updateTime: '2024.01.17',
// viewCount: 1897,
// description: '这是一门关于心法的课程,帮助学员掌握止的智慧...',
// lessons: [
// {
// title: '第一课:止的基础',
// duration: '45分钟',
// progress: 100
// },
// {
// title: '第二课:止的技巧',
// duration: '50分钟',
// progress: 60
// },
// {
// title: '第三课:止的应用',
// duration: '40分钟',
// progress: 0
// }
// ]
// });
</script>
<style scoped>
......
......@@ -8,7 +8,7 @@
<!-- 固定区域:视频播放和标签页 -->
<div class="fixed top-0 left-0 right-0 z-10 top-wrapper">
<!-- 视频播放区域 -->
<div v-if="course.type === 'video'" class="w-full bg-black relative">
<div v-if="course.course_type === 'video'" class="w-full bg-black relative">
<!-- 视频封面和播放按钮 -->
<div v-if="!isPlaying" class="relative w-full" style="aspect-ratio: 16/9;">
<img :src="course.cover" :alt="course.title" class="w-full h-full object-cover" />
......@@ -22,10 +22,10 @@
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="course.videoUrl" :autoplay="false"
<VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="course.file" :autoplay="false"
@onPlay="handleVideoPlay" @onPause="handleVideoPause" />
</div>
<div v-if="course.type === 'audio'" class="w-full relative" style="border-bottom: 1px solid #F3F4F6;">
<div v-if="course.course_type === 'audio'" class="w-full relative" style="border-bottom: 1px solid #F3F4F6;">
<!-- 音频播放器 -->
<AudioPlayer :songs="audioList" />
</div>
......@@ -47,9 +47,9 @@
<div id="intro" class="py-4 px-4">
<h1 class="text-lg font-bold mb-2">{{ course.title }}</h1>
<div class="text-gray-500 text-sm flex items-center gap-2">
<span>{{ course.date }}</span>
<span>{{ dayjs(course.schedule_time).format('YYYY-MM-DD') }}</span>
<span class="text-gray-300">|</span>
<span>{{ course.studyCount || 0 }}次学习</span>
<span>没有字段{{ course.studyCount || 0 }}次学习</span>
</div>
</div>
......@@ -160,6 +160,10 @@ import { useRoute } from 'vue-router';
import { useTitle } from '@vueuse/core';
import VideoPlayer from '@/components/ui/VideoPlayer.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
import dayjs from 'dayjs';
// 导入接口
import { getScheduleCourseAPI } from '@/api/course';
const route = useRoute();
const course = ref(null);
......@@ -329,43 +333,35 @@ const handleScroll = () => {
}
};
onMounted(() => {
nextTick(() => {
const topWrapper = document.querySelector('.top-wrapper');
const bottomWrapper = document.querySelector('.bottom-wrapper');
if (topWrapper) {
topWrapperHeight.value = topWrapper.clientHeight + 'px';
}
if (bottomWrapper) {
bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px';
}
onMounted(async () => {
// 延迟设置topWrapper和bottomWrapper的高度
setTimeout(() => {
nextTick(() => {
const topWrapper = document.querySelector('.top-wrapper');
const bottomWrapper = document.querySelector('.bottom-wrapper');
if (topWrapper) {
topWrapperHeight.value = topWrapper.clientHeight + 'px';
}
if (bottomWrapper) {
bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px';
}
// 添加滚动监听
window.addEventListener('scroll', handleScroll);
})
}, 500);
// 添加滚动监听
window.addEventListener('scroll', handleScroll);
})
const courseId = route.params.id;
if (courseId) {
// TODO: 这里需要替换为实际的API调用
// 临时使用模拟数据
course.value = {
id: courseId,
title: '开学礼·止的智慧·心法老师·20241001(上)',
videoUrl: 'https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/mp4/xgplayer-demo-360p.mp4',
progress: 35,
studyTime: 3600,
type: '视频课程',
studyCount: 1896,
cover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
date: '2024-12-04',
type: 'video',
};
// TODO: 模拟数据音频和视频显示
console.warn(courseId);
if (courseId == '2') {
course.value.type = 'audio'
} else {
course.value.type = 'video'
const { code, data } = await getScheduleCourseAPI({ i: courseId });
if (code) {
course.value = data;
// TODO: 测试数据
course.value.cover = 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg';
// 音频列表处理
// if (data.course_type === 'audio') {
// audioList.value = [course.value.file];
// }
}
}
})
......