feat(课程): 添加课程评论功能并优化课程详情页
- 添加课程评论功能,支持评论的增删改查 - 优化课程详情页,显示课程评论列表和评分 - 修改课程卡片组件,支持自定义跳转链接 - 更新路由配置,支持带参数的课程学习页面 - 修复部分页面样式和逻辑问题
Showing
11 changed files
with
410 additions
and
183 deletions
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-04-15 09:32:07 | 2 | * @Date: 2025-04-15 09:32:07 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-04-15 12:58:17 | 4 | + * @LastEditTime: 2025-04-18 14:43:46 |
| 5 | * @FilePath: /mlaj/src/api/course.js | 5 | * @FilePath: /mlaj/src/api/course.js |
| 6 | * @Description: 课程模块相关接口 | 6 | * @Description: 课程模块相关接口 |
| 7 | */ | 7 | */ |
| ... | @@ -10,7 +10,11 @@ import { fn, fetch } from './fn' | ... | @@ -10,7 +10,11 @@ import { fn, fetch } from './fn' |
| 10 | const Api = { | 10 | const Api = { |
| 11 | GET_COURSE_LIST: '/srv/?a=schedule&t=list', | 11 | GET_COURSE_LIST: '/srv/?a=schedule&t=list', |
| 12 | GET_COURSE_DETAIL: '/srv/?a=schedule&t=detail', | 12 | GET_COURSE_DETAIL: '/srv/?a=schedule&t=detail', |
| 13 | - GET_SCHEDULE_COURSE_LIST: '/srv/?a=schedule&t=course', | 13 | + GET_SCHEDULE_COURSE: '/srv/?a=schedule&t=course', |
| 14 | + GET_GROUP_COMMENT_LIST: '/srv/?a=group_comment_list', | ||
| 15 | + GROUP_COMMENT_ADD: '/srv/?a=group_comment_add', | ||
| 16 | + GROUP_COMMENT_EDIT: '/srv/?a=group_comment_edit', | ||
| 17 | + GROUP_COMMENT_DEL: '/srv/?a=group_comment_del', | ||
| 14 | } | 18 | } |
| 15 | 19 | ||
| 16 | /** | 20 | /** |
| ... | @@ -31,8 +35,42 @@ export const getCourseListAPI = (params) => fn(fetch.get(Api.GET_COURSE_LIST, p | ... | @@ -31,8 +35,42 @@ export const getCourseListAPI = (params) => fn(fetch.get(Api.GET_COURSE_LIST, p |
| 31 | export const getCourseDetailAPI = (params) => fn(fetch.get(Api.GET_COURSE_DETAIL, params)) | 35 | export const getCourseDetailAPI = (params) => fn(fetch.get(Api.GET_COURSE_DETAIL, params)) |
| 32 | 36 | ||
| 33 | /** | 37 | /** |
| 34 | - * @description: 获取特定学习课程的目录 | 38 | + * @description: 获取特定学习课程的详情 |
| 35 | * @param: i 课程 ID | 39 | * @param: i 课程 ID |
| 36 | * @return: data: [{ id, schedule_time, seq, title, duration, course_id, file}] | 40 | * @return: data: [{ id, schedule_time, seq, title, duration, course_id, file}] |
| 37 | */ | 41 | */ |
| 38 | -export const getScheduleCourseListAPI = (params) => fn(fetch.get(Api.GET_SCHEDULE_COURSE_LIST, params)) | 42 | +export const getScheduleCourseAPI = (params) => fn(fetch.get(Api.GET_SCHEDULE_COURSE, params)) |
| 43 | + | ||
| 44 | +/** | ||
| 45 | + * @description: 获取课程评论列表 | ||
| 46 | + * @param: i 课程 ID | ||
| 47 | + * @param: limit 每页数量 默认10 | ||
| 48 | + * @param: page 页码 | ||
| 49 | + * @return: data: { comment_score 课程评论分数, comment_count 评论数量, comment_list [{ id 评论id, created_by 评论人ID, name 评论人姓名, note 评论内容, score 分数, create_time 评论时间}] 评论列表} | ||
| 50 | + */ | ||
| 51 | +export const getGroupCommentListAPI = (params) => fn(fetch.get(Api.GET_GROUP_COMMENT_LIST, params)) | ||
| 52 | + | ||
| 53 | +/** | ||
| 54 | + * @description: 添加课程评论 | ||
| 55 | + * @param: i 课程 ID | ||
| 56 | + * @param: note 评论内容 | ||
| 57 | + * @param: score 分数 | ||
| 58 | + * @return: data: '' | ||
| 59 | + */ | ||
| 60 | +export const addGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_ADD, params)) | ||
| 61 | + | ||
| 62 | +/** | ||
| 63 | + * @description: 编辑课程评论 | ||
| 64 | + * @param: i 课程 ID | ||
| 65 | + * @param: note 评论内容 | ||
| 66 | + * @param: score 分数 | ||
| 67 | + * @return: data: '' | ||
| 68 | + */ | ||
| 69 | +export const editGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_EDIT, params)) | ||
| 70 | + | ||
| 71 | +/** | ||
| 72 | + * @description: 删除课程评论 | ||
| 73 | + * @param: i 课程 ID | ||
| 74 | + * @return: data: '' | ||
| 75 | + */ | ||
| 76 | +export const delGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_DEL, params)) | ... | ... |
| ... | @@ -27,6 +27,7 @@ declare module 'vue' { | ... | @@ -27,6 +27,7 @@ declare module 'vue' { |
| 27 | SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default'] | 27 | SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default'] |
| 28 | TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default'] | 28 | TermsPopup: typeof import('./components/ui/TermsPopup.vue')['default'] |
| 29 | UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default'] | 29 | UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default'] |
| 30 | + VanActionSheet: typeof import('vant/es')['ActionSheet'] | ||
| 30 | VanButton: typeof import('vant/es')['Button'] | 31 | VanButton: typeof import('vant/es')['Button'] |
| 31 | VanCellGroup: typeof import('vant/es')['CellGroup'] | 32 | VanCellGroup: typeof import('vant/es')['CellGroup'] |
| 32 | VanCheckbox: typeof import('vant/es')['Checkbox'] | 33 | VanCheckbox: typeof import('vant/es')['Checkbox'] | ... | ... |
src/components/courses/ReviewPopup.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-18 | ||
| 3 | + * @Description: 评论编辑组件 | ||
| 4 | +--> | ||
| 5 | +<template> | ||
| 6 | + <van-popup :show="show" @update:show="emit('update:show', $event)" position="bottom" round> | ||
| 7 | + <div class="p-4"> | ||
| 8 | + <div class="text-lg font-bold text-center mb-4">编辑评价</div> | ||
| 9 | + <div class="flex justify-center mb-4"> | ||
| 10 | + <van-rate v-model="score" :size="24" color="#ffd21e" void-icon="star" void-color="#eee" /> | ||
| 11 | + </div> | ||
| 12 | + <van-field v-model="note" rows="3" type="textarea" placeholder="请输入您的评价内容" class="mb-4" /> | ||
| 13 | + <div class="flex justify-end space-x-3"> | ||
| 14 | + <van-button round plain @click="handleCancel">取消</van-button> | ||
| 15 | + <van-button round type="primary" color="#4CAF50" @click="handleSubmit">提交</van-button> | ||
| 16 | + </div> | ||
| 17 | + </div> | ||
| 18 | + </van-popup> | ||
| 19 | +</template> | ||
| 20 | + | ||
| 21 | +<script setup> | ||
| 22 | +import { ref, watch } from 'vue' | ||
| 23 | +import { Popup, Rate, Field, Button } from 'vant' | ||
| 24 | + | ||
| 25 | +const props = defineProps({ | ||
| 26 | + show: { | ||
| 27 | + type: Boolean, | ||
| 28 | + default: false | ||
| 29 | + }, | ||
| 30 | + initialScore: { | ||
| 31 | + type: Number, | ||
| 32 | + default: 5 | ||
| 33 | + }, | ||
| 34 | + initialNote: { | ||
| 35 | + type: String, | ||
| 36 | + default: '' | ||
| 37 | + } | ||
| 38 | +}) | ||
| 39 | + | ||
| 40 | +const emit = defineEmits(['update:show', 'submit']) | ||
| 41 | + | ||
| 42 | +const score = ref(props.initialScore) | ||
| 43 | +const note = ref(props.initialNote) | ||
| 44 | + | ||
| 45 | +watch(() => props.show, (newVal) => { | ||
| 46 | + if (newVal) { | ||
| 47 | + score.value = props.initialScore | ||
| 48 | + note.value = props.initialNote | ||
| 49 | + } | ||
| 50 | +}) | ||
| 51 | + | ||
| 52 | +const handleCancel = () => { | ||
| 53 | + emit('update:show', false) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +const handleSubmit = () => { | ||
| 57 | + if (score.value === 0) { | ||
| 58 | + return | ||
| 59 | + } | ||
| 60 | + if (!note.value.trim()) { | ||
| 61 | + return | ||
| 62 | + } | ||
| 63 | + emit('submit', { | ||
| 64 | + score: score.value, | ||
| 65 | + note: note.value.trim() | ||
| 66 | + }) | ||
| 67 | + emit('update:show', false) | ||
| 68 | +} | ||
| 69 | +</script> |
| ... | @@ -6,7 +6,7 @@ | ... | @@ -6,7 +6,7 @@ |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | --> | 7 | --> |
| 8 | <template> | 8 | <template> |
| 9 | - <router-link :to="`/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm"> | 9 | + <router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm"> |
| 10 | <div class="w-1/3 h-28"> | 10 | <div class="w-1/3 h-28"> |
| 11 | <img | 11 | <img |
| 12 | :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | 12 | :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" |
| ... | @@ -37,6 +37,10 @@ defineProps({ | ... | @@ -37,6 +37,10 @@ defineProps({ |
| 37 | course: { | 37 | course: { |
| 38 | type: Object, | 38 | type: Object, |
| 39 | required: true | 39 | required: true |
| 40 | + }, | ||
| 41 | + linkTo: { | ||
| 42 | + type: String, | ||
| 43 | + default: '' | ||
| 40 | } | 44 | } |
| 41 | }) | 45 | }) |
| 42 | </script> | 46 | </script> | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-03-24 16:57:55 | 2 | * @Date: 2025-03-24 16:57:55 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-03-24 17:17:44 | 4 | + * @LastEditTime: 2025-04-18 14:12:14 |
| 5 | * @FilePath: /mlaj/src/components/ui/ReviewPopup.vue | 5 | * @FilePath: /mlaj/src/components/ui/ReviewPopup.vue |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | --> | 7 | --> |
| ... | @@ -91,8 +91,7 @@ const handleSubmit = () => { | ... | @@ -91,8 +91,7 @@ const handleSubmit = () => { |
| 91 | rating: rating.value, | 91 | rating: rating.value, |
| 92 | content: content.value.trim(), | 92 | content: content.value.trim(), |
| 93 | }); | 93 | }); |
| 94 | - show_toast.value = true; | 94 | + // 提交成功后关闭弹窗 |
| 95 | - message.value = "评论提交成功"; | ||
| 96 | handleCancel(); | 95 | handleCancel(); |
| 97 | }; | 96 | }; |
| 98 | </script> | 97 | </script> | ... | ... |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-03-20 20:36:36 | 2 | * @Date: 2025-03-20 20:36:36 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-04-08 16:09:21 | 4 | + * @LastEditTime: 2025-04-18 10:08:35 |
| 5 | * @FilePath: /mlaj/src/router/routes.js | 5 | * @FilePath: /mlaj/src/router/routes.js |
| 6 | * @Description: 路由地址映射配置 | 6 | * @Description: 路由地址映射配置 |
| 7 | */ | 7 | */ |
| ... | @@ -213,7 +213,7 @@ export const routes = [ | ... | @@ -213,7 +213,7 @@ export const routes = [ |
| 213 | } | 213 | } |
| 214 | }, | 214 | }, |
| 215 | { | 215 | { |
| 216 | - path: '/studyCourse', | 216 | + path: '/studyCourse/:id', |
| 217 | component: () => import('@/views/study/studyCoursePage.vue'), | 217 | component: () => import('@/views/study/studyCoursePage.vue'), |
| 218 | meta: { | 218 | meta: { |
| 219 | title: '课程集合页面', | 219 | title: '课程集合页面', | ... | ... |
| ... | @@ -118,38 +118,23 @@ | ... | @@ -118,38 +118,23 @@ |
| 118 | <FrostedGlass class="mb-6 p-4 rounded-xl"> | 118 | <FrostedGlass class="mb-6 p-4 rounded-xl"> |
| 119 | <h3 class="text-lg font-bold text-gray-800 mb-3">学员评价</h3> | 119 | <h3 class="text-lg font-bold text-gray-800 mb-3">学员评价</h3> |
| 120 | <div class="flex items-center mb-3"> | 120 | <div class="flex items-center mb-3"> |
| 121 | - <div class="flex text-yellow-400 mr-2"> | 121 | + <div class="flex items-center mr-2"> |
| 122 | - <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" | 122 | + <van-rate v-model="commentScore" readonly allow-half color="#facc15" void-color="#e5e7eb" size="20" /> |
| 123 | - fill="currentColor"> | ||
| 124 | - <path | ||
| 125 | - 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" /> | ||
| 126 | - </svg> | ||
| 127 | </div> | 123 | </div> |
| 128 | - <div class="text-gray-700">4.9 (126条评论)</div> | 124 | + <div class="text-gray-700">{{ commentScore }} ({{ commentTotal }}条评论)</div> |
| 129 | </div> | 125 | </div> |
| 130 | 126 | ||
| 131 | <div class="space-y-4"> | 127 | <div class="space-y-4"> |
| 132 | - <div class="border-b border-gray-100 pb-3"> | 128 | + <div v-for="(item, index) in commentList" :key="index" class="border-b border-gray-100 pb-3"> |
| 133 | <div class="flex justify-between"> | 129 | <div class="flex justify-between"> |
| 134 | - <div class="font-medium text-gray-800">王小明</div> | 130 | + <div class="font-medium text-gray-800">{{ item.name }}</div> |
| 135 | - <div class="text-xs text-gray-500">2024-06-15</div> | 131 | + <div class="text-xs text-gray-500">{{ formatDate(item.created_time) }}</div> |
| 136 | </div> | 132 | </div> |
| 137 | <p class="text-sm text-gray-600 mt-1"> | 133 | <p class="text-sm text-gray-600 mt-1"> |
| 138 | - 课程内容非常实用,老师讲解清晰,帮助我和孩子度过了考前紧张期。 | 134 | + {{ item.note }} |
| 139 | </p> | 135 | </p> |
| 140 | </div> | 136 | </div> |
| 141 | - | ||
| 142 | - <div class="border-b border-gray-100 pb-3"> | ||
| 143 | - <div class="flex justify-between"> | ||
| 144 | - <div class="font-medium text-gray-800">李晓华</div> | ||
| 145 | - <div class="text-xs text-gray-500">2024-06-10</div> | ||
| 146 | - </div> | ||
| 147 | - <p class="text-sm text-gray-600 mt-1"> | ||
| 148 | - 老师提供的减压方法很有效,孩子学习状态明显改善,感谢这个课程! | ||
| 149 | - </p> | ||
| 150 | </div> | 137 | </div> |
| 151 | - </div> | ||
| 152 | - | ||
| 153 | <button @click="router.push(`/courses/${course?.id}/reviews`)" | 138 | <button @click="router.push(`/courses/${course?.id}/reviews`)" |
| 154 | class="w-full text-center text-green-600 mt-3 text-sm"> | 139 | class="w-full text-center text-green-600 mt-3 text-sm"> |
| 155 | 查看全部评价 | 140 | 查看全部评价 |
| ... | @@ -199,7 +184,7 @@ | ... | @@ -199,7 +184,7 @@ |
| 199 | <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300" | 184 | <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 transition-transform duration-300" |
| 200 | :fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'"> | 185 | :fill="isFavorite ? 'red' : 'none'" viewBox="0 0 24 24" :stroke="isFavorite ? 'red' : 'currentColor'"> |
| 201 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | 186 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" |
| 202 | - 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" /> | 187 | + 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" /> |
| 203 | </svg> | 188 | </svg> |
| 204 | 收藏 | 189 | 收藏 |
| 205 | </button> | 190 | </button> |
| ... | @@ -235,16 +220,17 @@ | ... | @@ -235,16 +220,17 @@ |
| 235 | <script setup lang="jsx"> | 220 | <script setup lang="jsx"> |
| 236 | import { ref, onMounted, defineComponent, h } from 'vue' | 221 | import { ref, onMounted, defineComponent, h } from 'vue' |
| 237 | import { useRoute, useRouter } from 'vue-router' | 222 | import { useRoute, useRouter } from 'vue-router' |
| 238 | -import AppLayout from '@/components/layout/AppLayout.vue' | ||
| 239 | -import FrostedGlass from '@/components/ui/FrostedGlass.vue' | ||
| 240 | -import ReviewPopup from '@/components/ui/ReviewPopup.vue' | ||
| 241 | -// import { courses } from '@/utils/mockData' | ||
| 242 | import { useCart } from '@/contexts/cart' | 223 | import { useCart } from '@/contexts/cart' |
| 243 | import { useTitle } from '@vueuse/core'; | 224 | import { useTitle } from '@vueuse/core'; |
| 244 | import { showToast } from 'vant'; | 225 | import { showToast } from 'vant'; |
| 226 | +import { formatDate } from '@/utils/tools' | ||
| 227 | + | ||
| 228 | +import AppLayout from '@/components/layout/AppLayout.vue' | ||
| 229 | +import FrostedGlass from '@/components/ui/FrostedGlass.vue' | ||
| 230 | +import ReviewPopup from '@/components/ui/ReviewPopup.vue' | ||
| 245 | 231 | ||
| 246 | // 导入接口 | 232 | // 导入接口 |
| 247 | -import { getCourseDetailAPI } from "@/api/course"; | 233 | +import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course"; |
| 248 | import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite"; | 234 | import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite"; |
| 249 | 235 | ||
| 250 | const $route = useRoute(); | 236 | const $route = useRoute(); |
| ... | @@ -328,7 +314,7 @@ const RightContent = defineComponent({ | ... | @@ -328,7 +314,7 @@ const RightContent = defineComponent({ |
| 328 | 314 | ||
| 329 | const rightContent = h(RightContent) | 315 | const rightContent = h(RightContent) |
| 330 | 316 | ||
| 331 | -// Handle purchase | 317 | +// 立即购买操作 |
| 332 | const handlePurchase = () => { | 318 | const handlePurchase = () => { |
| 333 | if (course.value) { | 319 | if (course.value) { |
| 334 | addToCart({ | 320 | addToCart({ |
| ... | @@ -342,20 +328,45 @@ const handlePurchase = () => { | ... | @@ -342,20 +328,45 @@ const handlePurchase = () => { |
| 342 | } | 328 | } |
| 343 | } | 329 | } |
| 344 | 330 | ||
| 345 | -// Handle review submit | 331 | +// 提交评论操作 |
| 346 | -const handleReviewSubmit = (review) => { | 332 | +const handleReviewSubmit = async (review) => { |
| 347 | - // TODO: 对接评论提交接口 | 333 | + const { code, msg } = await addGroupCommentAPI({ |
| 348 | - console.log('Review submitted:', review) | 334 | + group_id: course.value?.id, |
| 335 | + note: review.content, | ||
| 336 | + score: review.rating | ||
| 337 | + }) | ||
| 338 | + if (code) { | ||
| 339 | + showToast('评论提交成功') | ||
| 349 | isReviewed.value = true | 340 | isReviewed.value = true |
| 341 | + await fetchCommentList() | ||
| 342 | + } | ||
| 350 | } | 343 | } |
| 351 | 344 | ||
| 352 | -// Fetch course data | 345 | +const commentList = ref([]) |
| 346 | +const commentScore = ref(0) | ||
| 347 | +const commentTotal = ref(0) | ||
| 348 | + | ||
| 349 | +// 获取评论列表 | ||
| 350 | +const fetchCommentList = async () => { | ||
| 351 | + const { code, data } = await getGroupCommentListAPI({ | ||
| 352 | + group_id: course.value?.id, | ||
| 353 | + page: 0, | ||
| 354 | + limit: 5 | ||
| 355 | + }) | ||
| 356 | + if (code) { | ||
| 357 | + commentList.value = data.comment_list | ||
| 358 | + commentScore.value = data.comment_score || 0 | ||
| 359 | + commentTotal.value = data.comment_count || 0 | ||
| 360 | + } | ||
| 361 | +} | ||
| 362 | + | ||
| 363 | +// 初始化 | ||
| 353 | onMounted(async () => { | 364 | onMounted(async () => { |
| 354 | const id = route.params.id | 365 | const id = route.params.id |
| 355 | // 调用接口获取课程详情 | 366 | // 调用接口获取课程详情 |
| 356 | - const res = await getCourseDetailAPI({ i: id }); | 367 | + const { code, data } = await getCourseDetailAPI({ i: id }); |
| 357 | - if (res.code) { | 368 | + if (code) { |
| 358 | - const foundCourse = res.data; | 369 | + const foundCourse = data; |
| 359 | if (foundCourse) { | 370 | if (foundCourse) { |
| 360 | course.value = foundCourse; | 371 | course.value = foundCourse; |
| 361 | teacher.value = { | 372 | teacher.value = { |
| ... | @@ -364,10 +375,13 @@ onMounted(async () => { | ... | @@ -364,10 +375,13 @@ onMounted(async () => { |
| 364 | position: '', | 375 | position: '', |
| 365 | description: '', | 376 | description: '', |
| 366 | } | 377 | } |
| 367 | - isPurchased.value = foundCourse.isPurchased; | 378 | + isFavorite.value = foundCourse.is_favorite; |
| 368 | - isReviewed.value = foundCourse.isReviewed; | 379 | + isPurchased.value = foundCourse.is_buy; |
| 380 | + isReviewed.value = foundCourse.is_comment; | ||
| 381 | + // 获取评论列表 | ||
| 382 | + await fetchCommentList() | ||
| 369 | } else { | 383 | } else { |
| 370 | - // Course not found, redirect to courses page | 384 | + // 课程不存在,跳转到课程主页面 |
| 371 | showToast('课程不存在') | 385 | showToast('课程不存在') |
| 372 | router.push('/courses') | 386 | router.push('/courses') |
| 373 | } | 387 | } | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-03-21 11:33:26 | 2 | * @Date: 2025-03-21 11:33:26 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-03-21 16:02:21 | 4 | + * @LastEditTime: 2025-04-18 14:55:53 |
| 5 | * @FilePath: /mlaj/src/views/courses/CourseReviewsPage.vue | 5 | * @FilePath: /mlaj/src/views/courses/CourseReviewsPage.vue |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | --> | 7 | --> |
| ... | @@ -11,81 +11,136 @@ | ... | @@ -11,81 +11,136 @@ |
| 11 | <!-- Overall Rating --> | 11 | <!-- Overall Rating --> |
| 12 | <div class="flex items-center justify-between mb-4"> | 12 | <div class="flex items-center justify-between mb-4"> |
| 13 | <div class="flex items-center"> | 13 | <div class="flex items-center"> |
| 14 | - <div class="text-2xl font-bold mr-2">4.9</div> | 14 | + <div class="text-2xl font-bold mr-2">{{ overallRating }}</div> |
| 15 | <van-rate v-model="overallRating" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" /> | 15 | <van-rate v-model="overallRating" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" /> |
| 16 | </div> | 16 | </div> |
| 17 | - <div class="text-gray-500 text-sm">126条评论</div> | 17 | + <div class="text-gray-500 text-sm">{{ commentTotal }}条评论</div> |
| 18 | </div> | 18 | </div> |
| 19 | 19 | ||
| 20 | <!-- Reviews List --> | 20 | <!-- Reviews List --> |
| 21 | - <van-list | 21 | + <van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad"> |
| 22 | - v-model:loading="loading" | ||
| 23 | - :finished="finished" | ||
| 24 | - finished-text="没有更多了" | ||
| 25 | - @load="onLoad" | ||
| 26 | - > | ||
| 27 | <div v-for="(review, index) in reviews" :key="index" class="mb-4 pb-4 border-b border-gray-100 last:border-0"> | 22 | <div v-for="(review, index) in reviews" :key="index" class="mb-4 pb-4 border-b border-gray-100 last:border-0"> |
| 28 | <div class="flex justify-between items-center mb-2"> | 23 | <div class="flex justify-between items-center mb-2"> |
| 29 | <div class="flex items-center flex-1 min-w-0 mr-2"> | 24 | <div class="flex items-center flex-1 min-w-0 mr-2"> |
| 30 | <div class="flex-grow"> | 25 | <div class="flex-grow"> |
| 31 | - <span class="font-medium text-sm block">{{ review.username }}</span> | 26 | + <span class="font-medium text-sm block">{{ review.name }}</span> |
| 32 | </div> | 27 | </div> |
| 33 | </div> | 28 | </div> |
| 34 | - <van-rate v-model="review.rating" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" /> | 29 | + <van-rate v-model="review.score" readonly :size="20" color="#ffd21e" void-icon="star" void-color="#eee" /> |
| 30 | + </div> | ||
| 31 | + <p class="text-gray-600 text-sm mb-2">{{ review.note }}</p> | ||
| 32 | + <div class="flex justify-between items-center"> | ||
| 33 | + <div class="text-gray-400 text-xs">{{ formatDate(review.created_time) }}</div> | ||
| 34 | + <van-icon v-if="review.is_my" name="ellipsis" class="text-gray-400" @click="showActionSheet(review)" /> | ||
| 35 | </div> | 35 | </div> |
| 36 | - <p class="text-gray-600 text-sm mb-2">{{ review.content }}</p> | ||
| 37 | - <div class="text-gray-400 text-xs">{{ review.date }}</div> | ||
| 38 | </div> | 36 | </div> |
| 39 | </van-list> | 37 | </van-list> |
| 40 | </div> | 38 | </div> |
| 39 | + | ||
| 40 | + <!-- Action Sheet --> | ||
| 41 | + <van-action-sheet v-model:show="showActions" :actions="actions" cancel-text="取消" close-on-click-action | ||
| 42 | + @select="onSelect" /> | ||
| 43 | + | ||
| 44 | + <!-- Review Edit Popup --> | ||
| 45 | + <ReviewPopup v-model:show="showReviewPopup" :initial-score="currentReview?.score" | ||
| 46 | + :initial-note="currentReview?.note" @submit="handleReviewEdit" /> | ||
| 41 | </AppLayout> | 47 | </AppLayout> |
| 42 | </template> | 48 | </template> |
| 43 | 49 | ||
| 44 | <script setup> | 50 | <script setup> |
| 45 | -import { ref } from 'vue' | 51 | +import { ref, onMounted } from 'vue' |
| 46 | import { useRoute } from 'vue-router' | 52 | import { useRoute } from 'vue-router' |
| 47 | import AppLayout from '@/components/layout/AppLayout.vue' | 53 | import AppLayout from '@/components/layout/AppLayout.vue' |
| 48 | -import { Rate, List } from 'vant' | 54 | +import { Rate, List, Icon, ActionSheet, showConfirmDialog, showToast } from 'vant' |
| 55 | +import { formatDate } from '@/utils/tools' | ||
| 56 | +import ReviewPopup from '@/components/courses/ReviewPopup.vue' | ||
| 57 | + | ||
| 58 | +// 导入接口 | ||
| 59 | +import { getGroupCommentListAPI, editGroupCommentAPI, delGroupCommentAPI } from '@/api/course' | ||
| 49 | 60 | ||
| 50 | const route = useRoute() | 61 | const route = useRoute() |
| 51 | -const overallRating = ref(4.9) | 62 | +const overallRating = ref(0) |
| 52 | const loading = ref(false) | 63 | const loading = ref(false) |
| 53 | const finished = ref(false) | 64 | const finished = ref(false) |
| 54 | const reviews = ref([]) | 65 | const reviews = ref([]) |
| 66 | +const commentTotal = ref(0) | ||
| 67 | +const limit = ref(5) | ||
| 68 | +const page = ref(0) | ||
| 55 | 69 | ||
| 56 | -// Mock data for demonstration | 70 | +// 动作面板相关 |
| 57 | -const mockReviews = [ | 71 | +const showActions = ref(false) |
| 58 | - { | 72 | +const currentReview = ref(null) |
| 59 | - username: '王小明', | 73 | +const actions = [ |
| 60 | - avatar: '', | 74 | + { name: '编辑', color: '#2563eb' }, |
| 61 | - rating: 5, | 75 | + { name: '删除', color: '#ef4444' }, |
| 62 | - content: '课程内容非常实用,老师讲解清晰,帮助我和孩子度过了考前紧张期。', | ||
| 63 | - date: '2024-06-15' | ||
| 64 | - }, | ||
| 65 | - { | ||
| 66 | - username: '李晓华', | ||
| 67 | - avatar: '', | ||
| 68 | - rating: 4.5, | ||
| 69 | - content: '老师提供的减压方法很有效,孩子学习状态明显改善,感谢这个课程!', | ||
| 70 | - date: '2024-06-10' | ||
| 71 | - }, | ||
| 72 | - // Add more mock reviews here | ||
| 73 | ] | 76 | ] |
| 74 | 77 | ||
| 75 | -const onLoad = () => { | 78 | +// 评论编辑相关 |
| 76 | - // Simulate async data loading | 79 | +const showReviewPopup = ref(false) |
| 77 | - setTimeout(() => { | 80 | + |
| 78 | - const newReviews = mockReviews.map(review => ({ | 81 | +const fetchComments = async () => { |
| 79 | - ...review, | 82 | + const { code, data } = await getGroupCommentListAPI({ |
| 80 | - avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${Math.random()}` | 83 | + group_id: route.params.id, |
| 81 | - })) | 84 | + page: page.value, |
| 82 | - reviews.value.push(...newReviews) | 85 | + limit: limit.value |
| 86 | + }) | ||
| 87 | + if (code) { | ||
| 88 | + if (page.value === 0) { | ||
| 89 | + reviews.value = data.comment_list | ||
| 90 | + overallRating.value = data.comment_score || 0 | ||
| 91 | + commentTotal.value = data.comment_count || 0 | ||
| 92 | + } else { | ||
| 93 | + reviews.value.push(...data.comment_list) | ||
| 94 | + } | ||
| 95 | + finished.value = data.comment_list.length < limit.value | ||
| 96 | + page.value += 1 | ||
| 97 | + } | ||
| 83 | loading.value = false | 98 | loading.value = false |
| 99 | +} | ||
| 100 | + | ||
| 101 | +const onLoad = () => { | ||
| 102 | + fetchComments() | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +const showActionSheet = (review) => { | ||
| 106 | + currentReview.value = review | ||
| 107 | + showActions.value = true | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +const onSelect = (action) => { | ||
| 111 | + if (action.name === '编辑') { | ||
| 112 | + showReviewPopup.value = true | ||
| 113 | + } else if (action.name === '删除') { | ||
| 114 | + showConfirmDialog({ | ||
| 115 | + title: '温馨提示', | ||
| 116 | + message: '确定要删除这条评论吗?', | ||
| 117 | + }).then(() => { | ||
| 118 | + handleReviewDelete() | ||
| 119 | + }) | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +const handleReviewEdit = async ({ score, note }) => { | ||
| 124 | + const { code } = await editGroupCommentAPI({ | ||
| 125 | + i: currentReview.value.id, | ||
| 126 | + score, | ||
| 127 | + note | ||
| 128 | + }) | ||
| 129 | + if (code) { | ||
| 130 | + showToast('评论修改成功') | ||
| 131 | + page.value = 0 | ||
| 132 | + await fetchComments() | ||
| 133 | + } | ||
| 134 | +} | ||
| 84 | 135 | ||
| 85 | - // Set finished when no more data | 136 | +const handleReviewDelete = async () => { |
| 86 | - if (reviews.value.length >= 20) { | 137 | + const { code } = await delGroupCommentAPI({ |
| 87 | - finished.value = true | 138 | + i: currentReview.value.id |
| 139 | + }) | ||
| 140 | + if (code) { | ||
| 141 | + showToast('评论删除成功') | ||
| 142 | + page.value = 0 | ||
| 143 | + await fetchComments() | ||
| 88 | } | 144 | } |
| 89 | - }, 1000) | ||
| 90 | } | 145 | } |
| 91 | </script> | 146 | </script> | ... | ... |
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-03-21 12:17:03 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-18 10:05:47 | ||
| 5 | + * @FilePath: /mlaj/src/views/courses/MyCoursesPage.vue | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | +--> | ||
| 1 | <template> | 8 | <template> |
| 2 | <div class="bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20"> | 9 | <div class="bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20"> |
| 3 | <!-- Course List --> | 10 | <!-- Course List --> |
| ... | @@ -8,7 +15,7 @@ | ... | @@ -8,7 +15,7 @@ |
| 8 | @load="onLoad" | 15 | @load="onLoad" |
| 9 | class="px-4 py-3 space-y-4" | 16 | class="px-4 py-3 space-y-4" |
| 10 | > | 17 | > |
| 11 | - <CourseCard v-for="course in courses" :key="course.id" :course="course" /> | 18 | + <CourseCard v-for="course in courses" :key="course.id" :course="course" :linkTo="`/studyCourse/${course.id}`" /> |
| 12 | </van-list> | 19 | </van-list> |
| 13 | 20 | ||
| 14 | <!-- 无数据提示 --> | 21 | <!-- 无数据提示 --> |
| ... | @@ -28,28 +35,46 @@ import CourseCard from '@/components/ui/CourseCard.vue'; | ... | @@ -28,28 +35,46 @@ import CourseCard from '@/components/ui/CourseCard.vue'; |
| 28 | import { courses as mockCourses } from '@/utils/mockData'; | 35 | import { courses as mockCourses } from '@/utils/mockData'; |
| 29 | import { useTitle } from '@vueuse/core'; | 36 | import { useTitle } from '@vueuse/core'; |
| 30 | 37 | ||
| 38 | +// 导入接口 | ||
| 39 | +import { getOrderListAPI } from '@/api/order' | ||
| 40 | + | ||
| 31 | const $route = useRoute(); | 41 | const $route = useRoute(); |
| 32 | const $router = useRouter(); | 42 | const $router = useRouter(); |
| 33 | useTitle($route.meta.title); | 43 | useTitle($route.meta.title); |
| 44 | + | ||
| 34 | const courses = ref([]) | 45 | const courses = ref([]) |
| 35 | const loading = ref(false) | 46 | const loading = ref(false) |
| 36 | const finished = ref(false) | 47 | const finished = ref(false) |
| 37 | -const page = ref(1) | 48 | +const page = ref(0) |
| 38 | -const pageSize = 10 | 49 | +const limit = ref(10) |
| 39 | 50 | ||
| 40 | -const onLoad = () => { | 51 | +const onLoad = async () => { |
| 41 | - loading.value = true | 52 | + const nextPage = page.value |
| 42 | - // 模拟异步加载数据 | 53 | + try { |
| 43 | - setTimeout(() => { | 54 | + const res = await getOrderListAPI({ |
| 44 | - const start = (page.value - 1) * pageSize | 55 | + limit: limit.value, |
| 45 | - const end = start + pageSize | 56 | + page: nextPage, |
| 46 | - const newCourses = mockCourses.slice(start, end) | 57 | + status: 'PAY' // 只获取已支付的订单 |
| 47 | - courses.value.push(...newCourses) | 58 | + }) |
| 48 | - loading.value = false | 59 | + if (res.code) { |
| 49 | - if (courses.value.length >= mockCourses.length) { | 60 | + // 从订单中提取所有课程信息 |
| 50 | - finished.value = true | 61 | + const newCourses = res.data.reduce((acc, order) => { |
| 62 | + if (order.details && Array.isArray(order.details)) { | ||
| 63 | + const coursesWithTitle = order.details.map(detail => ({ | ||
| 64 | + ...detail, | ||
| 65 | + title: detail.product_name | ||
| 66 | + })) | ||
| 67 | + acc.push(...coursesWithTitle) | ||
| 51 | } | 68 | } |
| 52 | - page.value++ | 69 | + return acc |
| 53 | - }, 1000) | 70 | + }, []) |
| 71 | + courses.value = [...courses.value, ...newCourses] | ||
| 72 | + finished.value = newCourses.length < limit.value | ||
| 73 | + page.value = nextPage + 1 | ||
| 74 | + } | ||
| 75 | + } catch (error) { | ||
| 76 | + console.error('获取课程列表失败:', error) | ||
| 77 | + } | ||
| 78 | + loading.value = false | ||
| 54 | } | 79 | } |
| 55 | </script> | 80 | </script> | ... | ... |
| ... | @@ -8,13 +8,13 @@ | ... | @@ -8,13 +8,13 @@ |
| 8 | <!-- 固定区域:课程封面和标签页 --> | 8 | <!-- 固定区域:课程封面和标签页 --> |
| 9 | <div class="fixed top-0 left-0 right-0 z-10 top-wrapper bg-white"> | 9 | <div class="fixed top-0 left-0 right-0 z-10 top-wrapper bg-white"> |
| 10 | <!-- 课程封面区域 --> | 10 | <!-- 课程封面区域 --> |
| 11 | - <van-image class="w-full aspect-video object-cover" :src="course?.coverImage" :alt="course?.title" /> | 11 | + <van-image class="w-full aspect-video object-cover" :src="course?.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" :alt="course?.title" /> |
| 12 | <div class="p-4"> | 12 | <div class="p-4"> |
| 13 | <h1 class="text-black text-xl font-bold mb-2">{{ course?.title }}</h1> | 13 | <h1 class="text-black text-xl font-bold mb-2">{{ course?.title }}</h1> |
| 14 | <div class="flex items-center text-gray-500 text-sm"> | 14 | <div class="flex items-center text-gray-500 text-sm"> |
| 15 | - <span>已更新 20期</span> | 15 | + <span>已更新 没有字段 期</span> |
| 16 | <span class="mx-2">|</span> | 16 | <span class="mx-2">|</span> |
| 17 | - <span>116人订阅</span> | 17 | + <span>没有字段 人订阅</span> |
| 18 | </div> | 18 | </div> |
| 19 | </div> | 19 | </div> |
| 20 | 20 | ||
| ... | @@ -38,32 +38,34 @@ | ... | @@ -38,32 +38,34 @@ |
| 38 | :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }"> | 38 | :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }"> |
| 39 | <!-- 详情区域 --> | 39 | <!-- 详情区域 --> |
| 40 | <div id="detail" class="py-4 px-4"> | 40 | <div id="detail" class="py-4 px-4"> |
| 41 | - <div class="text-gray-700 text-sm leading-relaxed" v-html="course?.description"></div> | 41 | + <div v-if="course?.feature" class="text-gray-700 text-sm leading-relaxed" v-html="course?.feature"></div> |
| 42 | - <van-empty description="暂无详情" /> | 42 | + <div v-if="course?.highlights" class="text-gray-700 text-sm leading-relaxed" v-html="course?.highlights"></div> |
| 43 | + <van-empty v-else description="暂无详情" /> | ||
| 43 | </div> | 44 | </div> |
| 44 | 45 | ||
| 45 | <div class="h-2 bg-gray-100"></div> | 46 | <div class="h-2 bg-gray-100"></div> |
| 46 | 47 | ||
| 47 | <!-- 目录区域 --> | 48 | <!-- 目录区域 --> |
| 48 | <div id="catalog" class="py-4"> | 49 | <div id="catalog" class="py-4"> |
| 49 | - <div class="space-y-4"> | 50 | + <div v-if="course_lessons.length" class="space-y-4"> |
| 50 | - <div v-for="(lesson, index) in course?.lessons" :key="index" | 51 | + <div v-for="(lesson, index) in course_lessons" :key="index" |
| 51 | @click="router.push(`/studyDetail/${lesson.id}`)" | 52 | @click="router.push(`/studyDetail/${lesson.id}`)" |
| 52 | class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"> | 53 | class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"> |
| 53 | <div v-if="lesson.progress > 0 && lesson.progress < 100" | 54 | <div v-if="lesson.progress > 0 && lesson.progress < 100" |
| 54 | class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded"> | 55 | class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded"> |
| 55 | - 上次看到</div> | 56 | + 没有字段上次看到</div> |
| 56 | <div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div> | 57 | <div class="text-black text-base font-medium mb-2">{{ lesson.title }}</div> |
| 57 | <div class="flex items-center text-sm text-gray-500"> | 58 | <div class="flex items-center text-sm text-gray-500"> |
| 58 | - <span>视频</span> | 59 | + <span>{{ course_type_maps[lesson.course_type] }}</span> |
| 59 | - <span>2024-10-22</span> | 60 | + <span>{{ dayjs(course.schedule_time).format('YYYY-MM-DD') }}</span> |
| 60 | <span class="mx-2">|</span> | 61 | <span class="mx-2">|</span> |
| 61 | - <span>1897次学习</span> | 62 | + <span>没有字段 次学习</span> |
| 62 | <span class="mx-2">|</span> | 63 | <span class="mx-2">|</span> |
| 63 | - <span>已学习{{ lesson.progress }}%</span> | 64 | + <span>没有字段 已学习{{ lesson?.progress }}%</span> |
| 64 | </div> | 65 | </div> |
| 65 | </div> | 66 | </div> |
| 66 | </div> | 67 | </div> |
| 68 | + <van-empty v-else description="暂无目录" /> | ||
| 67 | </div> | 69 | </div> |
| 68 | 70 | ||
| 69 | <div class="h-2 bg-gray-100"></div> | 71 | <div class="h-2 bg-gray-100"></div> |
| ... | @@ -94,6 +96,10 @@ | ... | @@ -94,6 +96,10 @@ |
| 94 | import { ref, onMounted, nextTick, onUnmounted } from 'vue'; | 96 | import { ref, onMounted, nextTick, onUnmounted } from 'vue'; |
| 95 | import { useTitle } from '@vueuse/core'; | 97 | import { useTitle } from '@vueuse/core'; |
| 96 | import { useRouter } from "vue-router"; | 98 | import { useRouter } from "vue-router"; |
| 99 | +import dayjs from 'dayjs'; | ||
| 100 | + | ||
| 101 | +// 导入接口 | ||
| 102 | +import { getCourseDetailAPI } from '@/api/course' | ||
| 97 | 103 | ||
| 98 | const router = useRouter(); | 104 | const router = useRouter(); |
| 99 | 105 | ||
| ... | @@ -103,6 +109,7 @@ useTitle('课程详情'); | ... | @@ -103,6 +109,7 @@ useTitle('课程详情'); |
| 103 | // 当前激活的标签页 | 109 | // 当前激活的标签页 |
| 104 | const activeTab = ref('detail'); | 110 | const activeTab = ref('detail'); |
| 105 | const topWrapperHeight = ref(0); | 111 | const topWrapperHeight = ref(0); |
| 112 | +const resizeObserver = ref(null); | ||
| 106 | 113 | ||
| 107 | // 计算topWrapperHeight的函数 | 114 | // 计算topWrapperHeight的函数 |
| 108 | const updateTopWrapperHeight = () => { | 115 | const updateTopWrapperHeight = () => { |
| ... | @@ -110,21 +117,38 @@ const updateTopWrapperHeight = () => { | ... | @@ -110,21 +117,38 @@ const updateTopWrapperHeight = () => { |
| 110 | const topWrapper = document.querySelector('.top-wrapper'); | 117 | const topWrapper = document.querySelector('.top-wrapper'); |
| 111 | if (topWrapper) { | 118 | if (topWrapper) { |
| 112 | // 使用 ResizeObserver 监听元素尺寸变化 | 119 | // 使用 ResizeObserver 监听元素尺寸变化 |
| 113 | - const resizeObserver = new ResizeObserver(() => { | 120 | + resizeObserver.value = new ResizeObserver(() => { |
| 114 | topWrapperHeight.value = `${topWrapper.offsetHeight}px`; | 121 | topWrapperHeight.value = `${topWrapper.offsetHeight}px`; |
| 115 | }); | 122 | }); |
| 116 | - resizeObserver.observe(topWrapper); | 123 | + resizeObserver.value.observe(topWrapper); |
| 117 | - | ||
| 118 | - // 组件卸载时取消监听 | ||
| 119 | - onUnmounted(() => { | ||
| 120 | - resizeObserver.disconnect(); | ||
| 121 | - }); | ||
| 122 | } | 124 | } |
| 123 | }); | 125 | }); |
| 124 | }; | 126 | }; |
| 125 | 127 | ||
| 126 | -// 初始化时计算topWrapperHeight | 128 | +const course = ref([]); |
| 127 | -onMounted(() => { | 129 | +const course_lessons = ref([]); |
| 130 | +const course_type_maps = ref({ | ||
| 131 | + video: '视频', | ||
| 132 | + audio: '录播课', | ||
| 133 | + image: '图片', | ||
| 134 | + file: '文件', | ||
| 135 | +}) | ||
| 136 | + | ||
| 137 | +onMounted(async () => { | ||
| 138 | + /** | ||
| 139 | + * 组件挂载时获取课程详情 | ||
| 140 | + */ | ||
| 141 | + // 获取课程ID | ||
| 142 | + const courseId = router.currentRoute.value.params.id; | ||
| 143 | + // 调用接口获取课程详情 | ||
| 144 | + const { code, data } = await getCourseDetailAPI({ i: courseId }); | ||
| 145 | + if (code) { | ||
| 146 | + course.value = data; | ||
| 147 | + course_lessons.value = data.schedule || []; | ||
| 148 | + } | ||
| 149 | + /** | ||
| 150 | + * 初始化时计算topWrapperHeight | ||
| 151 | + */ | ||
| 128 | // 添加滚动监听 | 152 | // 添加滚动监听 |
| 129 | window.addEventListener('scroll', handleScroll); | 153 | window.addEventListener('scroll', handleScroll); |
| 130 | // 添加窗口大小变化监听 | 154 | // 添加窗口大小变化监听 |
| ... | @@ -137,6 +161,8 @@ onMounted(() => { | ... | @@ -137,6 +161,8 @@ onMounted(() => { |
| 137 | onUnmounted(() => { | 161 | onUnmounted(() => { |
| 138 | window.removeEventListener('scroll', handleScroll); | 162 | window.removeEventListener('scroll', handleScroll); |
| 139 | window.removeEventListener('resize', updateTopWrapperHeight); | 163 | window.removeEventListener('resize', updateTopWrapperHeight); |
| 164 | + // 组件卸载时取消监听 | ||
| 165 | + resizeObserver.value.disconnect(); | ||
| 140 | }); | 166 | }); |
| 141 | 167 | ||
| 142 | // 处理滚动事件 | 168 | // 处理滚动事件 |
| ... | @@ -175,30 +201,30 @@ const handleTabChange = (name) => { | ... | @@ -175,30 +201,30 @@ const handleTabChange = (name) => { |
| 175 | }; | 201 | }; |
| 176 | 202 | ||
| 177 | // 课程数据 | 203 | // 课程数据 |
| 178 | -const course = ref({ | 204 | +// const course = ref({ |
| 179 | - title: '开学礼·止的智慧·心法老师·20241001', | 205 | +// title: '开学礼·止的智慧·心法老师·20241001', |
| 180 | - coverImage: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg', | 206 | +// coverImage: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg', |
| 181 | - updateTime: '2024.01.17', | 207 | +// updateTime: '2024.01.17', |
| 182 | - viewCount: 1897, | 208 | +// viewCount: 1897, |
| 183 | - description: '这是一门关于心法的课程,帮助学员掌握止的智慧...', | 209 | +// description: '这是一门关于心法的课程,帮助学员掌握止的智慧...', |
| 184 | - lessons: [ | 210 | +// lessons: [ |
| 185 | - { | 211 | +// { |
| 186 | - title: '第一课:止的基础', | 212 | +// title: '第一课:止的基础', |
| 187 | - duration: '45分钟', | 213 | +// duration: '45分钟', |
| 188 | - progress: 100 | 214 | +// progress: 100 |
| 189 | - }, | 215 | +// }, |
| 190 | - { | 216 | +// { |
| 191 | - title: '第二课:止的技巧', | 217 | +// title: '第二课:止的技巧', |
| 192 | - duration: '50分钟', | 218 | +// duration: '50分钟', |
| 193 | - progress: 60 | 219 | +// progress: 60 |
| 194 | - }, | 220 | +// }, |
| 195 | - { | 221 | +// { |
| 196 | - title: '第三课:止的应用', | 222 | +// title: '第三课:止的应用', |
| 197 | - duration: '40分钟', | 223 | +// duration: '40分钟', |
| 198 | - progress: 0 | 224 | +// progress: 0 |
| 199 | - } | 225 | +// } |
| 200 | - ] | 226 | +// ] |
| 201 | -}); | 227 | +// }); |
| 202 | </script> | 228 | </script> |
| 203 | 229 | ||
| 204 | <style scoped> | 230 | <style scoped> | ... | ... |
| ... | @@ -8,7 +8,7 @@ | ... | @@ -8,7 +8,7 @@ |
| 8 | <!-- 固定区域:视频播放和标签页 --> | 8 | <!-- 固定区域:视频播放和标签页 --> |
| 9 | <div class="fixed top-0 left-0 right-0 z-10 top-wrapper"> | 9 | <div class="fixed top-0 left-0 right-0 z-10 top-wrapper"> |
| 10 | <!-- 视频播放区域 --> | 10 | <!-- 视频播放区域 --> |
| 11 | - <div v-if="course.type === 'video'" class="w-full bg-black relative"> | 11 | + <div v-if="course.course_type === 'video'" class="w-full bg-black relative"> |
| 12 | <!-- 视频封面和播放按钮 --> | 12 | <!-- 视频封面和播放按钮 --> |
| 13 | <div v-if="!isPlaying" class="relative w-full" style="aspect-ratio: 16/9;"> | 13 | <div v-if="!isPlaying" class="relative w-full" style="aspect-ratio: 16/9;"> |
| 14 | <img :src="course.cover" :alt="course.title" class="w-full h-full object-cover" /> | 14 | <img :src="course.cover" :alt="course.title" class="w-full h-full object-cover" /> |
| ... | @@ -22,10 +22,10 @@ | ... | @@ -22,10 +22,10 @@ |
| 22 | </div> | 22 | </div> |
| 23 | </div> | 23 | </div> |
| 24 | <!-- 视频播放器 --> | 24 | <!-- 视频播放器 --> |
| 25 | - <VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="course.videoUrl" :autoplay="false" | 25 | + <VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="course.file" :autoplay="false" |
| 26 | @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> | 26 | @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> |
| 27 | </div> | 27 | </div> |
| 28 | - <div v-if="course.type === 'audio'" class="w-full relative" style="border-bottom: 1px solid #F3F4F6;"> | 28 | + <div v-if="course.course_type === 'audio'" class="w-full relative" style="border-bottom: 1px solid #F3F4F6;"> |
| 29 | <!-- 音频播放器 --> | 29 | <!-- 音频播放器 --> |
| 30 | <AudioPlayer :songs="audioList" /> | 30 | <AudioPlayer :songs="audioList" /> |
| 31 | </div> | 31 | </div> |
| ... | @@ -47,9 +47,9 @@ | ... | @@ -47,9 +47,9 @@ |
| 47 | <div id="intro" class="py-4 px-4"> | 47 | <div id="intro" class="py-4 px-4"> |
| 48 | <h1 class="text-lg font-bold mb-2">{{ course.title }}</h1> | 48 | <h1 class="text-lg font-bold mb-2">{{ course.title }}</h1> |
| 49 | <div class="text-gray-500 text-sm flex items-center gap-2"> | 49 | <div class="text-gray-500 text-sm flex items-center gap-2"> |
| 50 | - <span>{{ course.date }}</span> | 50 | + <span>{{ dayjs(course.schedule_time).format('YYYY-MM-DD') }}</span> |
| 51 | <span class="text-gray-300">|</span> | 51 | <span class="text-gray-300">|</span> |
| 52 | - <span>{{ course.studyCount || 0 }}次学习</span> | 52 | + <span>没有字段{{ course.studyCount || 0 }}次学习</span> |
| 53 | </div> | 53 | </div> |
| 54 | </div> | 54 | </div> |
| 55 | 55 | ||
| ... | @@ -160,6 +160,10 @@ import { useRoute } from 'vue-router'; | ... | @@ -160,6 +160,10 @@ import { useRoute } from 'vue-router'; |
| 160 | import { useTitle } from '@vueuse/core'; | 160 | import { useTitle } from '@vueuse/core'; |
| 161 | import VideoPlayer from '@/components/ui/VideoPlayer.vue'; | 161 | import VideoPlayer from '@/components/ui/VideoPlayer.vue'; |
| 162 | import AudioPlayer from '@/components/ui/AudioPlayer.vue'; | 162 | import AudioPlayer from '@/components/ui/AudioPlayer.vue'; |
| 163 | +import dayjs from 'dayjs'; | ||
| 164 | + | ||
| 165 | +// 导入接口 | ||
| 166 | +import { getScheduleCourseAPI } from '@/api/course'; | ||
| 163 | 167 | ||
| 164 | const route = useRoute(); | 168 | const route = useRoute(); |
| 165 | const course = ref(null); | 169 | const course = ref(null); |
| ... | @@ -329,7 +333,9 @@ const handleScroll = () => { | ... | @@ -329,7 +333,9 @@ const handleScroll = () => { |
| 329 | } | 333 | } |
| 330 | }; | 334 | }; |
| 331 | 335 | ||
| 332 | -onMounted(() => { | 336 | +onMounted(async () => { |
| 337 | + // 延迟设置topWrapper和bottomWrapper的高度 | ||
| 338 | + setTimeout(() => { | ||
| 333 | nextTick(() => { | 339 | nextTick(() => { |
| 334 | const topWrapper = document.querySelector('.top-wrapper'); | 340 | const topWrapper = document.querySelector('.top-wrapper'); |
| 335 | const bottomWrapper = document.querySelector('.bottom-wrapper'); | 341 | const bottomWrapper = document.querySelector('.bottom-wrapper'); |
| ... | @@ -343,29 +349,19 @@ onMounted(() => { | ... | @@ -343,29 +349,19 @@ onMounted(() => { |
| 343 | // 添加滚动监听 | 349 | // 添加滚动监听 |
| 344 | window.addEventListener('scroll', handleScroll); | 350 | window.addEventListener('scroll', handleScroll); |
| 345 | }) | 351 | }) |
| 352 | + }, 500); | ||
| 353 | + | ||
| 346 | const courseId = route.params.id; | 354 | const courseId = route.params.id; |
| 347 | if (courseId) { | 355 | if (courseId) { |
| 348 | - // TODO: 这里需要替换为实际的API调用 | 356 | + const { code, data } = await getScheduleCourseAPI({ i: courseId }); |
| 349 | - // 临时使用模拟数据 | 357 | + if (code) { |
| 350 | - course.value = { | 358 | + course.value = data; |
| 351 | - id: courseId, | 359 | + // TODO: 测试数据 |
| 352 | - title: '开学礼·止的智慧·心法老师·20241001(上)', | 360 | + course.value.cover = 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'; |
| 353 | - videoUrl: 'https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/mp4/xgplayer-demo-360p.mp4', | 361 | + // 音频列表处理 |
| 354 | - progress: 35, | 362 | + // if (data.course_type === 'audio') { |
| 355 | - studyTime: 3600, | 363 | + // audioList.value = [course.value.file]; |
| 356 | - type: '视频课程', | 364 | + // } |
| 357 | - studyCount: 1896, | ||
| 358 | - cover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg', | ||
| 359 | - date: '2024-12-04', | ||
| 360 | - type: 'video', | ||
| 361 | - }; | ||
| 362 | - | ||
| 363 | - // TODO: 模拟数据音频和视频显示 | ||
| 364 | - console.warn(courseId); | ||
| 365 | - if (courseId == '2') { | ||
| 366 | - course.value.type = 'audio' | ||
| 367 | - } else { | ||
| 368 | - course.value.type = 'video' | ||
| 369 | } | 365 | } |
| 370 | } | 366 | } |
| 371 | }) | 367 | }) | ... | ... |
-
Please register or login to post a comment