feat(课程): 添加课程打卡功能并重构路由结构
重构课程详情页路由为/profile/studyCourse/:id路径 在课程详情页和我的课程页添加打卡功能及相关弹窗 调整AppLayout组件支持无标题模式
Showing
6 changed files
with
204 additions
and
10 deletions
| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | <template> | 2 | <template> |
| 3 | <div class="app-layout"> | 3 | <div class="app-layout"> |
| 4 | <!-- Header --> | 4 | <!-- Header --> |
| 5 | - <header class="app-header"> | 5 | + <header class="app-header" v-if="hasTitle"> |
| 6 | <div v-if="showBack" class="header-back" @click="goBack"> | 6 | <div v-if="showBack" class="header-back" @click="goBack"> |
| 7 | <van-icon name="arrow-left" size="20" /> | 7 | <van-icon name="arrow-left" size="20" /> |
| 8 | </div> | 8 | </div> |
| ... | @@ -13,7 +13,7 @@ | ... | @@ -13,7 +13,7 @@ |
| 13 | </header> | 13 | </header> |
| 14 | 14 | ||
| 15 | <!-- Main Content --> | 15 | <!-- Main Content --> |
| 16 | - <main class="app-content" :class="{ 'has-bottom-nav': showBottomNav }"> | 16 | + <main class="app-content" :class="{ 'has-bottom-nav': showBottomNav, 'no-header': !hasTitle }"> |
| 17 | <slot></slot> | 17 | <slot></slot> |
| 18 | </main> | 18 | </main> |
| 19 | 19 | ||
| ... | @@ -29,7 +29,7 @@ | ... | @@ -29,7 +29,7 @@ |
| 29 | </template> | 29 | </template> |
| 30 | 30 | ||
| 31 | <script> | 31 | <script> |
| 32 | -import { ref, onMounted } from 'vue' | 32 | +import { ref, onMounted, computed } from 'vue' |
| 33 | import { useRouter, useRoute } from 'vue-router' | 33 | import { useRouter, useRoute } from 'vue-router' |
| 34 | 34 | ||
| 35 | export default { | 35 | export default { |
| ... | @@ -46,7 +46,11 @@ export default { | ... | @@ -46,7 +46,11 @@ export default { |
| 46 | showBottomNav: { | 46 | showBottomNav: { |
| 47 | type: Boolean, | 47 | type: Boolean, |
| 48 | default: true | 48 | default: true |
| 49 | - } | 49 | + }, |
| 50 | + hasTitle: { | ||
| 51 | + type: Boolean, | ||
| 52 | + default: true | ||
| 53 | + }, | ||
| 50 | }, | 54 | }, |
| 51 | setup(props) { | 55 | setup(props) { |
| 52 | const router = useRouter() | 56 | const router = useRouter() |
| ... | @@ -61,7 +65,7 @@ export default { | ... | @@ -61,7 +65,7 @@ export default { |
| 61 | } | 65 | } |
| 62 | 66 | ||
| 63 | return { | 67 | return { |
| 64 | - goBack | 68 | + goBack, |
| 65 | } | 69 | } |
| 66 | } | 70 | } |
| 67 | } | 71 | } |
| ... | @@ -119,4 +123,8 @@ export default { | ... | @@ -119,4 +123,8 @@ export default { |
| 119 | .app-content.has-bottom-nav { | 123 | .app-content.has-bottom-nav { |
| 120 | padding-bottom: 50px; | 124 | padding-bottom: 50px; |
| 121 | } | 125 | } |
| 126 | + | ||
| 127 | +.app-content.no-header { | ||
| 128 | + padding-top: 0; | ||
| 129 | +} | ||
| 122 | </style> | 130 | </style> | ... | ... |
| ... | @@ -220,8 +220,8 @@ export const routes = [ | ... | @@ -220,8 +220,8 @@ export const routes = [ |
| 220 | } | 220 | } |
| 221 | }, | 221 | }, |
| 222 | { | 222 | { |
| 223 | - path: '/studyCourse/:id', | 223 | + path: '/profile/studyCourse/:id', |
| 224 | - component: () => import('@/views/study/studyCoursePage.vue'), | 224 | + component: () => import('@/views/profile/StudyCoursePage.vue'), |
| 225 | meta: { | 225 | meta: { |
| 226 | title: '课程集合页面', | 226 | title: '课程集合页面', |
| 227 | } | 227 | } | ... | ... |
| ... | @@ -94,6 +94,24 @@ | ... | @@ -94,6 +94,24 @@ |
| 94 | </div> | 94 | </div> |
| 95 | </div> | 95 | </div> |
| 96 | 96 | ||
| 97 | + <div v-if="activeTab === '打卡互动'"> | ||
| 98 | + <!-- 打卡区域 --> | ||
| 99 | + <div class="py-4"> | ||
| 100 | + <div class="bg-white rounded-lg p-4 mb-4 cursor-pointer"> | ||
| 101 | + <div class="flex items-center justify-between" @click="goToCheckin()"> | ||
| 102 | + <div class="flex items-center gap-3"> | ||
| 103 | + <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" /> | ||
| 104 | + <div> | ||
| 105 | + <div class="text-base font-medium">打卡</div> | ||
| 106 | + <div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div> | ||
| 107 | + </div> | ||
| 108 | + </div> | ||
| 109 | + <van-icon name="arrow" class="text-gray-400" /> | ||
| 110 | + </div> | ||
| 111 | + </div> | ||
| 112 | + </div> | ||
| 113 | + </div> | ||
| 114 | + | ||
| 97 | <!-- <div v-if="activeTab === '课程亮点'"> | 115 | <!-- <div v-if="activeTab === '课程亮点'"> |
| 98 | <div class="space-y-3 text-gray-700"> | 116 | <div class="space-y-3 text-gray-700"> |
| 99 | <div v-html="course?.highlights"></div> | 117 | <div v-html="course?.highlights"></div> |
| ... | @@ -224,7 +242,7 @@ | ... | @@ -224,7 +242,7 @@ |
| 224 | color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md"> | 242 | color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md"> |
| 225 | {{ course?.price !== '0.00' ? '立即' : '免费' }}购买 | 243 | {{ course?.price !== '0.00' ? '立即' : '免费' }}购买 |
| 226 | </van-button> | 244 | </van-button> |
| 227 | - <van-button v-else @click="router.push(`/studyCourse/${course?.id}`)" round block | 245 | + <van-button v-else @click="router.push(`/profile/studyCourse/${course?.id}`)" round block |
| 228 | color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md"> | 246 | color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md"> |
| 229 | 查看课程 | 247 | 查看课程 |
| 230 | </van-button> | 248 | </van-button> |
| ... | @@ -234,6 +252,71 @@ | ... | @@ -234,6 +252,71 @@ |
| 234 | 252 | ||
| 235 | <!-- Review Popup --> | 253 | <!-- Review Popup --> |
| 236 | <ReviewPopup v-model:show="showReviewPopup" title="立即评价" @submit="handleReviewSubmit" /> | 254 | <ReviewPopup v-model:show="showReviewPopup" title="立即评价" @submit="handleReviewSubmit" /> |
| 255 | + | ||
| 256 | + <!-- 打卡弹窗 --> | ||
| 257 | + <van-popup | ||
| 258 | + v-model:show="showCheckInDialog" | ||
| 259 | + round | ||
| 260 | + position="bottom" | ||
| 261 | + @close="closeCheckInDialog" | ||
| 262 | + :style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }" | ||
| 263 | + > | ||
| 264 | + <div class="p-4"> | ||
| 265 | + <div class="flex justify-between items-center mb-3"> | ||
| 266 | + <h3 class="font-medium"> | ||
| 267 | + <span :class="{ 'text-green-500' : showTaskList }" @click="toggleTask('today')">今日打卡</span> | ||
| 268 | + <span :class="{ 'text-green-500' : showTimeoutTaskList }" @click="toggleTask('timeout')">历史打卡</span> | ||
| 269 | + </h3> | ||
| 270 | + <van-icon name="cross" @click="showCheckInDialog = false" /> | ||
| 271 | + </div> | ||
| 272 | + | ||
| 273 | + <div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center"> | ||
| 274 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-500 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 275 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||
| 276 | + </svg> | ||
| 277 | + <h4 class="text-green-700 font-medium mb-1">打卡成功!</h4> | ||
| 278 | + </div> | ||
| 279 | + <template v-else> | ||
| 280 | + <div class="grid grid-cols-2 gap-4 py-2"> | ||
| 281 | + <button | ||
| 282 | + v-for="checkInType in default_list" | ||
| 283 | + :key="checkInType.id" | ||
| 284 | + class="flex flex-col items-center p-2 rounded-lg border transition-colors | ||
| 285 | + bg-white/70 border-gray-100 hover:bg-white" | ||
| 286 | + :class="{ | ||
| 287 | + 'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id | ||
| 288 | + }" | ||
| 289 | + @click="handleCheckInSelect(checkInType)" | ||
| 290 | + > | ||
| 291 | + <div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors | ||
| 292 | + bg-gray-100 text-gray-500" | ||
| 293 | + :class="{ | ||
| 294 | + 'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id | ||
| 295 | + }" | ||
| 296 | + > | ||
| 297 | + <van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" /> | ||
| 298 | + <van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" /> | ||
| 299 | + </div> | ||
| 300 | + <span class="text-xs">{{ checkInType.title }}</span> | ||
| 301 | + </button> | ||
| 302 | + </div> | ||
| 303 | + | ||
| 304 | + <div v-if="selectedCheckIn" class="mt-3"> | ||
| 305 | + <button | ||
| 306 | + class="mt-2 w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-2 rounded-lg flex items-center justify-center" | ||
| 307 | + @click="handleCheckInSubmit" | ||
| 308 | + :disabled="isCheckingIn" | ||
| 309 | + > | ||
| 310 | + <template v-if="isCheckingIn"> | ||
| 311 | + <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div> | ||
| 312 | + 提交中... | ||
| 313 | + </template> | ||
| 314 | + <template v-else>提交打卡</template> | ||
| 315 | + </button> | ||
| 316 | + </div> | ||
| 317 | + </template> | ||
| 318 | + </div> | ||
| 319 | + </van-popup> | ||
| 237 | </AppLayout> | 320 | </AppLayout> |
| 238 | </template> | 321 | </template> |
| 239 | 322 | ||
| ... | @@ -252,6 +335,7 @@ import FrostedGlass from '@/components/ui/FrostedGlass.vue' | ... | @@ -252,6 +335,7 @@ import FrostedGlass from '@/components/ui/FrostedGlass.vue' |
| 252 | // 导入接口 | 335 | // 导入接口 |
| 253 | import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course"; | 336 | import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course"; |
| 254 | import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite"; | 337 | import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite"; |
| 338 | +import { checkinTaskAPI } from '@/api/checkin'; | ||
| 255 | 339 | ||
| 256 | const $route = useRoute(); | 340 | const $route = useRoute(); |
| 257 | const $router = useRouter(); | 341 | const $router = useRouter(); |
| ... | @@ -272,6 +356,17 @@ const isPurchased = ref(false) | ... | @@ -272,6 +356,17 @@ const isPurchased = ref(false) |
| 272 | const isReviewed = ref(false) | 356 | const isReviewed = ref(false) |
| 273 | const showReviewPopup = ref(false) | 357 | const showReviewPopup = ref(false) |
| 274 | 358 | ||
| 359 | +// 打卡相关状态 | ||
| 360 | +const task_list = ref([]) | ||
| 361 | +const timeout_task_list = ref([]) | ||
| 362 | +const default_list = ref([]) | ||
| 363 | +const showTaskList = ref(true) | ||
| 364 | +const showTimeoutTaskList = ref(false) | ||
| 365 | +const showCheckInDialog = ref(false) | ||
| 366 | +const selectedCheckIn = ref(null) | ||
| 367 | +const isCheckingIn = ref(false) | ||
| 368 | +const checkInSuccess = ref(false) | ||
| 369 | + | ||
| 275 | const { addToCart, proceedToCheckout } = useCart() | 370 | const { addToCart, proceedToCheckout } = useCart() |
| 276 | 371 | ||
| 277 | // Handle favorite toggle | 372 | // Handle favorite toggle |
| ... | @@ -306,6 +401,7 @@ const curriculumItems = computed(() => { | ... | @@ -306,6 +401,7 @@ const curriculumItems = computed(() => { |
| 306 | { title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) }, | 401 | { title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) }, |
| 307 | // { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights }, | 402 | // { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights }, |
| 308 | // { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal }, | 403 | // { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal }, |
| 404 | + { title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy }, | ||
| 309 | ].filter(item => item.show); | 405 | ].filter(item => item.show); |
| 310 | }); | 406 | }); |
| 311 | 407 | ||
| ... | @@ -449,6 +545,93 @@ const goToStudyDetail = (item) => { | ... | @@ -449,6 +545,93 @@ const goToStudyDetail = (item) => { |
| 449 | // 跳转详情 | 545 | // 跳转详情 |
| 450 | router.push(`/studyDetail/${item.id}`) | 546 | router.push(`/studyDetail/${item.id}`) |
| 451 | } | 547 | } |
| 548 | + | ||
| 549 | +// 打卡相关方法 | ||
| 550 | +/** | ||
| 551 | + * 处理打卡选择 | ||
| 552 | + * @param {Object} type - 打卡类型对象 | ||
| 553 | + */ | ||
| 554 | +const handleCheckInSelect = (type) => { | ||
| 555 | + if (type.is_gray && type.task_type === 'checkin') { | ||
| 556 | + showToast('您已经完成了今天的打卡'); | ||
| 557 | + return; | ||
| 558 | + } | ||
| 559 | + if (type.task_type === 'upload') { | ||
| 560 | + router.push({ | ||
| 561 | + path: '/checkin/index', | ||
| 562 | + query: { | ||
| 563 | + id: type.id | ||
| 564 | + } | ||
| 565 | + }); | ||
| 566 | + showCheckInDialog.value = false; | ||
| 567 | + return; | ||
| 568 | + } else { | ||
| 569 | + selectedCheckIn.value = type; | ||
| 570 | + } | ||
| 571 | +}; | ||
| 572 | + | ||
| 573 | +/** | ||
| 574 | + * 处理打卡提交 | ||
| 575 | + */ | ||
| 576 | +const handleCheckInSubmit = async () => { | ||
| 577 | + if (!selectedCheckIn.value) { | ||
| 578 | + showToast('请选择打卡项目'); | ||
| 579 | + return; | ||
| 580 | + } | ||
| 581 | + | ||
| 582 | + isCheckingIn.value = true; | ||
| 583 | + try { | ||
| 584 | + const { code } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id }); | ||
| 585 | + if (code) { | ||
| 586 | + checkInSuccess.value = true; | ||
| 587 | + // 重置表单 | ||
| 588 | + setTimeout(() => { | ||
| 589 | + checkInSuccess.value = false; | ||
| 590 | + selectedCheckIn.value = null; | ||
| 591 | + showCheckInDialog.value = false; | ||
| 592 | + }, 1500); | ||
| 593 | + } | ||
| 594 | + } catch (error) { | ||
| 595 | + console.error('打卡失败:', error); | ||
| 596 | + showToast('打卡失败,请重试'); | ||
| 597 | + } finally { | ||
| 598 | + isCheckingIn.value = false; | ||
| 599 | + } | ||
| 600 | +}; | ||
| 601 | + | ||
| 602 | +/** | ||
| 603 | + * 打开打卡弹窗 | ||
| 604 | + */ | ||
| 605 | +const goToCheckin = () => { | ||
| 606 | + if(!default_list.value.length) { | ||
| 607 | + showToast('暂无打卡任务'); | ||
| 608 | + return; | ||
| 609 | + } | ||
| 610 | + showCheckInDialog.value = true; | ||
| 611 | +}; | ||
| 612 | + | ||
| 613 | +/** | ||
| 614 | + * 切换打卡任务类型 | ||
| 615 | + * @param {string} type - 任务类型 ('today' | 'timeout') | ||
| 616 | + */ | ||
| 617 | +const toggleTask = (type) => { | ||
| 618 | + if(type === 'today') { | ||
| 619 | + showTaskList.value = true; | ||
| 620 | + showTimeoutTaskList.value = false; | ||
| 621 | + default_list.value = task_list.value; | ||
| 622 | + } else { | ||
| 623 | + showTaskList.value = false; | ||
| 624 | + showTimeoutTaskList.value = true; | ||
| 625 | + default_list.value = timeout_task_list.value; | ||
| 626 | + } | ||
| 627 | +} | ||
| 628 | + | ||
| 629 | +/** | ||
| 630 | + * 关闭打卡弹窗 | ||
| 631 | + */ | ||
| 632 | +const closeCheckInDialog = () => { | ||
| 633 | + showCheckInDialog.value = false; | ||
| 634 | +} | ||
| 452 | </script> | 635 | </script> |
| 453 | 636 | ||
| 454 | <style scoped> | 637 | <style scoped> | ... | ... |
| ... | @@ -16,7 +16,7 @@ | ... | @@ -16,7 +16,7 @@ |
| 16 | @load="onLoad" | 16 | @load="onLoad" |
| 17 | class="px-4 py-3 space-y-4" | 17 | class="px-4 py-3 space-y-4" |
| 18 | > | 18 | > |
| 19 | - <CourseCard v-for="course in courses" :key="course.good_id" :course="course" :linkTo="`/studyCourse/${course.good_id}`" /> | 19 | + <CourseCard v-for="course in courses" :key="course.good_id" :course="course" :linkTo="`/profile/studyCourse/${course.good_id}`" /> |
| 20 | </van-list> | 20 | </van-list> |
| 21 | 21 | ||
| 22 | <!-- 无数据提示 --> | 22 | <!-- 无数据提示 --> | ... | ... |
| ... | @@ -159,6 +159,6 @@ const handleImageError = (e) => { | ... | @@ -159,6 +159,6 @@ const handleImageError = (e) => { |
| 159 | 159 | ||
| 160 | // 跳转到课程详情页 | 160 | // 跳转到课程详情页 |
| 161 | const handleClick = (record) => { | 161 | const handleClick = (record) => { |
| 162 | - $router.push(`/studyCourse/${record.id}`); | 162 | + $router.push(`/profile/studyCourse/${record.id}`); |
| 163 | }; | 163 | }; |
| 164 | </script> | 164 | </script> | ... | ... |
| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | * @Description: 课程详情页面 | 3 | * @Description: 课程详情页面 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | +<AppLayout :has-title="false" active-tab="profile"> | ||
| 6 | <div class="study-course-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen"> | 7 | <div class="study-course-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen"> |
| 7 | <div v-if="course" class="flex flex-col h-screen"> | 8 | <div v-if="course" class="flex flex-col h-screen"> |
| 8 | <!-- 固定区域:课程封面和标签页 --> | 9 | <!-- 固定区域:课程封面和标签页 --> |
| ... | @@ -180,6 +181,7 @@ | ... | @@ -180,6 +181,7 @@ |
| 180 | </div> | 181 | </div> |
| 181 | </van-popup> | 182 | </van-popup> |
| 182 | </div> | 183 | </div> |
| 184 | + </AppLayout> | ||
| 183 | </template> | 185 | </template> |
| 184 | 186 | ||
| 185 | <script setup> | 187 | <script setup> |
| ... | @@ -188,6 +190,7 @@ import { useTitle } from '@vueuse/core'; | ... | @@ -188,6 +190,7 @@ import { useTitle } from '@vueuse/core'; |
| 188 | import { useRouter } from "vue-router"; | 190 | import { useRouter } from "vue-router"; |
| 189 | import dayjs from 'dayjs'; | 191 | import dayjs from 'dayjs'; |
| 190 | import { showToast } from 'vant'; | 192 | import { showToast } from 'vant'; |
| 193 | +import AppLayout from '@/components/layout/AppLayout.vue'; | ||
| 191 | 194 | ||
| 192 | // 导入接口 | 195 | // 导入接口 |
| 193 | import { getCourseDetailAPI } from '@/api/course'; | 196 | import { getCourseDetailAPI } from '@/api/course'; | ... | ... |
-
Please register or login to post a comment