hookehuyr

feat(打卡): 实现打卡功能完整流程

- 新增获取作业详情和打卡API接口
- 重构打卡弹窗组件,移除文本输入改为直接提交
- 更新文件上传组件,支持根据类型过滤文件
- 实现打卡日历页面,展示作业进度和团队头像
- 添加打卡类型选择页面,区分图文/视频/音频打卡
- 完善打卡成功后的状态处理和页面跳转
/*
* @Date: 2025-06-06 09:26:16
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-06 09:31:00
* @LastEditTime: 2025-06-06 15:05:19
* @FilePath: /mlaj/src/api/checkin.js
* @Description: 签到模块相关接口
*/
import { fn, fetch } from './fn'
const Api = {
GET_TASK_LIST: '/srv/?a=task&t=my_list'
GET_TASK_LIST: '/srv/?a=task&t=my_list',
GET_TASK_DETAIL: '/srv/?a=task&t=detail',
TASK_CHECKIN: '/srv/?a=checkin&t=checkin',
}
/**
......@@ -18,3 +20,18 @@ const Api = {
*/
export const getTaskListAPI = (params) => fn(fetch.get(Api.GET_TASK_LIST, params))
/**
* @description: 获取作业详情
* @param: i 作业id
* @param: month 月份
* @returns data: { id 作业id, title 作业名称, frequency 交作业的频次, begin_date 开始时间, end_date 结束时间, task_type 任务类型 [checkin=签到 | file=上传附件], is_gray 作业是否应该置灰, my_checkin_dates 我在日历中打过卡的日期, target_number 打卡的目标数量, checkin_number 已经打卡的数量, checkin_avatars 最后打卡的10个人的头像 }
*/
export const getTaskDetailAPI = (params) => fn(fetch.get(Api.GET_TASK_DETAIL, params))
/**
* @description: 签到打卡
* @param task_id 签到作业ID
* @returns
*/
export const checkinTaskAPI = (params) => fn(fetch.post(Api.TASK_CHECKIN, params))
......
......@@ -17,7 +17,7 @@
<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" />
</svg>
<h4 class="text-green-700 font-medium mb-1">打卡成功!</h4>
<p class="text-green-600 text-sm">+5 积分已添加到您的账户</p>
<!-- <p class="text-green-600 text-sm">+5 积分已添加到您的账户</p> -->
</div>
<template v-else>
<div class="flex space-x-2 py-2">
......@@ -39,18 +39,18 @@
: 'bg-gray-100 text-gray-500'
]">
<van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" />
<van-icon v-if="checkInType.task_type === 'file'" name="tosend" size="1.5rem" />
<van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" />
</div>
<span class="text-xs">{{ checkInType.name }}</span>
</button>
</div>
<div v-if="selectedCheckIn" class="mt-3">
<textarea
<!-- <textarea
:placeholder="`请输入${selectedCheckIn.name}内容...`"
v-model="checkInContent"
class="w-full p-3 border border-gray-200 rounded-lg text-sm resize-none h-24"
/>
/> -->
<button
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"
@click="handleCheckInSubmit"
......@@ -72,7 +72,7 @@
import { ref } from 'vue'
import { showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { getTaskListAPI } from "@/api/checkin";
import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin";
// 签到列表
const checkInTypes = ref([]);
......@@ -96,9 +96,16 @@ const isCheckingIn = ref(false)
const checkInSuccess = ref(false)
const handleCheckInSelect = (type) => {
if (type.task_type === 'file') {
if (type.is_gray) {
showToast('您已经完成了今天的打卡')
return
}
if (type.task_type === 'upload') {
router.push({
path: '/checkin/index',
query: {
id: type.id
}
})
}
selectedCheckIn.value = type;
......@@ -109,18 +116,18 @@ const handleCheckInSubmit = async () => {
showToast('请选择打卡项目')
return
}
if (!checkInContent.value.trim()) {
showToast('请输入打卡内容')
return
}
// if (!checkInContent.value.trim()) {
// showToast('请输入打卡内容')
// return
// }
isCheckingIn.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// API调用
const { code, data } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
if (code) {
checkInSuccess.value = true
emit('check-in-success')
// 重置表单
setTimeout(() => {
checkInSuccess.value = false
......@@ -128,6 +135,7 @@ const handleCheckInSubmit = async () => {
checkInContent.value = ''
emit('update:show', false)
}, 1500)
}
} catch (error) {
showToast('打卡失败,请重试')
} finally {
......@@ -151,6 +159,7 @@ onMounted(async () => {
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray
})
})
}
......
/*
* @Date: 2025-03-21 13:28:30
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-03 18:36:49
* @LastEditTime: 2025-06-06 14:35:56
* @FilePath: /mlaj/src/router/checkin.js
* @Description: 文件描述
*/
......@@ -65,7 +65,7 @@ export default [
name: 'FileCheckIn',
component: () => import('@/views/checkin/upload/file.vue'),
meta: {
title: '打卡视频音频',
title: '打卡视频/音频',
requiresAuth: true
}
},
......
<!--
* @Date: 2025-03-20 19:55:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-06 11:10:02
* @LastEditTime: 2025-06-06 15:16:49
* @FilePath: /mlaj/src/views/HomePage.vue
* @Description: 美乐爱觉教育首页组件
*
......@@ -85,7 +85,7 @@
<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" />
</svg>
<h4 class="text-green-700 font-medium mb-1">打卡成功!</h4>
<p class="text-green-600 text-sm">+5 积分已添加到您的账户</p>
<!-- <p class="text-green-600 text-sm">+5 积分已添加到您的账户</p> -->
</div>
<template v-else>
<div class="flex space-x-2 py-2">
......@@ -107,18 +107,18 @@
: 'bg-gray-100 text-gray-500'
]">
<van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" />
<van-icon v-if="checkInType.task_type === 'file'" name="tosend" size="1.5rem" />
<van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" />
</div>
<span class="text-xs">{{ checkInType.name }}</span>
</button>
</div>
<div v-if="selectedCheckIn" class="mt-3">
<textarea
<!-- <textarea
:placeholder="`请输入${selectedCheckIn.name}内容...`"
v-model="checkInContent"
class="w-full p-3 border border-gray-200 rounded-lg text-sm resize-none h-24"
/>
/> -->
<button
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"
@click="handleCheckInSubmit"
......@@ -523,7 +523,7 @@ import { showToast } from 'vant'
// 导入接口
import { getCourseListAPI } from "@/api/course";
import { getTaskListAPI } from "@/api/checkin";
import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin";
// 视频播放状态管理
const activeVideoIndex = ref(null); // 当前播放的视频索引
......@@ -602,8 +602,9 @@ onMounted(async () => {
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray
})
})
});
}
}
})
......@@ -667,9 +668,16 @@ const scrollToSlide = (index) => {
// 打卡功能:处理打卡类型选择
const handleCheckInSelect = (checkInType) => {
if (checkInType.task_type === 'file') {
if (checkInType.is_gray) {
showToast('您已经完成了今天的打卡')
return
}
if (checkInType.task_type === 'upload') {
$router.push({
path: '/checkin/index',
query: {
id: checkInType.id,
},
})
}
selectedCheckIn.value = checkInType // 更新选中的打卡类型
......@@ -677,31 +685,30 @@ const handleCheckInSelect = (checkInType) => {
}
// 打卡功能:处理打卡提交
const handleCheckInSubmit = () => {
const handleCheckInSubmit = async () => {
// 表单验证
if (!selectedCheckIn.value) {
showToast('请选择打卡项目')
return
}
if (!checkInContent.value.trim()) {
showToast('请输入打卡内容')
return
}
// if (!checkInContent.value.trim()) {
// showToast('请输入打卡内容')
// return
// }
isCheckingIn.value = true
// 模拟API调用
setTimeout(() => {
// API调用
isCheckingIn.value = false
checkInSuccess.value = true
selectedCheckIn.value = null
checkInContent.value = ''
// 3秒后重置成功提示
const { code, data } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
if (code) {
setTimeout(() => {
selectedCheckIn.value = null
checkInSuccess.value = false
}, 3000)
}, 1500)
}, 1500);
}
}
const contentRef = ref(null) // 内容区域的ref引用
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-03 16:07:54
* @LastEditTime: 2025-06-06 14:33:04
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
<template>
<AppLayout :hasTitle="false">
<van-config-provider :theme-vars="themeVars">
<van-calendar title="每日打卡" :poppable="false" :show-confirm="false" :style="{ height: '24rem' }"
<van-calendar :title="taskDetail.title" :poppable="false" :show-confirm="false" :style="{ height: '24rem' }"
switch-mode="year-month" color="#4caf50" :formatter="formatter" row-height="42" :show-mark="false"
@select="onSelectDay"
@click-subtitle="onClickSubtitle">
......@@ -20,7 +20,7 @@
<div class="grade-percentage-main">
<van-row justify="space-between" style="margin: 0.5rem 0; font-size: 0.9rem;">
<van-col span="12">
<span>年级目标</span>
<span>作业目标</span>
</van-col>
<van-col span="12" style="text-align: right;">
<span style="font-weight: bold;">{{ progress1 }}%</span>
......@@ -28,7 +28,7 @@
</van-row>
<van-progress :percentage="progress1" color="#4caf50" :show-pivot="false" />
</div>
<div class="class-percentage-main">
<!-- <div class="class-percentage-main">
<van-row justify="space-between" style="margin: 0.5rem 0; font-size: 0.9rem;">
<van-col span="12">
<span>班级目标</span>
......@@ -38,7 +38,7 @@
</van-col>
</van-row>
<van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" />
</div>
</div> -->
<div style="padding: 0.75rem 1rem;">
<van-image round width="2.8rem" height="2.8rem" src="https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg"
v-for="(item, index) in teamAvatars" :key="index"
......@@ -47,16 +47,20 @@
</div>
</div>
<div class="text-wrapper">
<div class="text-header">上传附件</div>
<div v-if="!taskDetail.is_gray" class="text-wrapper">
<div class="text-header">打卡类型</div>
<div class="upload-wrapper">
<div @click="goToCheckinImagePage" class="upload-boxer">
<div><van-icon name="photo" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">图文上传</div>
<div style="font-size: 0.85rem;">图文打卡</div>
</div>
<div @click="goToCheckinFilePage" class="upload-boxer">
<div @click="goToCheckinFilePage('video')" class="upload-boxer">
<div><van-icon name="video" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">视频/语音</div>
<div style="font-size: 0.85rem;">视频打卡</div>
</div>
<div @click="goToCheckinFilePage('audio')" class="upload-boxer">
<div><van-icon name="music" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">音频打卡</div>
</div>
</div>
</div>
......@@ -66,15 +70,21 @@
<div class="post-card" v-for="post in mockPosts" :key="post.id">
<div class="post-header">
<van-row>
<van-col span="3">
<van-col span="4">
<van-image round width="2.5rem" height="2.5rem" :src="post.user.avatar" />
</van-col>
<van-col span="20">
<van-col span="17">
<div class="user-info">
<div class="username">{{ post.user.name }}</div>
<div class="post-time">{{ post.user.time }}</div>
</div>
</van-col>
<van-col span="3">
<div class="post-menu">
<van-icon name="edit" @click="editCheckin()" />
<van-icon name="delete-o" @click="delCheckin()" />
</div>
</van-col>
</van-row>
</div>
<div class="post-content">
......@@ -129,7 +139,7 @@
</div>
</div>
<div class="post-footer" @click="handLike(post)">
<van-icon name="like" class="like-icon" :color="post.is_liked ? 'red' : ''" />
<van-icon name="good-job" class="like-icon" :color="post.is_liked ? 'red' : ''" />
<span class="like-count">{{ post.likes }}</span>
</div>
</div>
......@@ -137,12 +147,15 @@
<div style="height: 5rem;"></div>
</van-config-provider>
<van-dialog v-model:show="dialog_show" title="标题" show-cancel-button></van-dialog>
</AppLayout>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog, showSuccessToast, showFailToast } from 'vant';
import AppLayout from "@/components/layout/AppLayout.vue";
import FrostedGlass from "@/components/ui/FrostedGlass.vue";
import VideoPlayer from "@/components/ui/VideoPlayer.vue";
......@@ -150,6 +163,8 @@ import AudioPlayer from "@/components/ui/AudioPlayer.vue";
import { useTitle } from '@vueuse/core';
import dayjs from 'dayjs';
import { getTaskDetailAPI } from "@/api/checkin";
const route = useRoute()
const router = useRouter()
useTitle(route.meta.title);
......@@ -445,15 +460,10 @@ const themeVars = {
calendarSelectedDayBackground: '#4caf50'
}
const progress1 = ref(50);
const progress1 = ref(0);
const progress2 = ref(76);
const teamAvatars = ref([
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg'
])
const teamAvatars = ref([])
// 图片预览相关
const showImagePreview = ref(false);
......@@ -503,14 +513,57 @@ const onClickSubtitle = (evt) => {
const goToCheckinImagePage = () => {
router.push('/checkin/image');
}
const goToCheckinFilePage = () => {
router.push('/checkin/file');
const goToCheckinFilePage = (type) => {
router.push('/checkin/file?type=' + type);
}
const handLike = (post) => {
post.is_liked = !post.is_liked;
// TODO: 调用接口
}
const editCheckin = () => {
let type = 'image';
if (type === 'image') {
router.push({
path: '/checkin/image',
})
} else {
router.push({
path: '/checkin/file',
})
}
}
const delCheckin = () => {
showConfirmDialog({
title: '温馨提示',
message: '您是否确定要删除该动态?',
confirmButtonColor: '#4caf50',
})
.then(() => {
// on confirm
// TODO: 调用接口
// 删除成功后,刷新页面
showSuccessToast('成功文案');
// showFailToast('失败文案');
})
.catch(() => {
// on cancel
});
}
const taskDetail = ref({});
onMounted(async () => {
const { code, data } = await getTaskDetailAPI({ id: route.query.id, month: dayjs().format('YYYY-MM') });
if (code) {
console.warn(data);
taskDetail.value = data;
progress1.value = (data.checkin_number/data.target_number)*100 ;
teamAvatars.value = data.checkin_avatars;
}
})
</script>
<style lang="less">
......@@ -574,6 +627,13 @@ const handLike = (post) => {
}
}
.post-menu {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.post-content {
.post-text {
margin-bottom: 1rem;
......
<!--
* @Date: 2025-06-03 09:41:41
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-03 17:11:53
* @LastEditTime: 2025-06-06 14:38:20
* @FilePath: /mlaj/src/views/checkin/upload/file.vue
* @Description: 音视频文件上传组件
-->
......@@ -17,7 +17,7 @@
:after-read="afterRead"
@delete="onDelete"
multiple
accept="audio/*,video/*"
:accept="route.query.type === 'video' ? 'video/*' : 'audio/*'"
result-type="file"
upload-icon="plus"
>
......@@ -29,7 +29,7 @@
</template>
</van-uploader>
<div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ route.query.type === 'video' ? "视频文件" : '音频文件' }}</div>
</div>
<!-- 文字留言区域 -->
......@@ -96,14 +96,6 @@ const canSubmit = computed(() => {
return fileList.value.length > 0 && message.value.trim() !== ''
})
// 固定类型限制
const fileTypes = "audio/video";
// 文件类型中文页面显示
const type_text = computed(() => {
return "音频/视频文件";
});
// 文件校验
const beforeRead = (file) => {
let flag = true
......