hookehuyr

feat(打卡): 添加课程打卡弹窗功能并显示关联打卡数量

- 新增打卡弹窗组件,包含打卡类型选择和提交功能
- 动态显示关联打卡数量而非固定文本
- 处理打卡提交逻辑和状态管理
- 优化打卡入口逻辑,无任务时提示用户
...@@ -103,7 +103,7 @@ ...@@ -103,7 +103,7 @@
103 <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" /> 103 <van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" />
104 <div> 104 <div>
105 <div class="text-base font-medium">打卡</div> 105 <div class="text-base font-medium">打卡</div>
106 - <div class="text-sm text-gray-500">关联7个打卡</div> 106 + <div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div>
107 </div> 107 </div>
108 </div> 108 </div>
109 <van-icon name="arrow" class="text-gray-400" /> 109 <van-icon name="arrow" class="text-gray-400" />
...@@ -114,6 +114,67 @@ ...@@ -114,6 +114,67 @@
114 <div style="height: 30vh;"></div> 114 <div style="height: 30vh;"></div>
115 </div> 115 </div>
116 </div> 116 </div>
117 +
118 + <!-- 打卡弹窗 -->
119 + <van-popup
120 + v-model:show="showCheckInDialog"
121 + round
122 + position="bottom"
123 + :style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }"
124 + >
125 + <div class="p-4">
126 + <div class="flex justify-between items-center mb-3">
127 + <h3 class="font-medium">今日打卡</h3>
128 + <van-icon name="cross" @click="showCheckInDialog = false" />
129 + </div>
130 +
131 + <div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
132 + <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">
133 + <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" />
134 + </svg>
135 + <h4 class="text-green-700 font-medium mb-1">打卡成功!</h4>
136 + </div>
137 + <template v-else>
138 + <div class="grid grid-cols-2 gap-4 py-2">
139 + <button
140 + v-for="checkInType in task_list"
141 + :key="checkInType.id"
142 + class="flex flex-col items-center p-2 rounded-lg border transition-colors
143 + bg-white/70 border-gray-100 hover:bg-white"
144 + :class="{
145 + 'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id
146 + }"
147 + @click="handleCheckInSelect(checkInType)"
148 + >
149 + <div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors
150 + bg-gray-100 text-gray-500"
151 + :class="{
152 + 'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id
153 + }"
154 + >
155 + <van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" />
156 + <van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" />
157 + </div>
158 + <span class="text-xs">{{ checkInType.title }}</span>
159 + </button>
160 + </div>
161 +
162 + <div v-if="selectedCheckIn" class="mt-3">
163 + <button
164 + 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"
165 + @click="handleCheckInSubmit"
166 + :disabled="isCheckingIn"
167 + >
168 + <template v-if="isCheckingIn">
169 + <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
170 + 提交中...
171 + </template>
172 + <template v-else>提交打卡</template>
173 + </button>
174 + </div>
175 + </template>
176 + </div>
177 + </van-popup>
117 </div> 178 </div>
118 </template> 179 </template>
119 180
...@@ -122,9 +183,11 @@ import { ref, onMounted, nextTick, onUnmounted } from 'vue'; ...@@ -122,9 +183,11 @@ import { ref, onMounted, nextTick, onUnmounted } from 'vue';
122 import { useTitle } from '@vueuse/core'; 183 import { useTitle } from '@vueuse/core';
123 import { useRouter } from "vue-router"; 184 import { useRouter } from "vue-router";
124 import dayjs from 'dayjs'; 185 import dayjs from 'dayjs';
186 +import { showToast } from 'vant';
125 187
126 // 导入接口 188 // 导入接口
127 import { getCourseDetailAPI } from '@/api/course' 189 import { getCourseDetailAPI } from '@/api/course'
190 +import { checkinTaskAPI } from '@/api/checkin';
128 191
129 const router = useRouter(); 192 const router = useRouter();
130 193
...@@ -166,6 +229,8 @@ const course_type_maps = ref({ ...@@ -166,6 +229,8 @@ const course_type_maps = ref({
166 file: '文件', 229 file: '文件',
167 }) 230 })
168 231
232 +const task_list = ref([]);
233 +
169 onMounted(async () => { 234 onMounted(async () => {
170 /** 235 /**
171 * 组件挂载时获取课程详情 236 * 组件挂载时获取课程详情
...@@ -176,6 +241,7 @@ onMounted(async () => { ...@@ -176,6 +241,7 @@ onMounted(async () => {
176 const { code, data } = await getCourseDetailAPI({ i: courseId }); 241 const { code, data } = await getCourseDetailAPI({ i: courseId });
177 if (code) { 242 if (code) {
178 course.value = data; 243 course.value = data;
244 + task_list.value = data.task_list || [];
179 course_lessons.value = data.schedule || []; 245 course_lessons.value = data.schedule || [];
180 } 246 }
181 /** 247 /**
...@@ -292,38 +358,68 @@ const handleTabChange = (name) => { ...@@ -292,38 +358,68 @@ const handleTabChange = (name) => {
292 }); 358 });
293 }; 359 };
294 360
295 -// 课程数据
296 -// const course = ref({
297 -// title: '开学礼·止的智慧·心法老师·20241001',
298 -// coverImage: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
299 -// updateTime: '2024.01.17',
300 -// viewCount: 1897,
301 -// description: '这是一门关于心法的课程,帮助学员掌握止的智慧...',
302 -// lessons: [
303 -// {
304 -// title: '第一课:止的基础',
305 -// duration: '45分钟',
306 -// progress: 100
307 -// },
308 -// {
309 -// title: '第二课:止的技巧',
310 -// duration: '50分钟',
311 -// progress: 60
312 -// },
313 -// {
314 -// title: '第三课:止的应用',
315 -// duration: '40分钟',
316 -// progress: 0
317 -// }
318 -// ]
319 -// });
320 -
321 const goToStudyDetail = (lessonId) => { 361 const goToStudyDetail = (lessonId) => {
322 router.push(`/studyDetail/${lessonId}`); 362 router.push(`/studyDetail/${lessonId}`);
323 }; 363 };
324 364
365 +// 打卡相关状态
366 +const showCheckInDialog = ref(false);
367 +const selectedCheckIn = ref(null);
368 +const isCheckingIn = ref(false);
369 +const checkInSuccess = ref(false);
370 +
371 +// 处理打卡选择
372 +const handleCheckInSelect = (type) => {
373 + if (type.is_gray && type.task_type === 'checkin') {
374 + showToast('您已经完成了今天的打卡');
375 + return;
376 + }
377 + if (type.task_type === 'upload') {
378 + router.push({
379 + path: '/checkin/index',
380 + query: {
381 + id: type.id
382 + }
383 + });
384 + showCheckInDialog.value = false;
385 + return;
386 + }
387 + selectedCheckIn.value = type;
388 +};
389 +
390 +// 处理打卡提交
391 +const handleCheckInSubmit = async () => {
392 + if (!selectedCheckIn.value) {
393 + showToast('请选择打卡项目');
394 + return;
395 + }
396 +
397 + isCheckingIn.value = true;
398 + try {
399 + const { code } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
400 + if (code) {
401 + checkInSuccess.value = true;
402 + // 重置表单
403 + setTimeout(() => {
404 + checkInSuccess.value = false;
405 + selectedCheckIn.value = null;
406 + showCheckInDialog.value = false;
407 + }, 1500);
408 + }
409 + } catch (error) {
410 + console.error('打卡失败:', error);
411 + showToast('打卡失败,请重试');
412 + } finally {
413 + isCheckingIn.value = false;
414 + }
415 +};
416 +
325 const goToCheckin = () => { 417 const goToCheckin = () => {
326 - router.push(`/checkin/index?course_id=${course.value.id}`); 418 + if(!task_list.length) {
419 + showToast('暂无打卡任务');
420 + return;
421 + }
422 + showCheckInDialog.value = true;
327 }; 423 };
328 </script> 424 </script>
329 425
......