hookehuyr

refactor(打卡弹窗): 将打卡弹窗统一为通用组件 CheckInDialog

重构 CourseDetailPage、StudyCoursePage 和 StudyDetailPage 中的打卡弹窗逻辑,提取为通用组件 CheckInDialog
组件支持通过 props 传入今日和历史打卡任务列表,并统一处理弹窗显隐和交互逻辑
移除各页面中冗余的打卡相关状态和方法,简化代码结构
......@@ -75,3 +75,16 @@ https://oa-dev.onwall.cn/f/mlaj
- 依赖:`pnpm add qrcode`(在 Canvas 内本地生成二维码,避免跨域图片导致画布污染)。
- 跨域:通过 `crossorigin="anonymous"` 加载封面,并追加时间戳防缓存;若封面跨域不允许,则显示降级卡片,仍可长按截图保存。
- 文案:使用中文字体并自动换行限制行数,末行超出追加省略号。
- 打卡弹窗统一为通用组件 CheckInDialog
- 目的:统一 CourseDetailPage、StudyCoursePage、StudyDetailPage 三处页面的打卡弹窗与交互,避免重复逻辑。
- 组件:`/src/components/ui/CheckInDialog.vue``v-model:show` 控制显隐;支持外部传入任务列表。
- Props:
- `items_today`:今日打卡任务数组(外部传入)。
- `items_history`:历史打卡任务数组(外部传入)。
- 数据结构:每项需包含 `id``title(name)``task_type``checkin`/`upload`)、`is_gray`
- 使用位置:
- `/src/views/courses/CourseDetailPage.vue`
- `/src/views/profile/StudyCoursePage.vue`
- `/src/views/study/StudyDetailPage.vue`
- 清理:上述页面已移除旧弹窗的冗余状态与方法(如 `default_list``showTaskList``showTimeoutTaskList``selectedCheckIn` 等),统一由组件内部处理。
......
......@@ -8,7 +8,10 @@
>
<div class="p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">今日打卡</h3>
<h3 class="font-medium">
<span :class="{ 'text-green-500' : active_tab === 'today' }" @click="active_tab = 'today'">今日打卡</span>&nbsp;&nbsp;&nbsp;&nbsp;
<span v-if="has_history" :class="{ 'text-green-500' : active_tab === 'history' }" @click="active_tab = 'history'">历史打卡</span>
</h3>
<van-icon name="cross" @click="handleClose" />
</div>
......@@ -22,7 +25,7 @@
<template v-else>
<div class="grid grid-cols-2 gap-4 py-2"> <!-- grid-cols-2 强制每行2列,gap控制间距 -->
<button
v-for="checkInType in checkInTypes"
v-for="checkInType in active_list"
:key="checkInType.id"
class="flex flex-col items-center p-2 rounded-lg border transition-colors
bg-white/70 border-gray-100 hover:bg-white"
......@@ -68,7 +71,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin";
......@@ -80,11 +83,12 @@ const route = useRoute()
const router = useRouter()
const props = defineProps({
show: {
type: Boolean,
required: true,
default: false
}
/** 弹窗显隐 */
show: { type: Boolean, required: true, default: false },
/** 今日打卡任务(外部传入,可选) */
items_today: { type: Array, default: () => [] },
/** 历史打卡任务(外部传入,可选) */
items_history: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:show', 'check-in-success', 'check-in-data'])
......@@ -94,6 +98,36 @@ const checkInContent = ref('')
const isCheckingIn = ref(false)
const checkInSuccess = ref(false)
/**
* @var {import('vue').Ref<'today'|'history'>} active_tab
* @description 当前选中的任务标签页:今日或历史。
*/
const active_tab = ref('today')
/**
* @function has_history
* @description 是否存在历史任务(用于显示“历史打卡”标签)。
* @returns {boolean}
*/
const has_history = computed(() => {
const list = Array.isArray(props.items_history) ? props.items_history : []
return list.length > 0
})
/**
* @function active_list
* @description 当前展示的任务列表:优先使用外部传入,未传时回退为组件内部获取的列表。
* @returns {Array}
*/
const active_list = computed(() => {
const today = Array.isArray(props.items_today) ? props.items_today : []
const history = Array.isArray(props.items_history) ? props.items_history : []
if (active_tab.value === 'today') {
return today.length ? today : checkInTypes.value
}
return history
})
const handleCheckInSelect = (type) => {
if (type.is_gray && type.task_type === 'checkin') {
showToast('您已经完成了今天的打卡')
......@@ -151,18 +185,20 @@ const handleClose = () => {
}
onMounted(async () => {
// 获取签到列表
const task = await getTaskListAPI()
if (task.code) {
emit('check-in-data', task.data)
task.data.forEach(item => {
checkInTypes.value.push({
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray
})
})
}
// 当未从外部传入“今日任务”时,回退为组件内部获取的通用任务列表
if (!Array.isArray(props.items_today) || props.items_today.length === 0) {
const task = await getTaskListAPI()
if (task.code) {
emit('check-in-data', task.data)
task.data.forEach(item => {
checkInTypes.value.push({
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray
})
})
}
}
})
</script>
......
......@@ -264,70 +264,13 @@
<!-- Review Popup -->
<ReviewPopup v-model:show="showReviewPopup" title="立即评价" @submit="handleReviewSubmit" />
<!-- 打卡弹窗 -->
<van-popup
v-model:show="showCheckInDialog"
round
position="bottom"
@close="closeCheckInDialog"
:style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }"
>
<div class="p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">
<span :class="{ 'text-green-500' : showTaskList }" @click="toggleTask('today')">今日打卡</span>&nbsp;&nbsp;&nbsp;&nbsp;
<span :class="{ 'text-green-500' : showTimeoutTaskList }" @click="toggleTask('timeout')">历史打卡</span>
</h3>
<van-icon name="cross" @click="showCheckInDialog = false" />
</div>
<div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<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">
<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>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4 py-2">
<button
v-for="checkInType in default_list"
:key="checkInType.id"
class="flex flex-col items-center p-2 rounded-lg border transition-colors
bg-white/70 border-gray-100 hover:bg-white"
:class="{
'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id
}"
@click="handleCheckInSelect(checkInType)"
>
<div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors
bg-gray-100 text-gray-500"
:class="{
'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id
}"
>
<van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" />
<van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" />
</div>
<span :class="['text-xs', checkInType.is_gray ? 'text-gray-500' : '']">{{ checkInType.name }}</span>
</button>
</div>
<div v-if="selectedCheckIn" class="mt-3">
<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"
:disabled="isCheckingIn"
>
<template v-if="isCheckingIn">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
提交中...
</template>
<template v-else>提交打卡</template>
</button>
</div>
</template>
</div>
</van-popup>
<!-- 打卡弹窗(统一组件) -->
<CheckInDialog
v-model:show="showCheckInDialog"
:items_today="task_list"
:items_history="timeout_task_list"
@check-in-success="handleCheckInSuccess"
/>
<!-- 咨询弹窗:底部只有关闭按钮,内容支持富文本 -->
<van-popup
......@@ -397,11 +340,12 @@ import { sharePage } from '@/composables/useShare.js'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import SharePoster from '@/components/ui/SharePoster.vue'
import CheckInDialog from '@/components/ui/CheckInDialog.vue'
// 导入接口
import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course";
import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
import { checkinTaskAPI } from '@/api/checkin';
// 已统一使用通用打卡弹窗,移除未使用的打卡提交接口
//
// Open Graph 元标签:进入课程详情页时动态插入,离开页面时移除
......@@ -568,13 +512,7 @@ const handleIntroduceClick = (event) => {
// 打卡相关状态
const task_list = ref([])
const timeout_task_list = ref([])
const default_list = ref([])
const showTaskList = ref(true)
const showTimeoutTaskList = ref(false)
const showCheckInDialog = ref(false)
const selectedCheckIn = ref(null)
const isCheckingIn = ref(false)
const checkInSuccess = ref(false)
// 咨询弹窗相关状态
/**
......@@ -860,7 +798,7 @@ onMounted(async () => {
});
}
default_list.value = task_list.value;
// 统一弹窗组件后不再维护 default_list
// 进入详情页时写入 Open Graph 元标签,提升分享预览效果
set_og_meta({
......@@ -979,54 +917,6 @@ const goToStudyDetail = (item) => {
* 处理打卡选择
* @param {Object} type - 打卡类型对象
*/
const handleCheckInSelect = (type) => {
if (type.is_gray && type.task_type === 'checkin') {
showToast('您已经完成了今天的打卡');
return;
}
if (type.task_type === 'upload') {
router.push({
path: '/checkin/index',
query: {
id: type.id
}
});
showCheckInDialog.value = false;
return;
} else {
selectedCheckIn.value = type;
}
};
/**
* 处理打卡提交
*/
const handleCheckInSubmit = async () => {
if (!selectedCheckIn.value) {
showToast('请选择打卡项目');
return;
}
isCheckingIn.value = true;
try {
const { code } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
if (code) {
checkInSuccess.value = true;
// 重置表单
setTimeout(() => {
checkInSuccess.value = false;
selectedCheckIn.value = null;
showCheckInDialog.value = false;
}, 1500);
}
} catch (error) {
console.error('打卡失败:', error);
showToast('打卡失败,请重试');
} finally {
isCheckingIn.value = false;
}
};
/**
* 打开打卡弹窗
*/
......@@ -1041,36 +931,13 @@ const goToCheckin = () => {
})
return;
}
if(!default_list.value.length) {
if(!(task_list.value.length || timeout_task_list.value.length)) {
showToast('暂无打卡任务');
return;
}
showCheckInDialog.value = true;
};
/**
* 切换打卡任务类型
* @param {string} type - 任务类型 ('today' | 'timeout')
*/
const toggleTask = (type) => {
if(type === 'today') {
showTaskList.value = true;
showTimeoutTaskList.value = false;
default_list.value = task_list.value;
} else {
showTaskList.value = false;
showTimeoutTaskList.value = true;
default_list.value = timeout_task_list.value;
}
}
/**
* 关闭打卡弹窗
*/
const closeCheckInDialog = () => {
showCheckInDialog.value = false;
}
setTimeout(() => {
// TAG:微信分享
// 自定义分享内容
......@@ -1108,3 +975,10 @@ setTimeout(() => {
background-color: #4caf50;
}
</style>
/**
* 处理打卡成功
* 注释:统一弹窗触发成功事件后,页面提示成功。
*/
const handleCheckInSuccess = () => {
showToast('打卡成功');
}
......
......@@ -118,70 +118,13 @@
</div>
</div>
<!-- 打卡弹窗 -->
<van-popup
<!-- 打卡弹窗:统一使用 CheckInDialog 组件 -->
<CheckInDialog
v-model:show="showCheckInDialog"
round
position="bottom"
@close="closeCheckInDialog"
:style="{ minHeight: '30%', maxHeight: '80%', width: '100%' }"
>
<div class="p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">
<span :class="{ 'text-green-500' : showTaskList }" @click="toggleTask('today')">今日打卡</span>&nbsp;&nbsp;&nbsp;&nbsp;
<span :class="{ 'text-green-500' : showTimeoutTaskList }" @click="toggleTask('timeout')">历史打卡</span>
</h3>
<van-icon name="cross" @click="showCheckInDialog = false" />
</div>
<div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<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">
<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>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4 py-2">
<button
v-for="checkInType in default_list"
:key="checkInType.id"
class="flex flex-col items-center p-2 rounded-lg border transition-colors
bg-white/70 border-gray-100 hover:bg-white"
:class="{
'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id
}"
@click="handleCheckInSelect(checkInType)"
>
<div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors
bg-gray-100 text-gray-500"
:class="{
'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id
}"
>
<van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" />
<van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" />
</div>
<span :class="['text-xs', checkInType.is_gray ? 'text-gray-500' : '']">{{ checkInType.name }}</span>
</button>
</div>
<div v-if="selectedCheckIn" class="mt-3">
<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"
:disabled="isCheckingIn"
>
<template v-if="isCheckingIn">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
提交中...
</template>
<template v-else>提交打卡</template>
</button>
</div>
</template>
</div>
</van-popup>
:items_today="task_list"
:items_history="timeout_task_list"
@check-in-success="handleCheckInSuccess"
/>
</div>
</AppLayout>
</template>
......@@ -193,10 +136,10 @@ import { useRouter } from "vue-router";
import dayjs from 'dayjs';
import { showToast } from 'vant';
import AppLayout from '@/components/layout/AppLayout.vue';
import CheckInDialog from '@/components/ui/CheckInDialog.vue';
// 导入接口
import { getCourseDetailAPI } from '@/api/course';
import { checkinTaskAPI } from '@/api/checkin';
const router = useRouter();
......@@ -334,9 +277,6 @@ const course_type_maps = ref({
const task_list = ref([]);
const timeout_task_list = ref([]);
const default_list = ref([]);
const showTaskList = ref(true);
const showTimeoutTaskList = ref(false);
onMounted(async () => {
/**
......@@ -376,8 +316,6 @@ onMounted(async () => {
}
course_lessons.value = data.schedule || [];
default_list.value = task_list.value;
showTaskList.value = true;
}
else {
// 课程不存在,跳转到课程主页面
......@@ -515,83 +453,22 @@ const goToStudyDetail = (lessonId) => {
// 打卡相关状态
const showCheckInDialog = ref(false);
const selectedCheckIn = ref(null);
const isCheckingIn = ref(false);
const checkInSuccess = ref(false);
// 处理打卡选择
const handleCheckInSelect = (type) => {
if (type.is_gray && type.task_type === 'checkin') {
showToast('您已经完成了今天的打卡');
return;
}
if (type.task_type === 'upload') {
router.push({
path: '/checkin/index',
query: {
id: type.id
}
});
showCheckInDialog.value = false;
return;
} else {
selectedCheckIn.value = type;
}
};
// 处理打卡提交
const handleCheckInSubmit = async () => {
if (!selectedCheckIn.value) {
showToast('请选择打卡项目');
return;
}
isCheckingIn.value = true;
try {
const { code } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
if (code) {
checkInSuccess.value = true;
// 重置表单
setTimeout(() => {
checkInSuccess.value = false;
selectedCheckIn.value = null;
showCheckInDialog.value = false;
}, 1500);
}
} catch (error) {
console.error('打卡失败:', error);
showToast('打卡失败,请重试');
} finally {
isCheckingIn.value = false;
}
};
const goToCheckin = () => {
if(!default_list.value.length) {
if(!(task_list.value.length || timeout_task_list.value.length)) {
showToast('暂无打卡任务');
return;
}
showCheckInDialog.value = true;
};
const toggleTask = (type) => {
if(type === 'today') {
showTaskList.value = true;
showTimeoutTaskList.value = false;
default_list.value = task_list.value;
} else {
showTaskList.value = false;
showTimeoutTaskList.value = true;
default_list.value = timeout_task_list.value;
}
}
const closeCheckInDialog = () => {
showCheckInDialog.value = false;
// 切换到今日任务
showTaskList.value = true;
showTimeoutTaskList.value = false;
default_list.value = task_list.value;
/**
* 处理打卡成功
* 注释:统一弹窗触发成功事件后,页面提示成功。
*/
const handleCheckInSuccess = () => {
showToast('打卡成功');
}
</script>
......
This diff is collapsed. Click to expand it.