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>
......
......@@ -30,7 +30,8 @@
<div v-if="course.course_type === 'audio'" class="w-full relative"
style="border-bottom: 1px solid #F3F4F6;">
<!-- 音频播放器 -->
<AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay" @pause="onAudioPause" />
<AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay"
@pause="onAudioPause" />
</div>
<!-- 图片列表展示区域 -->
<div v-if="course.course_type === 'image'" class="w-full relative">
......@@ -74,14 +75,17 @@
</div>
<!-- 标签页区域 -->
<div class="px-4 py-3 bg-white" style="position: relative;">
<van-tabs v-model:active="activeTab" sticky animated swipeable shrink color="#4caf50" @change="handleTabChange">
<van-tabs v-model:active="activeTab" sticky animated swipeable shrink color="#4caf50"
@change="handleTabChange">
<van-tab title="介绍" name="intro">
</van-tab>
<van-tab :title-style="{ 'min-width': '50%' }" name="comments">
<template #title>评论({{ commentCount }})</template>
</van-tab>
</van-tabs>
<div v-if="task_list.length > 0" @click="goToCheckin" style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动</div>
<div v-if="task_list.length > 0" @click="goToCheckin"
style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动
</div>
</div>
</div>
......@@ -91,7 +95,9 @@
<div id="intro" class="py-4 px-4">
<h1 class="text-lg font-bold mb-2">{{ course.title }}</h1>
<div class="text-gray-500 text-sm flex items-center gap-2">
<span>开课时间 {{ course.schedule_time ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss') : '暂无' }}</span>
<span>开课时间 {{ course.schedule_time ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss') :
'暂无'
}}</span>
<!-- <span class="text-gray-300">|</span> -->
<!-- <span>没有字段{{ course.studyCount || 0 }}次学习</span> -->
</div>
......@@ -163,10 +169,11 @@
style="margin-right: 0.5rem;" />
<div class="flex-1 ml-3">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
<span class="font-medium text-gray-900">{{ comment.name || '匿名用户'
}}</span>
<div class="flex items-center space-x-1">
<span class="text-sm text-gray-500">{{ comment.like_count
}}</span>
}}</span>
&nbsp;
<van-icon :name="comment.is_like ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
......@@ -176,7 +183,7 @@
</div>
<p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
<div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time)
}}</div>
}}</div>
</div>
</div>
</div>
......@@ -218,12 +225,8 @@
</div>
<!-- 图片预览组件 -->
<van-image-preview
v-model:show="showPreview"
:images="previewImages"
:show-index="false"
:close-on-click-image="false"
>
<van-image-preview v-model:show="showPreview" :images="previewImages" :show-index="false"
:close-on-click-image="false">
<template #image="{ src, style, onLoad }">
<img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" />
</template>
......@@ -252,9 +255,14 @@
<div v-if="lesson.progress > 0 && lesson.progress < 100"
class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
上次看到</div>
<div class="text-black text-base mb-2" :class="{ 'text-green-600 font-medium' : courseId == lesson.id }">{{ lesson.title }} • <span class="text-sm text-gray-500">{{ course_type_maps[lesson.course_type] }}</span></div>
<div class="text-black text-base mb-2"
:class="{ 'text-green-600 font-medium': courseId == lesson.id }">{{ lesson.title }} •
<span class="text-sm text-gray-500">{{ course_type_maps[lesson.course_type] }}</span>
</div>
<div class="flex items-center text-sm text-gray-500">
<span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无' }}</span>
<span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') :
'暂无'
}}</span>
<span class="mx-2">|</span>
<span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
</div>
......@@ -268,13 +276,7 @@
<!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview -->
<!-- Office 文档预览弹窗 -->
<van-popup
v-model:show="officeShow"
position="center"
round
closeable
:style="{ height: '80%', width: '90%' }"
>
<van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }">
<div class="h-full flex flex-col">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3>
......@@ -294,135 +296,45 @@
</van-popup>
<!-- 音频播放器弹窗 -->
<van-popup
v-model:show="audioShow"
position="bottom"
round
closeable
:style="{ height: '60%', width: '100%' }"
>
<van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }">
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3>
<AudioPlayer
v-if="audioShow && audioUrl"
:songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full"
/>
<AudioPlayer v-if="audioShow && audioUrl" :songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full" />
</div>
</van-popup>
<!-- 视频播放器弹窗 -->
<van-popup
v-model:show="videoShow"
position="center"
round
closeable
:style="{ width: '95%', maxHeight: '80vh' }"
@close="stopPopupVideoPlay"
>
<van-popup v-model:show="videoShow" position="center" round closeable
:style="{ width: '95%', maxHeight: '80vh' }" @close="stopPopupVideoPlay">
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">视频预览</h3>
<div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<!-- 视频封面 -->
<div
v-show="!isPopupVideoPlaying"
<div v-show="!isPopupVideoPlaying"
class="absolute inset-0 bg-black flex items-center justify-center cursor-pointer"
@click="startPopupVideoPlay"
>
@click="startPopupVideoPlay">
<div class="w-16 h-16 bg-white bg-opacity-80 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-black ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer
v-show="isPopupVideoPlaying"
ref="popupVideoPlayerRef"
:video-url="videoUrl"
:video-id="videoTitle"
:autoplay="false"
class="w-full h-full"
@play="handlePopupVideoPlay"
@pause="handlePopupVideoPause"
/>
<VideoPlayer v-show="isPopupVideoPlaying" ref="popupVideoPlayerRef" :video-url="videoUrl"
:video-id="videoTitle" :autoplay="false" class="w-full h-full" @play="handlePopupVideoPlay"
@pause="handlePopupVideoPause" />
</div>
</div>
</van-popup>
<!-- 打卡弹窗 -->
<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
v-model:show="showDownloadFailDialog"
position="center"
round
closeable
:style="{ width: '85%', maxWidth: '400px' }"
>
<van-popup v-model:show="showDownloadFailDialog" position="center" round closeable
:style="{ width: '85%', maxWidth: '400px' }">
<div class="p-6">
<div class="text-center mb-4">
<van-icon name="warning-o" size="48" color="#ff6b6b" class="mb-2" />
......@@ -449,20 +361,10 @@
</div>
<div class="flex gap-3">
<van-button
block
type="default"
@click="showDownloadFailDialog = false"
class="flex-1"
>
<van-button block type="default" @click="showDownloadFailDialog = false" class="flex-1">
关闭
</van-button>
<van-button
block
type="primary"
@click="copyToClipboard(downloadFailInfo.fileUrl)"
class="flex-1"
>
<van-button block type="primary" @click="copyToClipboard(downloadFailInfo.fileUrl)" class="flex-1">
复制链接
</van-button>
</div>
......@@ -470,13 +372,8 @@
</van-popup>
<!-- 学习资料全屏弹窗 -->
<van-popup
v-model:show="showMaterialsPopup"
position="bottom"
:style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="true"
:lock-scroll="true"
>
<van-popup v-model:show="showMaterialsPopup" position="bottom" :style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="true" :lock-scroll="true">
<div class="flex flex-col h-full bg-gray-50">
<!-- 头部导航栏 -->
<div class="bg-white shadow-sm border-b border-gray-100">
......@@ -502,13 +399,8 @@
<!-- <span class="text-blue-600 text-sm font-medium">
{{ courseFile?.list ? courseFile.list.length : 0 }}
</span> -->
<van-button
@click="showMaterialsPopup = false"
type="default"
size="small"
round
class="w-8 h-8 p-0 bg-gray-100 border-0"
>
<van-button @click="showMaterialsPopup = false" type="default" size="small" round
class="w-8 h-8 p-0 bg-gray-100 border-0">
<van-icon name="cross" size="16" class="text-gray-600" />
</van-button>
</div>
......@@ -518,29 +410,26 @@
<!-- 文件列表 -->
<div class="flex-1 overflow-y-auto p-4 pb-safe">
<div v-if="courseFile?.list && courseFile.list.length > 0" class="space-y-4">
<FrostedGlass
v-for="(file, index) in courseFile.list"
:key="index"
:bgOpacity="70"
<FrostedGlass v-for="(file, index) in courseFile.list" :key="index" :bgOpacity="70"
blurLevel="md"
className="p-5 hover:bg-white/80 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] transform"
>
className="p-5 hover:bg-white/80 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] transform">
<!-- 文件信息 -->
<div class="flex items-start gap-4 mb-4 p-2">
<div class="w-12 h-12 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<van-icon
:name="getFileIcon(file.title || file.name)"
class="text-blue-600"
:size="22"
/>
<div
class="w-12 h-12 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<van-icon :name="getFileIcon(file.title || file.name)" class="text-blue-600"
:size="22" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-gray-900 mb-2 line-clamp-2">{{ file.title || file.name }}</h3>
<h3 class="text-base font-semibold text-gray-900 mb-2 line-clamp-2">{{ file.title ||
file.name }}</h3>
<div class="flex items-center justify-between gap-4 text-sm text-gray-600">
<div class="flex items-center gap-1">
<van-icon name="label-o" size="12" style="margin-right: 0.25rem;"/>
<van-icon name="label-o" size="12" style="margin-right: 0.25rem;" />
<span>{{ getFileType(file.title || file.name) }}</span>
<span class="ml-2">{{ file.size ? (file.size / 1024 / 1024).toFixed(2) + 'MB' : '' }}</span>
<span class="ml-2">{{ file.size ? (file.size / 1024 / 1024).toFixed(2) +
'MB' : ''
}}</span>
</div>
<!-- 复制地址按钮 -->
<!-- <button
......@@ -577,8 +466,8 @@
</template> -->
<!-- 统一使用移动端操作逻辑:根据文件类型显示不同的预览按钮 -->
<!-- Office 文档显示预览按钮 -->
<!-- <button
<!-- Office 文档显示预览按钮 -->
<!-- <button
v-if="isOfficeFile(file.url)"
@click="showOfficeDocument(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
......@@ -586,44 +475,33 @@
<van-icon name="description" size="16" />
文档预览
</button> -->
<!-- PDF文件显示在线查看按钮 -->
<button
v-if="file.url && file.url.toLowerCase().includes('.pdf')"
@click="showPdf(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="eye-o" size="16" />
在线查看
</button>
<!-- 音频文件显示音频播放按钮 -->
<button
v-else-if="isAudioFile(file.url)"
@click="showAudio(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="music-o" size="16" />
音频播放
</button>
<!-- 视频文件显示视频播放按钮 -->
<button
v-else-if="isVideoFile(file.url)"
@click="showVideo(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="video-o" size="16" />
视频播放
</button>
<!-- 图片文件显示图片预览按钮 -->
<button
v-else-if="isImageFile(file.url)"
@click="showImage(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="photo-o" size="16" />
图片预览
</button>
<!-- 其他文件显示下载按钮 -->
<!-- <button
<!-- PDF文件显示在线查看按钮 -->
<button v-if="file.url && file.url.toLowerCase().includes('.pdf')"
@click="showPdf(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="eye-o" size="16" />
在线查看
</button>
<!-- 音频文件显示音频播放按钮 -->
<button v-else-if="isAudioFile(file.url)" @click="showAudio(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="music-o" size="16" />
音频播放
</button>
<!-- 视频文件显示视频播放按钮 -->
<button v-else-if="isVideoFile(file.url)" @click="showVideo(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="video-o" size="16" />
视频播放
</button>
<!-- 图片文件显示图片预览按钮 -->
<button v-else-if="isImageFile(file.url)" @click="showImage(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="photo-o" size="16" />
图片预览
</button>
<!-- 其他文件显示下载按钮 -->
<!-- <button
@click="downloadFile(file)"
class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
......@@ -636,7 +514,8 @@
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<div
class="w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<van-icon name="folder-o" :size="28" class="text-gray-400" />
</div>
<p class="text-gray-500 text-base sm:text-lg mb-2 text-center">暂无学习资料</p>
......@@ -655,6 +534,7 @@ import { useTitle } from '@vueuse/core';
import VideoPlayer from '@/components/ui/VideoPlayer.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
import FrostedGlass from '@/components/ui/FrostedGlass.vue';
import CheckInDialog from '@/components/ui/CheckInDialog.vue';
// import OfficeViewer from '@/components/ui/OfficeViewer.vue';
import dayjs from 'dayjs';
import { formatDate, wxInfo } from '@/utils/tools'
......@@ -667,7 +547,6 @@ import { showToast } from 'vant';
// 导入接口
import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course';
import { addStudyRecordAPI } from "@/api/record";
import { checkinTaskAPI } from '@/api/checkin';
const route = useRoute();
const router = useRouter();
......@@ -763,6 +642,16 @@ const closeImagePreview = () => {
showPreview.value = false;
};
/**
* @function handleCheckInSuccess
* @description 打卡成功后提示并进行轻反馈
* @returns {void}
*/
const handleCheckInSuccess = () => {
// 打卡成功轻提示
showToast('打卡成功');
};
// 评论列表分页参数
const popupCommentList = ref([]);
const popupLoading = ref(false);
......@@ -824,7 +713,7 @@ const handleLessonClick = async (lesson) => {
title: item.title || '未命名音频',
artist: '',
url: item.url,
cover: item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'
cover: item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'
}));
} else {
audioList.value = [];
......@@ -851,7 +740,7 @@ const handleLessonClick = async (lesson) => {
// 图片附件或者附件不存在
// 进入后直接执行学习时长埋点
if(course.value?.course_type === 'image' || !course.value?.course_type) {
if (course.value?.course_type === 'image' || !course.value?.course_type) {
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
......@@ -1112,13 +1001,12 @@ onMounted(async () => {
});
}
default_list.value = task_list.value;
showTaskList.value = true;
// 统一弹窗组件后不再维护 default_list 与切换状态
}
}
// 图片附件或者附件不存在
// 进入后直接执行学习时长埋点
if(course.value.course_type === 'image' || !course.value.course_type) {
if (course.value.course_type === 'image' || !course.value.course_type) {
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
......@@ -1655,7 +1543,7 @@ const getOfficeFileType = (fileName) => {
const onAudioPlay = (audio, meta_id) => {
console.log('开始播放音频', audio);
// 学习时长埋点开始
startAction({meta_id});
startAction({ meta_id });
}
/**
......@@ -1808,90 +1696,19 @@ watch(showCatalog, (newVal) => {
// 打卡相关状态
const showCheckInDialog = ref(false);
const selectedCheckIn = ref(null);
const isCheckingIn = ref(false);
const checkInSuccess = ref(false);
const task_list = ref([]);
const timeout_task_list = ref([]);
const default_list = ref([]);
const showTaskList = ref(true);
const showTimeoutTaskList = 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;
}
/**
* 格式化文件大小
* @param {number} size - 文件大小(字节)
......