hookehuyr

feat(课程): 添加课程打卡功能并重构路由结构

重构课程详情页路由为/profile/studyCourse/:id路径
在课程详情页和我的课程页添加打卡功能及相关弹窗
调整AppLayout组件支持无标题模式
......@@ -2,7 +2,7 @@
<template>
<div class="app-layout">
<!-- Header -->
<header class="app-header">
<header class="app-header" v-if="hasTitle">
<div v-if="showBack" class="header-back" @click="goBack">
<van-icon name="arrow-left" size="20" />
</div>
......@@ -13,7 +13,7 @@
</header>
<!-- Main Content -->
<main class="app-content" :class="{ 'has-bottom-nav': showBottomNav }">
<main class="app-content" :class="{ 'has-bottom-nav': showBottomNav, 'no-header': !hasTitle }">
<slot></slot>
</main>
......@@ -29,7 +29,7 @@
</template>
<script>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
export default {
......@@ -46,7 +46,11 @@ export default {
showBottomNav: {
type: Boolean,
default: true
}
},
hasTitle: {
type: Boolean,
default: true
},
},
setup(props) {
const router = useRouter()
......@@ -61,7 +65,7 @@ export default {
}
return {
goBack
goBack,
}
}
}
......@@ -119,4 +123,8 @@ export default {
.app-content.has-bottom-nav {
padding-bottom: 50px;
}
.app-content.no-header {
padding-top: 0;
}
</style>
......
......@@ -220,8 +220,8 @@ export const routes = [
}
},
{
path: '/studyCourse/:id',
component: () => import('@/views/study/studyCoursePage.vue'),
path: '/profile/studyCourse/:id',
component: () => import('@/views/profile/StudyCoursePage.vue'),
meta: {
title: '课程集合页面',
}
......
......@@ -94,6 +94,24 @@
</div>
</div>
<div v-if="activeTab === '打卡互动'">
<!-- 打卡区域 -->
<div class="py-4">
<div class="bg-white rounded-lg p-4 mb-4 cursor-pointer">
<div class="flex items-center justify-between" @click="goToCheckin()">
<div class="flex items-center gap-3">
<van-icon size="3rem" name="calendar-o" class="text-xl text-gray-600" />
<div>
<div class="text-base font-medium">打卡</div>
<div class="text-sm text-gray-500">关联{{ task_list.length }}个打卡</div>
</div>
</div>
<van-icon name="arrow" class="text-gray-400" />
</div>
</div>
</div>
</div>
<!-- <div v-if="activeTab === '课程亮点'">
<div class="space-y-3 text-gray-700">
<div v-html="course?.highlights"></div>
......@@ -224,7 +242,7 @@
color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md">
{{ course?.price !== '0.00' ? '立即' : '免费' }}购买
</van-button>
<van-button v-else @click="router.push(`/studyCourse/${course?.id}`)" round block
<van-button v-else @click="router.push(`/profile/studyCourse/${course?.id}`)" round block
color="linear-gradient(to right, #22c55e, #16a34a)" class="shadow-md">
查看课程
</van-button>
......@@ -234,6 +252,71 @@
<!-- 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" />
<van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" />
</div>
<span class="text-xs">{{ checkInType.title }}</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>
</AppLayout>
</template>
......@@ -252,6 +335,7 @@ import FrostedGlass from '@/components/ui/FrostedGlass.vue'
// 导入接口
import { getCourseDetailAPI, getGroupCommentListAPI, addGroupCommentAPI } from "@/api/course";
import { addFavoriteAPI, cancelFavoriteAPI } from "@/api/favorite";
import { checkinTaskAPI } from '@/api/checkin';
const $route = useRoute();
const $router = useRouter();
......@@ -272,6 +356,17 @@ const isPurchased = ref(false)
const isReviewed = ref(false)
const showReviewPopup = ref(false)
// 打卡相关状态
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)
const { addToCart, proceedToCheckout } = useCart()
// Handle favorite toggle
......@@ -306,6 +401,7 @@ const curriculumItems = computed(() => {
{ title: '课程大纲', active: activeTab.value === '课程大纲', show: !!(course.value.schedule && course.value.schedule.length > 0) },
// { title: '课程亮点', active: activeTab.value === '课程亮点', show: !!course.value.highlights },
// { title: '学习目标', active: activeTab.value === '学习目标', show: !!course.value.learning_goal },
{ title: '打卡互动', active: activeTab.value === '打卡互动', show: !!course.value.is_buy },
].filter(item => item.show);
});
......@@ -449,6 +545,93 @@ const goToStudyDetail = (item) => {
// 跳转详情
router.push(`/studyDetail/${item.id}`)
}
// 打卡相关方法
/**
* 处理打卡选择
* @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;
}
};
/**
* 打开打卡弹窗
*/
const goToCheckin = () => {
if(!default_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;
}
</script>
<style scoped>
......
......@@ -16,7 +16,7 @@
@load="onLoad"
class="px-4 py-3 space-y-4"
>
<CourseCard v-for="course in courses" :key="course.good_id" :course="course" :linkTo="`/studyCourse/${course.good_id}`" />
<CourseCard v-for="course in courses" :key="course.good_id" :course="course" :linkTo="`/profile/studyCourse/${course.good_id}`" />
</van-list>
<!-- 无数据提示 -->
......
......@@ -159,6 +159,6 @@ const handleImageError = (e) => {
// 跳转到课程详情页
const handleClick = (record) => {
$router.push(`/studyCourse/${record.id}`);
$router.push(`/profile/studyCourse/${record.id}`);
};
</script>
......
......@@ -3,6 +3,7 @@
* @Description: 课程详情页面
-->
<template>
<AppLayout :has-title="false" active-tab="profile">
<div class="study-course-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen">
<div v-if="course" class="flex flex-col h-screen">
<!-- 固定区域:课程封面和标签页 -->
......@@ -180,6 +181,7 @@
</div>
</van-popup>
</div>
</AppLayout>
</template>
<script setup>
......@@ -188,6 +190,7 @@ import { useTitle } from '@vueuse/core';
import { useRouter } from "vue-router";
import dayjs from 'dayjs';
import { showToast } from 'vant';
import AppLayout from '@/components/layout/AppLayout.vue';
// 导入接口
import { getCourseDetailAPI } from '@/api/course';
......