hookehuyr

feat(课程): 添加课程打卡功能并重构路由结构

重构课程详情页路由为/profile/studyCourse/:id路径
在课程详情页和我的课程页添加打卡功能及相关弹窗
调整AppLayout组件支持无标题模式
...@@ -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>&nbsp;&nbsp;&nbsp;&nbsp;
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';
......