hookehuyr

feat(打卡功能): 在课程详情页和课程学习页添加打卡弹窗功能

添加今日打卡和历史打卡切换功能,支持任务选择和提交打卡
...@@ -120,11 +120,15 @@ ...@@ -120,11 +120,15 @@
120 v-model:show="showCheckInDialog" 120 v-model:show="showCheckInDialog"
121 round 121 round
122 position="bottom" 122 position="bottom"
123 + @close="closeCheckInDialog"
123 :style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }" 124 :style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }"
124 > 125 >
125 <div class="p-4"> 126 <div class="p-4">
126 <div class="flex justify-between items-center mb-3"> 127 <div class="flex justify-between items-center mb-3">
127 - <h3 class="font-medium">今日打卡</h3> 128 + <h3 class="font-medium">
129 + <span :class="{ 'text-green-500' : showTaskList }" @click="toggleTask('today')">今日打卡</span>&nbsp;&nbsp;&nbsp;&nbsp;
130 + <span :class="{ 'text-green-500' : showTimeoutTaskList }" @click="toggleTask('timeout')">历史打卡</span>
131 + </h3>
128 <van-icon name="cross" @click="showCheckInDialog = false" /> 132 <van-icon name="cross" @click="showCheckInDialog = false" />
129 </div> 133 </div>
130 134
...@@ -137,7 +141,7 @@ ...@@ -137,7 +141,7 @@
137 <template v-else> 141 <template v-else>
138 <div class="grid grid-cols-2 gap-4 py-2"> 142 <div class="grid grid-cols-2 gap-4 py-2">
139 <button 143 <button
140 - v-for="checkInType in task_list" 144 + v-for="checkInType in default_list"
141 :key="checkInType.id" 145 :key="checkInType.id"
142 class="flex flex-col items-center p-2 rounded-lg border transition-colors 146 class="flex flex-col items-center p-2 rounded-lg border transition-colors
143 bg-white/70 border-gray-100 hover:bg-white" 147 bg-white/70 border-gray-100 hover:bg-white"
...@@ -186,7 +190,7 @@ import dayjs from 'dayjs'; ...@@ -186,7 +190,7 @@ import dayjs from 'dayjs';
186 import { showToast } from 'vant'; 190 import { showToast } from 'vant';
187 191
188 // 导入接口 192 // 导入接口
189 -import { getCourseDetailAPI } from '@/api/course' 193 +import { getCourseDetailAPI } from '@/api/course';
190 import { checkinTaskAPI } from '@/api/checkin'; 194 import { checkinTaskAPI } from '@/api/checkin';
191 195
192 const router = useRouter(); 196 const router = useRouter();
...@@ -230,6 +234,10 @@ const course_type_maps = ref({ ...@@ -230,6 +234,10 @@ const course_type_maps = ref({
230 }) 234 })
231 235
232 const task_list = ref([]); 236 const task_list = ref([]);
237 +const timeout_task_list = ref([]);
238 +const default_list = ref([]);
239 +const showTaskList = ref(true);
240 +const showTimeoutTaskList = ref(false);
233 241
234 onMounted(async () => { 242 onMounted(async () => {
235 /** 243 /**
...@@ -242,7 +250,10 @@ onMounted(async () => { ...@@ -242,7 +250,10 @@ onMounted(async () => {
242 if (code) { 250 if (code) {
243 course.value = data; 251 course.value = data;
244 task_list.value = data.task_list || []; 252 task_list.value = data.task_list || [];
253 + timeout_task_list.value = data.timeout_task_list || [];
245 course_lessons.value = data.schedule || []; 254 course_lessons.value = data.schedule || [];
255 + default_list.value = task_list.value;
256 + showTaskList.value = true;
246 } 257 }
247 /** 258 /**
248 * 初始化时计算topWrapperHeight 259 * 初始化时计算topWrapperHeight
...@@ -415,12 +426,32 @@ const handleCheckInSubmit = async () => { ...@@ -415,12 +426,32 @@ const handleCheckInSubmit = async () => {
415 }; 426 };
416 427
417 const goToCheckin = () => { 428 const goToCheckin = () => {
418 - if(!task_list.value.length) { 429 + if(!default_list.value.length) {
419 showToast('暂无打卡任务'); 430 showToast('暂无打卡任务');
420 return; 431 return;
421 } 432 }
422 showCheckInDialog.value = true; 433 showCheckInDialog.value = true;
423 }; 434 };
435 +
436 +const toggleTask = (type) => {
437 + if(type === 'today') {
438 + showTaskList.value = true;
439 + showTimeoutTaskList.value = false;
440 + default_list.value = task_list.value;
441 + } else {
442 + showTaskList.value = false;
443 + showTimeoutTaskList.value = true;
444 + default_list.value = timeout_task_list.value;
445 + }
446 +}
447 +
448 +const closeCheckInDialog = () => {
449 + showCheckInDialog.value = false;
450 + // 切换到今日任务
451 + showTaskList.value = true;
452 + showTimeoutTaskList.value = false;
453 + default_list.value = task_list.value;
454 +}
424 </script> 455 </script>
425 456
426 <style scoped> 457 <style scoped>
......
...@@ -73,7 +73,7 @@ ...@@ -73,7 +73,7 @@
73 </div> 73 </div>
74 </div> 74 </div>
75 <!-- 标签页区域 --> 75 <!-- 标签页区域 -->
76 - <div class="px-4 py-3 bg-white"> 76 + <div class="px-4 py-3 bg-white" style="position: relative;">
77 <van-tabs v-model:active="activeTab" sticky animated swipeable shrink @change="handleTabChange"> 77 <van-tabs v-model:active="activeTab" sticky animated swipeable shrink @change="handleTabChange">
78 <van-tab title="介绍" name="intro"> 78 <van-tab title="介绍" name="intro">
79 </van-tab> 79 </van-tab>
...@@ -81,6 +81,7 @@ ...@@ -81,6 +81,7 @@
81 <template #title>评论({{ commentCount }})</template> 81 <template #title>评论({{ commentCount }})</template>
82 </van-tab> 82 </van-tab>
83 </van-tabs> 83 </van-tabs>
84 + <div @click="goToCheckin" style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">课程互动</div>
84 </div> 85 </div>
85 </div> 86 </div>
86 87
...@@ -239,6 +240,71 @@ ...@@ -239,6 +240,71 @@
239 240
240 <!-- PDF预览 --> 241 <!-- PDF预览 -->
241 <PdfPreview v-model:show="pdfShow" :url="pdfUrl" :title="pdfTitle" @onLoad="onPdfLoad" /> 242 <PdfPreview v-model:show="pdfShow" :url="pdfUrl" :title="pdfTitle" @onLoad="onPdfLoad" />
243 +
244 + <!-- 打卡弹窗 -->
245 + <van-popup
246 + v-model:show="showCheckInDialog"
247 + round
248 + position="bottom"
249 + @close="closeCheckInDialog"
250 + :style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }"
251 + >
252 + <div class="p-4">
253 + <div class="flex justify-between items-center mb-3">
254 + <h3 class="font-medium">
255 + <span :class="{ 'text-green-500' : showTaskList }" @click="toggleTask('today')">今日打卡</span>&nbsp;&nbsp;&nbsp;&nbsp;
256 + <span :class="{ 'text-green-500' : showTimeoutTaskList }" @click="toggleTask('timeout')">历史打卡</span>
257 + </h3>
258 + <van-icon name="cross" @click="showCheckInDialog = false" />
259 + </div>
260 +
261 + <div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
262 + <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">
263 + <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" />
264 + </svg>
265 + <h4 class="text-green-700 font-medium mb-1">打卡成功!</h4>
266 + </div>
267 + <template v-else>
268 + <div class="grid grid-cols-2 gap-4 py-2">
269 + <button
270 + v-for="checkInType in default_list"
271 + :key="checkInType.id"
272 + class="flex flex-col items-center p-2 rounded-lg border transition-colors
273 + bg-white/70 border-gray-100 hover:bg-white"
274 + :class="{
275 + 'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id
276 + }"
277 + @click="handleCheckInSelect(checkInType)"
278 + >
279 + <div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors
280 + bg-gray-100 text-gray-500"
281 + :class="{
282 + 'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id
283 + }"
284 + >
285 + <van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" />
286 + <van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" />
287 + </div>
288 + <span class="text-xs">{{ checkInType.title }}</span>
289 + </button>
290 + </div>
291 +
292 + <div v-if="selectedCheckIn" class="mt-3">
293 + <button
294 + 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"
295 + @click="handleCheckInSubmit"
296 + :disabled="isCheckingIn"
297 + >
298 + <template v-if="isCheckingIn">
299 + <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
300 + 提交中...
301 + </template>
302 + <template v-else>提交打卡</template>
303 + </button>
304 + </div>
305 + </template>
306 + </div>
307 + </van-popup>
242 </div> 308 </div>
243 </template> 309 </template>
244 310
...@@ -254,10 +320,12 @@ import axios from 'axios'; ...@@ -254,10 +320,12 @@ import axios from 'axios';
254 import { v4 as uuidv4 } from "uuid"; 320 import { v4 as uuidv4 } from "uuid";
255 import { useIntersectionObserver } from '@vueuse/core'; 321 import { useIntersectionObserver } from '@vueuse/core';
256 import PdfPreview from '@/components/ui/PdfPreview.vue'; 322 import PdfPreview from '@/components/ui/PdfPreview.vue';
323 +import { showToast } from 'vant';
257 324
258 // 导入接口 325 // 导入接口
259 import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course'; 326 import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course';
260 import { addStudyRecordAPI } from "@/api/record"; 327 import { addStudyRecordAPI } from "@/api/record";
328 +import { checkinTaskAPI } from '@/api/checkin';
261 329
262 const route = useRoute(); 330 const route = useRoute();
263 const router = useRouter(); 331 const router = useRouter();
...@@ -479,6 +547,10 @@ onMounted(async () => { ...@@ -479,6 +547,10 @@ onMounted(async () => {
479 const detail = await getCourseDetailAPI({ i: course.value.group_id }); 547 const detail = await getCourseDetailAPI({ i: course.value.group_id });
480 if (detail.code) { 548 if (detail.code) {
481 course_lessons.value = detail.data.schedule || []; 549 course_lessons.value = detail.data.schedule || [];
550 + task_list.value = detail.data.task_list || [];
551 + timeout_task_list.value = detail.timeout_task_list || [];
552 + default_list.value = task_list.value;
553 + showTaskList.value = true;
482 } 554 }
483 } 555 }
484 // 图片附件或者附件不存在 556 // 图片附件或者附件不存在
...@@ -883,6 +955,91 @@ watch(showCatalog, (newVal) => { ...@@ -883,6 +955,91 @@ watch(showCatalog, (newVal) => {
883 }); 955 });
884 } 956 }
885 }); 957 });
958 +
959 +// 打卡相关状态
960 +const showCheckInDialog = ref(false);
961 +const selectedCheckIn = ref(null);
962 +const isCheckingIn = ref(false);
963 +const checkInSuccess = ref(false);
964 +const task_list = ref([]);
965 +const timeout_task_list = ref([]);
966 +const default_list = ref([]);
967 +const showTaskList = ref(true);
968 +const showTimeoutTaskList = ref(false);
969 +
970 +// 处理打卡选择
971 +const handleCheckInSelect = (type) => {
972 + if (type.is_gray && type.task_type === 'checkin') {
973 + showToast('您已经完成了今天的打卡');
974 + return;
975 + }
976 + if (type.task_type === 'upload') {
977 + router.push({
978 + path: '/checkin/index',
979 + query: {
980 + id: type.id
981 + }
982 + });
983 + showCheckInDialog.value = false;
984 + return;
985 + }
986 + selectedCheckIn.value = type;
987 +};
988 +
989 +// 处理打卡提交
990 +const handleCheckInSubmit = async () => {
991 + if (!selectedCheckIn.value) {
992 + showToast('请选择打卡项目');
993 + return;
994 + }
995 +
996 + isCheckingIn.value = true;
997 + try {
998 + const { code } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
999 + if (code) {
1000 + checkInSuccess.value = true;
1001 + // 重置表单
1002 + setTimeout(() => {
1003 + checkInSuccess.value = false;
1004 + selectedCheckIn.value = null;
1005 + showCheckInDialog.value = false;
1006 + }, 1500);
1007 + }
1008 + } catch (error) {
1009 + console.error('打卡失败:', error);
1010 + showToast('打卡失败,请重试');
1011 + } finally {
1012 + isCheckingIn.value = false;
1013 + }
1014 +};
1015 +
1016 +const goToCheckin = () => {
1017 + if(!default_list.value.length) {
1018 + showToast('暂无打卡任务');
1019 + return;
1020 + }
1021 + showCheckInDialog.value = true;
1022 +};
1023 +
1024 +const toggleTask = (type) => {
1025 + if(type === 'today') {
1026 + showTaskList.value = true;
1027 + showTimeoutTaskList.value = false;
1028 + default_list.value = task_list.value;
1029 + } else {
1030 + showTaskList.value = false;
1031 + showTimeoutTaskList.value = true;
1032 + default_list.value = timeout_task_list.value;
1033 + }
1034 +}
1035 +
1036 +const closeCheckInDialog = () => {
1037 + showCheckInDialog.value = false;
1038 + // 切换到今日任务
1039 + showTaskList.value = true;
1040 + showTimeoutTaskList.value = false;
1041 + default_list.value = task_list.value;
1042 +}
886 </script> 1043 </script>
887 1044
888 <style lang="less" scoped> 1045 <style lang="less" scoped>
......