hookehuyr

feat(components): 新增可复用打卡列表组件 CheckInList

提取首页与弹窗中重复的打卡列表 UI 与交互逻辑,封装为独立组件
新增组件支持滚动容器、紧凑布局等配置项
更新相关文档说明及类型定义
......@@ -88,3 +88,20 @@ https://oa-dev.onwall.cn/f/mlaj
- `/src/views/profile/StudyCoursePage.vue`
- `/src/views/study/StudyDetailPage.vue`
- 清理:上述页面已移除旧弹窗的冗余状态与方法(如 `default_list``showTaskList``showTimeoutTaskList``selectedCheckIn` 等),统一由组件内部处理。
- 打卡列表组件 CheckInList(复用)
- 目的:抽取首页与弹窗内重复的“打卡类型列表 + 提交按钮”UI与交互逻辑,提升复用性与维护性。
- 位置:`/src/components/ui/CheckInList.vue`,样式补充:`/src/components/ui/CheckInList.less`(使用 Less 层级嵌套)。
- Props:
- `items`:打卡任务数组,元素包含 `id``name``task_type``checkin`/`upload`)、`is_gray`
- `dense`:是否使用更紧凑的栅格与间距。
- `scroll`:是否启用滚动容器(最大高度 13rem)。
- Emits:
- `submit-success`:提交成功后触发,由父组件决定后续行为(轻提示、关闭弹窗等)。
- 行为:
- 点击置灰的 `checkin` 项时提示“您已经完成了今天的打卡”。
- 点击 `upload` 类型时跳转到 `/checkin/index?id=xxx` 上传页面。
- 选择 `checkin` 类型后显示提交按钮,点击后调用接口提交;成功后抛出 `submit-success` 并重置选中项。
- 使用位置:
- 首页:`/src/views/HomePage.vue`(替换原重复 UI,监听 `submit-success` 显示“打卡成功”)。
- 弹窗:`/src/components/ui/CheckInDialog.vue`(以 `active_list` 作为数据源,监听 `submit-success` 转发为 `check-in-success` 并延时关闭)。
......
......@@ -13,6 +13,7 @@ declare module 'vue' {
AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default']
BottomNav: typeof import('./components/layout/BottomNav.vue')['default']
CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default']
CheckInList: typeof import('./components/ui/CheckInList.vue')['default']
CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default']
ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default']
CourseCard: typeof import('./components/ui/CourseCard.vue')['default']
......
......@@ -14,67 +14,16 @@
</h3>
<van-icon name="cross" @click="handleClose" />
</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>
<!-- <p class="text-green-600 text-sm">+5 积分已添加到您的账户</p> -->
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4 py-2"> <!-- grid-cols-2 强制每行2列,gap控制间距 -->
<button
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"
: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">
<!-- <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"
: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>
<CheckInList :items="active_list" @submit-success="handleListSuccess" />
</div>
</van-popup>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin";
import CheckInList from '@/components/ui/CheckInList.vue'
import { getTaskListAPI } from "@/api/checkin";
// 签到列表
const checkInTypes = ref([]);
......@@ -93,11 +42,6 @@ const props = defineProps({
const emit = defineEmits(['update:show', 'check-in-success', 'check-in-data'])
const selectedCheckIn = ref(null)
const checkInContent = ref('')
const isCheckingIn = ref(false)
const checkInSuccess = ref(false)
/**
* @var {import('vue').Ref<'today'|'history'>} active_tab
* @description 当前选中的任务标签页:今日或历史。
......@@ -128,77 +72,45 @@ const active_list = computed(() => {
return history
})
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
}
})
} else {
selectedCheckIn.value = type;
}
/**
* @function refresh_checkin_list
* @description 重新获取打卡任务列表,用于提交成功后更新置灰状态;同时向父组件透出最新数据。
* @returns {Promise<void>}
*/
const refresh_checkin_list = async () => {
const task = await getTaskListAPI()
if (task?.code) {
// 重建本地签到任务列表(当未传入 props.items_today 时用于展示)
checkInTypes.value = (task.data || []).map(item => ({
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray
}))
// 向父组件透出最新数据,便于父组件自行刷新其持有的数据源
emit('check-in-data', task.data)
}
}
const handleCheckInSubmit = async () => {
if (!selectedCheckIn.value) {
showToast('请选择打卡项目')
return
}
// if (!checkInContent.value.trim()) {
// showToast('请输入打卡内容')
// return
// }
isCheckingIn.value = true
try {
// API调用
const { code, data } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
if (code) {
checkInSuccess.value = true
// 重置表单
setTimeout(() => {
checkInSuccess.value = false
selectedCheckIn.value = null
checkInContent.value = ''
emit('update:show', false)
}, 1500)
emit('check-in-success')
}
} catch (error) {
// showToast('打卡失败,请重试')
} finally {
isCheckingIn.value = false
}
/**
* @function handleListSuccess
* @description 子组件提交成功后,刷新任务列表并通知外部,然后关闭弹窗。
* @returns {Promise<void>}
*/
const handleListSuccess = async () => {
await refresh_checkin_list()
emit('check-in-success')
setTimeout(() => emit('update:show', false), 1500)
}
const handleClose = () => {
selectedCheckIn.value = null
checkInContent.value = ''
checkInSuccess.value = false
emit('update:show', false)
}
onMounted(async () => {
// 当未从外部传入“今日任务”时,回退为组件内部获取的通用任务列表
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
})
})
}
await refresh_checkin_list()
}
})
</script>
......
.CheckInListWrapper {
// 列表项样式
.CheckInListItem {
// 选中态样式
&.is-active {
border-color: #bbf7d0; // 绿色边框
background-color: rgba(16, 185, 129, 0.1); // 轻微绿色背景
}
// 图标样式
.Icon {
&.is-active {
background-color: #10b981; // 绿色激活背景
color: #ffffff; // 白色图标
}
}
}
// 提交按钮样式
.SubmitBtn {
&:disabled {
opacity: 0.7; // 禁用态透明度
}
}
}
<template>
<!-- 列表主体 -->
<div :class="wrapper_class" :style="scroll_style">
<button
v-for="item in items"
:key="item.id"
class="CheckInListItem flex flex-col items-center p-2 rounded-lg border transition-colors bg-white/70 border-gray-100 hover:bg-white"
:class="{ 'is-active': selected_item?.id === item.id }"
@click="handle_select(item)"
>
<div class="Icon w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors bg-gray-100"
:class="{ 'is-active': selected_item?.id === item.id }"
>
<van-icon v-if="item.task_type === 'checkin'" name="edit" size="1.5rem" :color="item.is_gray ? 'gray' : ''" />
<van-icon v-if="item.task_type === 'upload'" name="tosend" size="1.5rem" :color="item.is_gray ? 'gray' : ''" />
</div>
<span :class="['text-xs', item.is_gray ? 'text-gray-500' : '']">{{ item.name }}</span>
</button>
</div>
<!-- 提交按钮 -->
<div v-if="selected_item" class="mt-3">
<button
class="SubmitBtn 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="handle_submit"
:disabled="submitting"
>
<template v-if="submitting">
<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>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { checkinTaskAPI } from '@/api/checkin'
import { showToast } from 'vant'
/**
* @typedef {Object} CheckInItem
* @property {number|string} id - 任务ID。
* @property {string} name - 任务名称。
* @property {string} task_type - 任务类型,`checkin` 或 `upload`。
* @property {boolean} [is_gray] - 是否置灰,表示今日已完成。
*/
/**
* @function props
* @description 组件接收的属性定义。
*/
const props = defineProps({
items: { type: Array, default: () => [] },
dense: { type: Boolean, default: false },
scroll: { type: Boolean, default: false },
})
/**
* @function emits
* @description 组件对外抛出的事件。
*/
const emit = defineEmits(['submit-success'])
const router = useRouter()
const selected_item = ref(null)
const submitting = ref(false)
/**
* @function wrapper_class
* @description 计算列表容器类名。
* @returns {string[]}
*/
const wrapper_class = computed(() => [
'CheckInListWrapper',
props.dense ? 'grid grid-cols-2 gap-2 py-2' : 'grid grid-cols-2 gap-4 py-2',
])
/**
* @function scroll_style
* @description 当 `scroll` 为真时启用滚动区域样式。
* @returns {Object}
*/
const scroll_style = computed(() => {
if (!props.scroll) return {}
return { maxHeight: '13rem', overflow: 'auto' }
})
/**
* @function handle_select
* @description 处理打卡类型选择:已完成提示;上传型跳转;否则选中。
* @param {CheckInItem} item - 当前点击的打卡项。
* @returns {void}
*/
const handle_select = (item) => {
if (item.is_gray && item.task_type === 'checkin') {
showToast('您已经完成了今天的打卡')
return
}
if (item.task_type === 'upload') {
router.push({
path: '/checkin/index',
query: { id: item.id },
})
return
}
selected_item.value = item
}
/**
* @function handle_submit
* @description 提交打卡调用接口,成功后抛出事件并复位。
* @returns {Promise<void>}
*/
const handle_submit = async () => {
if (!selected_item.value) {
showToast('请选择打卡项目')
return
}
submitting.value = true
try {
const { code } = await checkinTaskAPI({ task_id: selected_item.value.id })
if (code) {
emit('submit-success')
showToast('打卡成功')
selected_item.value = null
}
} catch (e) {
// showToast('打卡失败,请重试')
} finally {
submitting.value = false
}
}
</script>
<style lang="less" scoped>
@import './CheckInList.less';
</style>
<!--
* @Date: 2025-03-20 19:55:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-04 16:52:14
* @LastEditTime: 2025-12-10 14:23:26
* @FilePath: /mlaj/src/views/HomePage.vue
* @Description: 美乐爱觉教育首页组件
*
......@@ -79,59 +79,8 @@
<h3 class="font-medium">今日打卡</h3>
<router-link to="/profile" class="text-green-600 text-sm">打卡记录</router-link>
</div>
<template v-if="checkInTypes.length">
<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>
<!-- <p class="text-green-600 text-sm">+5 积分已添加到您的账户</p> -->
</div>
<template v-else>
<div class="grid grid-cols-2 gap-2 py-2" style="max-height: 13rem; overflow: auto;">
<button
v-for="checkInType in checkInTypes"
: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"
: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">
<!-- <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"
: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>
<CheckInList :items="checkInTypes" dense scroll @submit-success="handleHomeCheckInSuccess" />
</template>
<template v-else>
<div class="text-center">
......@@ -536,6 +485,7 @@ import LiveStreamCard from '@/components/ui/LiveStreamCard.vue'
import ActivityCard from '@/components/ui/ActivityCard.vue'
import SummerCampCard from '@/components/ui/SummerCampCard.vue'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
import CheckInList from '@/components/ui/CheckInList.vue'
// TODO: 导入模拟数据和工具函数
import { liveStreams } from '@/utils/mockData'
......@@ -545,7 +495,7 @@ import { showToast } from 'vant'
// 导入接口
import { getCourseListAPI } from "@/api/course";
import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin";
import { getTaskListAPI } from "@/api/checkin";
// 视频播放状态管理
const activeVideoIndex = ref(null); // 当前播放的视频索引
......@@ -567,11 +517,10 @@ const { currentUser } = useAuth()
// 响应式状态管理
const activeTab = ref('推荐') // 当前激活的内容标签页
const selectedCheckIn = ref(null) // 选中的打卡类型
const checkInContent = ref('') // 打卡内容
// 已移除:选中项与提交逻辑由通用组件内部处理
const currentSlide = ref(0) // 当前轮播图索引
const isCheckingIn = ref(false) // 打卡提交状态
const checkInSuccess = ref(false) // 打卡成功状态
// const isCheckingIn = ref(false)
// const checkInSuccess = ref(false)
const displayedRecommendations = ref([]) // 当前显示的推荐内容
//
......@@ -738,49 +687,24 @@ const scrollToSlide = (index) => {
}
}
// 打卡功能:处理打卡类型选择
const handleCheckInSelect = (checkInType) => {
if (checkInType.is_gray && checkInType.task_type === 'checkin') {
showToast('您已经完成了今天的打卡')
return
}
if (checkInType.task_type === 'upload') {
$router.push({
path: '/checkin/index',
query: {
id: checkInType.id,
},
})
} else {
selectedCheckIn.value = checkInType // 更新选中的打卡类型
checkInContent.value = '' // 清空打卡内容
}
}
// 打卡功能:处理打卡提交
const handleCheckInSubmit = async () => {
// 表单验证
if (!selectedCheckIn.value) {
showToast('请选择打卡项目')
return
}
// if (!checkInContent.value.trim()) {
// showToast('请输入打卡内容')
// return
// }
// API调用
const { code, data } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id });
if (code) {
isCheckingIn.value = true
checkInSuccess.value = true
checkInContent.value = ''
setTimeout(() => {
isCheckingIn.value = false
selectedCheckIn.value = null
checkInSuccess.value = false
}, 1000);
}
/**
* @function handleHomeCheckInSuccess
* @description 首页打卡成功后刷新签到任务列表,更新置灰状态,并给出轻提示。
* @returns {Promise<void>}
*/
const handleHomeCheckInSuccess = async () => {
// 轻提示
showToast('打卡成功')
// 统一刷新:重新获取签到任务列表并更新置灰状态
const task = await getTaskListAPI()
if (task?.code) {
checkInTypes.value = (task.data || []).map(item => ({
id: item.id,
name: item.title,
task_type: item.task_type,
is_gray: item.is_gray
}))
}
}
const contentRef = ref(null) // 内容区域的ref引用
......