feat(components): 新增可复用打卡列表组件 CheckInList
提取首页与弹窗中重复的打卡列表 UI 与交互逻辑,封装为独立组件 新增组件支持滚动容器、紧凑布局等配置项 更新相关文档说明及类型定义
Showing
6 changed files
with
236 additions
and
217 deletions
| ... | @@ -88,3 +88,20 @@ https://oa-dev.onwall.cn/f/mlaj | ... | @@ -88,3 +88,20 @@ https://oa-dev.onwall.cn/f/mlaj |
| 88 | - `/src/views/profile/StudyCoursePage.vue` | 88 | - `/src/views/profile/StudyCoursePage.vue` |
| 89 | - `/src/views/study/StudyDetailPage.vue` | 89 | - `/src/views/study/StudyDetailPage.vue` |
| 90 | - 清理:上述页面已移除旧弹窗的冗余状态与方法(如 `default_list`、`showTaskList`、`showTimeoutTaskList`、`selectedCheckIn` 等),统一由组件内部处理。 | 90 | - 清理:上述页面已移除旧弹窗的冗余状态与方法(如 `default_list`、`showTaskList`、`showTimeoutTaskList`、`selectedCheckIn` 等),统一由组件内部处理。 |
| 91 | + | ||
| 92 | + - 打卡列表组件 CheckInList(复用) | ||
| 93 | + - 目的:抽取首页与弹窗内重复的“打卡类型列表 + 提交按钮”UI与交互逻辑,提升复用性与维护性。 | ||
| 94 | + - 位置:`/src/components/ui/CheckInList.vue`,样式补充:`/src/components/ui/CheckInList.less`(使用 Less 层级嵌套)。 | ||
| 95 | + - Props: | ||
| 96 | + - `items`:打卡任务数组,元素包含 `id`、`name`、`task_type`(`checkin`/`upload`)、`is_gray`。 | ||
| 97 | + - `dense`:是否使用更紧凑的栅格与间距。 | ||
| 98 | + - `scroll`:是否启用滚动容器(最大高度 13rem)。 | ||
| 99 | + - Emits: | ||
| 100 | + - `submit-success`:提交成功后触发,由父组件决定后续行为(轻提示、关闭弹窗等)。 | ||
| 101 | + - 行为: | ||
| 102 | + - 点击置灰的 `checkin` 项时提示“您已经完成了今天的打卡”。 | ||
| 103 | + - 点击 `upload` 类型时跳转到 `/checkin/index?id=xxx` 上传页面。 | ||
| 104 | + - 选择 `checkin` 类型后显示提交按钮,点击后调用接口提交;成功后抛出 `submit-success` 并重置选中项。 | ||
| 105 | + - 使用位置: | ||
| 106 | + - 首页:`/src/views/HomePage.vue`(替换原重复 UI,监听 `submit-success` 显示“打卡成功”)。 | ||
| 107 | + - 弹窗:`/src/components/ui/CheckInDialog.vue`(以 `active_list` 作为数据源,监听 `submit-success` 转发为 `check-in-success` 并延时关闭)。 | ... | ... |
| ... | @@ -13,6 +13,7 @@ declare module 'vue' { | ... | @@ -13,6 +13,7 @@ declare module 'vue' { |
| 13 | AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default'] | 13 | AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default'] |
| 14 | BottomNav: typeof import('./components/layout/BottomNav.vue')['default'] | 14 | BottomNav: typeof import('./components/layout/BottomNav.vue')['default'] |
| 15 | CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default'] | 15 | CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default'] |
| 16 | + CheckInList: typeof import('./components/ui/CheckInList.vue')['default'] | ||
| 16 | CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default'] | 17 | CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default'] |
| 17 | ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default'] | 18 | ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default'] |
| 18 | CourseCard: typeof import('./components/ui/CourseCard.vue')['default'] | 19 | CourseCard: typeof import('./components/ui/CourseCard.vue')['default'] | ... | ... |
| ... | @@ -14,67 +14,16 @@ | ... | @@ -14,67 +14,16 @@ |
| 14 | </h3> | 14 | </h3> |
| 15 | <van-icon name="cross" @click="handleClose" /> | 15 | <van-icon name="cross" @click="handleClose" /> |
| 16 | </div> | 16 | </div> |
| 17 | - | 17 | + <CheckInList :items="active_list" @submit-success="handleListSuccess" /> |
| 18 | - <div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center"> | ||
| 19 | - <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"> | ||
| 20 | - <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" /> | ||
| 21 | - </svg> | ||
| 22 | - <h4 class="text-green-700 font-medium mb-1">打卡成功!</h4> | ||
| 23 | - <!-- <p class="text-green-600 text-sm">+5 积分已添加到您的账户</p> --> | ||
| 24 | - </div> | ||
| 25 | - <template v-else> | ||
| 26 | - <div class="grid grid-cols-2 gap-4 py-2"> <!-- grid-cols-2 强制每行2列,gap控制间距 --> | ||
| 27 | - <button | ||
| 28 | - v-for="checkInType in active_list" | ||
| 29 | - :key="checkInType.id" | ||
| 30 | - class="flex flex-col items-center p-2 rounded-lg border transition-colors | ||
| 31 | - bg-white/70 border-gray-100 hover:bg-white" | ||
| 32 | - :class="{ | ||
| 33 | - 'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id | ||
| 34 | - }" | ||
| 35 | - @click="handleCheckInSelect(checkInType)" | ||
| 36 | - > | ||
| 37 | - <div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors | ||
| 38 | - bg-gray-100 text-gray-500" | ||
| 39 | - :class="{ | ||
| 40 | - 'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id | ||
| 41 | - }" | ||
| 42 | - > | ||
| 43 | - <van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" /> | ||
| 44 | - <van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" /> | ||
| 45 | - </div> | ||
| 46 | - <span :class="['text-xs', checkInType.is_gray ? 'text-gray-500' : '']">{{ checkInType.name }}</span> | ||
| 47 | - </button> | ||
| 48 | - </div> | ||
| 49 | - | ||
| 50 | - <div v-if="selectedCheckIn" class="mt-3"> | ||
| 51 | - <!-- <textarea | ||
| 52 | - :placeholder="`请输入${selectedCheckIn.name}内容...`" | ||
| 53 | - v-model="checkInContent" | ||
| 54 | - class="w-full p-3 border border-gray-200 rounded-lg text-sm resize-none h-24" | ||
| 55 | - /> --> | ||
| 56 | - <button | ||
| 57 | - 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" | ||
| 58 | - @click="handleCheckInSubmit" | ||
| 59 | - :disabled="isCheckingIn" | ||
| 60 | - > | ||
| 61 | - <template v-if="isCheckingIn"> | ||
| 62 | - <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div> | ||
| 63 | - 提交中... | ||
| 64 | - </template> | ||
| 65 | - <template v-else>提交打卡</template> | ||
| 66 | - </button> | ||
| 67 | - </div> | ||
| 68 | - </template> | ||
| 69 | </div> | 18 | </div> |
| 70 | </van-popup> | 19 | </van-popup> |
| 71 | </template> | 20 | </template> |
| 72 | 21 | ||
| 73 | <script setup> | 22 | <script setup> |
| 74 | import { ref, computed, onMounted } from 'vue' | 23 | import { ref, computed, onMounted } from 'vue' |
| 75 | -import { showToast } from 'vant' | ||
| 76 | import { useRoute, useRouter } from 'vue-router' | 24 | import { useRoute, useRouter } from 'vue-router' |
| 77 | -import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin"; | 25 | +import CheckInList from '@/components/ui/CheckInList.vue' |
| 26 | +import { getTaskListAPI } from "@/api/checkin"; | ||
| 78 | 27 | ||
| 79 | // 签到列表 | 28 | // 签到列表 |
| 80 | const checkInTypes = ref([]); | 29 | const checkInTypes = ref([]); |
| ... | @@ -93,11 +42,6 @@ const props = defineProps({ | ... | @@ -93,11 +42,6 @@ const props = defineProps({ |
| 93 | 42 | ||
| 94 | const emit = defineEmits(['update:show', 'check-in-success', 'check-in-data']) | 43 | const emit = defineEmits(['update:show', 'check-in-success', 'check-in-data']) |
| 95 | 44 | ||
| 96 | -const selectedCheckIn = ref(null) | ||
| 97 | -const checkInContent = ref('') | ||
| 98 | -const isCheckingIn = ref(false) | ||
| 99 | -const checkInSuccess = ref(false) | ||
| 100 | - | ||
| 101 | /** | 45 | /** |
| 102 | * @var {import('vue').Ref<'today'|'history'>} active_tab | 46 | * @var {import('vue').Ref<'today'|'history'>} active_tab |
| 103 | * @description 当前选中的任务标签页:今日或历史。 | 47 | * @description 当前选中的任务标签页:今日或历史。 |
| ... | @@ -128,77 +72,45 @@ const active_list = computed(() => { | ... | @@ -128,77 +72,45 @@ const active_list = computed(() => { |
| 128 | return history | 72 | return history |
| 129 | }) | 73 | }) |
| 130 | 74 | ||
| 131 | -const handleCheckInSelect = (type) => { | 75 | +/** |
| 132 | - if (type.is_gray && type.task_type === 'checkin') { | 76 | + * @function refresh_checkin_list |
| 133 | - showToast('您已经完成了今天的打卡') | 77 | + * @description 重新获取打卡任务列表,用于提交成功后更新置灰状态;同时向父组件透出最新数据。 |
| 134 | - return | 78 | + * @returns {Promise<void>} |
| 135 | - } | 79 | + */ |
| 136 | - if (type.task_type === 'upload') { | 80 | +const refresh_checkin_list = async () => { |
| 137 | - router.push({ | 81 | + const task = await getTaskListAPI() |
| 138 | - path: '/checkin/index', | 82 | + if (task?.code) { |
| 139 | - query: { | 83 | + // 重建本地签到任务列表(当未传入 props.items_today 时用于展示) |
| 140 | - id: type.id | 84 | + checkInTypes.value = (task.data || []).map(item => ({ |
| 141 | - } | 85 | + id: item.id, |
| 142 | - }) | 86 | + name: item.title, |
| 143 | - } else { | 87 | + task_type: item.task_type, |
| 144 | - selectedCheckIn.value = type; | 88 | + is_gray: item.is_gray |
| 89 | + })) | ||
| 90 | + // 向父组件透出最新数据,便于父组件自行刷新其持有的数据源 | ||
| 91 | + emit('check-in-data', task.data) | ||
| 145 | } | 92 | } |
| 146 | } | 93 | } |
| 147 | 94 | ||
| 148 | -const handleCheckInSubmit = async () => { | 95 | +/** |
| 149 | - if (!selectedCheckIn.value) { | 96 | + * @function handleListSuccess |
| 150 | - showToast('请选择打卡项目') | 97 | + * @description 子组件提交成功后,刷新任务列表并通知外部,然后关闭弹窗。 |
| 151 | - return | 98 | + * @returns {Promise<void>} |
| 152 | - } | 99 | + */ |
| 153 | - // if (!checkInContent.value.trim()) { | 100 | +const handleListSuccess = async () => { |
| 154 | - // showToast('请输入打卡内容') | 101 | + await refresh_checkin_list() |
| 155 | - // return | ||
| 156 | - // } | ||
| 157 | - | ||
| 158 | - isCheckingIn.value = true | ||
| 159 | - try { | ||
| 160 | - // API调用 | ||
| 161 | - const { code, data } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id }); | ||
| 162 | - if (code) { | ||
| 163 | - checkInSuccess.value = true | ||
| 164 | - // 重置表单 | ||
| 165 | - setTimeout(() => { | ||
| 166 | - checkInSuccess.value = false | ||
| 167 | - selectedCheckIn.value = null | ||
| 168 | - checkInContent.value = '' | ||
| 169 | - emit('update:show', false) | ||
| 170 | - }, 1500) | ||
| 171 | emit('check-in-success') | 102 | emit('check-in-success') |
| 172 | - } | 103 | + setTimeout(() => emit('update:show', false), 1500) |
| 173 | - } catch (error) { | ||
| 174 | - // showToast('打卡失败,请重试') | ||
| 175 | - } finally { | ||
| 176 | - isCheckingIn.value = false | ||
| 177 | - } | ||
| 178 | } | 104 | } |
| 179 | 105 | ||
| 180 | const handleClose = () => { | 106 | const handleClose = () => { |
| 181 | - selectedCheckIn.value = null | ||
| 182 | - checkInContent.value = '' | ||
| 183 | - checkInSuccess.value = false | ||
| 184 | emit('update:show', false) | 107 | emit('update:show', false) |
| 185 | } | 108 | } |
| 186 | 109 | ||
| 187 | onMounted(async () => { | 110 | onMounted(async () => { |
| 188 | // 当未从外部传入“今日任务”时,回退为组件内部获取的通用任务列表 | 111 | // 当未从外部传入“今日任务”时,回退为组件内部获取的通用任务列表 |
| 189 | if (!Array.isArray(props.items_today) || props.items_today.length === 0) { | 112 | if (!Array.isArray(props.items_today) || props.items_today.length === 0) { |
| 190 | - const task = await getTaskListAPI() | 113 | + await refresh_checkin_list() |
| 191 | - if (task.code) { | ||
| 192 | - emit('check-in-data', task.data) | ||
| 193 | - task.data.forEach(item => { | ||
| 194 | - checkInTypes.value.push({ | ||
| 195 | - id: item.id, | ||
| 196 | - name: item.title, | ||
| 197 | - task_type: item.task_type, | ||
| 198 | - is_gray: item.is_gray | ||
| 199 | - }) | ||
| 200 | - }) | ||
| 201 | - } | ||
| 202 | } | 114 | } |
| 203 | }) | 115 | }) |
| 204 | </script> | 116 | </script> | ... | ... |
src/components/ui/CheckInList.less
0 → 100644
| 1 | +.CheckInListWrapper { | ||
| 2 | + // 列表项样式 | ||
| 3 | + .CheckInListItem { | ||
| 4 | + // 选中态样式 | ||
| 5 | + &.is-active { | ||
| 6 | + border-color: #bbf7d0; // 绿色边框 | ||
| 7 | + background-color: rgba(16, 185, 129, 0.1); // 轻微绿色背景 | ||
| 8 | + } | ||
| 9 | + | ||
| 10 | + // 图标样式 | ||
| 11 | + .Icon { | ||
| 12 | + &.is-active { | ||
| 13 | + background-color: #10b981; // 绿色激活背景 | ||
| 14 | + color: #ffffff; // 白色图标 | ||
| 15 | + } | ||
| 16 | + } | ||
| 17 | + } | ||
| 18 | + | ||
| 19 | + // 提交按钮样式 | ||
| 20 | + .SubmitBtn { | ||
| 21 | + &:disabled { | ||
| 22 | + opacity: 0.7; // 禁用态透明度 | ||
| 23 | + } | ||
| 24 | + } | ||
| 25 | +} |
src/components/ui/CheckInList.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <!-- 列表主体 --> | ||
| 3 | + <div :class="wrapper_class" :style="scroll_style"> | ||
| 4 | + <button | ||
| 5 | + v-for="item in items" | ||
| 6 | + :key="item.id" | ||
| 7 | + class="CheckInListItem flex flex-col items-center p-2 rounded-lg border transition-colors bg-white/70 border-gray-100 hover:bg-white" | ||
| 8 | + :class="{ 'is-active': selected_item?.id === item.id }" | ||
| 9 | + @click="handle_select(item)" | ||
| 10 | + > | ||
| 11 | + <div class="Icon w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors bg-gray-100" | ||
| 12 | + :class="{ 'is-active': selected_item?.id === item.id }" | ||
| 13 | + > | ||
| 14 | + <van-icon v-if="item.task_type === 'checkin'" name="edit" size="1.5rem" :color="item.is_gray ? 'gray' : ''" /> | ||
| 15 | + <van-icon v-if="item.task_type === 'upload'" name="tosend" size="1.5rem" :color="item.is_gray ? 'gray' : ''" /> | ||
| 16 | + </div> | ||
| 17 | + <span :class="['text-xs', item.is_gray ? 'text-gray-500' : '']">{{ item.name }}</span> | ||
| 18 | + </button> | ||
| 19 | + </div> | ||
| 20 | + | ||
| 21 | + <!-- 提交按钮 --> | ||
| 22 | + <div v-if="selected_item" class="mt-3"> | ||
| 23 | + <button | ||
| 24 | + 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" | ||
| 25 | + @click="handle_submit" | ||
| 26 | + :disabled="submitting" | ||
| 27 | + > | ||
| 28 | + <template v-if="submitting"> | ||
| 29 | + <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div> | ||
| 30 | + 提交中... | ||
| 31 | + </template> | ||
| 32 | + <template v-else>提交打卡</template> | ||
| 33 | + </button> | ||
| 34 | + </div> | ||
| 35 | +</template> | ||
| 36 | + | ||
| 37 | +<script setup> | ||
| 38 | +import { ref, computed } from 'vue' | ||
| 39 | +import { useRouter } from 'vue-router' | ||
| 40 | +import { checkinTaskAPI } from '@/api/checkin' | ||
| 41 | +import { showToast } from 'vant' | ||
| 42 | + | ||
| 43 | +/** | ||
| 44 | + * @typedef {Object} CheckInItem | ||
| 45 | + * @property {number|string} id - 任务ID。 | ||
| 46 | + * @property {string} name - 任务名称。 | ||
| 47 | + * @property {string} task_type - 任务类型,`checkin` 或 `upload`。 | ||
| 48 | + * @property {boolean} [is_gray] - 是否置灰,表示今日已完成。 | ||
| 49 | + */ | ||
| 50 | + | ||
| 51 | +/** | ||
| 52 | + * @function props | ||
| 53 | + * @description 组件接收的属性定义。 | ||
| 54 | + */ | ||
| 55 | +const props = defineProps({ | ||
| 56 | + items: { type: Array, default: () => [] }, | ||
| 57 | + dense: { type: Boolean, default: false }, | ||
| 58 | + scroll: { type: Boolean, default: false }, | ||
| 59 | +}) | ||
| 60 | + | ||
| 61 | +/** | ||
| 62 | + * @function emits | ||
| 63 | + * @description 组件对外抛出的事件。 | ||
| 64 | + */ | ||
| 65 | +const emit = defineEmits(['submit-success']) | ||
| 66 | + | ||
| 67 | +const router = useRouter() | ||
| 68 | +const selected_item = ref(null) | ||
| 69 | +const submitting = ref(false) | ||
| 70 | + | ||
| 71 | +/** | ||
| 72 | + * @function wrapper_class | ||
| 73 | + * @description 计算列表容器类名。 | ||
| 74 | + * @returns {string[]} | ||
| 75 | + */ | ||
| 76 | +const wrapper_class = computed(() => [ | ||
| 77 | + 'CheckInListWrapper', | ||
| 78 | + props.dense ? 'grid grid-cols-2 gap-2 py-2' : 'grid grid-cols-2 gap-4 py-2', | ||
| 79 | +]) | ||
| 80 | + | ||
| 81 | +/** | ||
| 82 | + * @function scroll_style | ||
| 83 | + * @description 当 `scroll` 为真时启用滚动区域样式。 | ||
| 84 | + * @returns {Object} | ||
| 85 | + */ | ||
| 86 | +const scroll_style = computed(() => { | ||
| 87 | + if (!props.scroll) return {} | ||
| 88 | + return { maxHeight: '13rem', overflow: 'auto' } | ||
| 89 | +}) | ||
| 90 | + | ||
| 91 | +/** | ||
| 92 | + * @function handle_select | ||
| 93 | + * @description 处理打卡类型选择:已完成提示;上传型跳转;否则选中。 | ||
| 94 | + * @param {CheckInItem} item - 当前点击的打卡项。 | ||
| 95 | + * @returns {void} | ||
| 96 | + */ | ||
| 97 | +const handle_select = (item) => { | ||
| 98 | + if (item.is_gray && item.task_type === 'checkin') { | ||
| 99 | + showToast('您已经完成了今天的打卡') | ||
| 100 | + return | ||
| 101 | + } | ||
| 102 | + if (item.task_type === 'upload') { | ||
| 103 | + router.push({ | ||
| 104 | + path: '/checkin/index', | ||
| 105 | + query: { id: item.id }, | ||
| 106 | + }) | ||
| 107 | + return | ||
| 108 | + } | ||
| 109 | + selected_item.value = item | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +/** | ||
| 113 | + * @function handle_submit | ||
| 114 | + * @description 提交打卡调用接口,成功后抛出事件并复位。 | ||
| 115 | + * @returns {Promise<void>} | ||
| 116 | + */ | ||
| 117 | +const handle_submit = async () => { | ||
| 118 | + if (!selected_item.value) { | ||
| 119 | + showToast('请选择打卡项目') | ||
| 120 | + return | ||
| 121 | + } | ||
| 122 | + submitting.value = true | ||
| 123 | + try { | ||
| 124 | + const { code } = await checkinTaskAPI({ task_id: selected_item.value.id }) | ||
| 125 | + if (code) { | ||
| 126 | + emit('submit-success') | ||
| 127 | + showToast('打卡成功') | ||
| 128 | + selected_item.value = null | ||
| 129 | + } | ||
| 130 | + } catch (e) { | ||
| 131 | + // showToast('打卡失败,请重试') | ||
| 132 | + } finally { | ||
| 133 | + submitting.value = false | ||
| 134 | + } | ||
| 135 | +} | ||
| 136 | +</script> | ||
| 137 | + | ||
| 138 | +<style lang="less" scoped> | ||
| 139 | +@import './CheckInList.less'; | ||
| 140 | +</style> |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-03-20 19:55:21 | 2 | * @Date: 2025-03-20 19:55:21 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-12-04 16:52:14 | 4 | + * @LastEditTime: 2025-12-10 14:23:26 |
| 5 | * @FilePath: /mlaj/src/views/HomePage.vue | 5 | * @FilePath: /mlaj/src/views/HomePage.vue |
| 6 | * @Description: 美乐爱觉教育首页组件 | 6 | * @Description: 美乐爱觉教育首页组件 |
| 7 | * | 7 | * |
| ... | @@ -79,59 +79,8 @@ | ... | @@ -79,59 +79,8 @@ |
| 79 | <h3 class="font-medium">今日打卡</h3> | 79 | <h3 class="font-medium">今日打卡</h3> |
| 80 | <router-link to="/profile" class="text-green-600 text-sm">打卡记录</router-link> | 80 | <router-link to="/profile" class="text-green-600 text-sm">打卡记录</router-link> |
| 81 | </div> | 81 | </div> |
| 82 | - | ||
| 83 | <template v-if="checkInTypes.length"> | 82 | <template v-if="checkInTypes.length"> |
| 84 | - <div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center"> | 83 | + <CheckInList :items="checkInTypes" dense scroll @submit-success="handleHomeCheckInSuccess" /> |
| 85 | - <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"> | ||
| 86 | - <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" /> | ||
| 87 | - </svg> | ||
| 88 | - <h4 class="text-green-700 font-medium mb-1">打卡成功!</h4> | ||
| 89 | - <!-- <p class="text-green-600 text-sm">+5 积分已添加到您的账户</p> --> | ||
| 90 | - </div> | ||
| 91 | - <template v-else> | ||
| 92 | - <div class="grid grid-cols-2 gap-2 py-2" style="max-height: 13rem; overflow: auto;"> | ||
| 93 | - <button | ||
| 94 | - v-for="checkInType in checkInTypes" | ||
| 95 | - :key="checkInType.id" | ||
| 96 | - class="flex flex-col items-center p-2 rounded-lg border transition-colors | ||
| 97 | - bg-white/70 border-gray-100 hover:bg-white" | ||
| 98 | - :class="{ | ||
| 99 | - 'bg-green-100 border-green-200': selectedCheckIn?.id === checkInType.id | ||
| 100 | - }" | ||
| 101 | - @click="handleCheckInSelect(checkInType)" | ||
| 102 | - > | ||
| 103 | - <div class="w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors | ||
| 104 | - bg-gray-100" | ||
| 105 | - :class="{ | ||
| 106 | - 'bg-green-500 text-white': selectedCheckIn?.id === checkInType.id | ||
| 107 | - }" | ||
| 108 | - > | ||
| 109 | - <van-icon v-if="checkInType.task_type === 'checkin'" name="edit" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" /> | ||
| 110 | - <van-icon v-if="checkInType.task_type === 'upload'" name="tosend" size="1.5rem" :color="checkInType.is_gray ? 'gray' : ''" /> | ||
| 111 | - </div> | ||
| 112 | - <span :class="['text-xs', checkInType.is_gray ? 'text-gray-500' : '']">{{ checkInType.name }}</span> | ||
| 113 | - </button> | ||
| 114 | - </div> | ||
| 115 | - | ||
| 116 | - <div v-if="selectedCheckIn" class="mt-3"> | ||
| 117 | - <!-- <textarea | ||
| 118 | - :placeholder="`请输入${selectedCheckIn.name}内容...`" | ||
| 119 | - v-model="checkInContent" | ||
| 120 | - class="w-full p-3 border border-gray-200 rounded-lg text-sm resize-none h-24" | ||
| 121 | - /> --> | ||
| 122 | - <button | ||
| 123 | - 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" | ||
| 124 | - @click="handleCheckInSubmit" | ||
| 125 | - :disabled="isCheckingIn" | ||
| 126 | - > | ||
| 127 | - <template v-if="isCheckingIn"> | ||
| 128 | - <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div> | ||
| 129 | - 提交中... | ||
| 130 | - </template> | ||
| 131 | - <template v-else>提交打卡</template> | ||
| 132 | - </button> | ||
| 133 | - </div> | ||
| 134 | - </template> | ||
| 135 | </template> | 84 | </template> |
| 136 | <template v-else> | 85 | <template v-else> |
| 137 | <div class="text-center"> | 86 | <div class="text-center"> |
| ... | @@ -536,6 +485,7 @@ import LiveStreamCard from '@/components/ui/LiveStreamCard.vue' | ... | @@ -536,6 +485,7 @@ import LiveStreamCard from '@/components/ui/LiveStreamCard.vue' |
| 536 | import ActivityCard from '@/components/ui/ActivityCard.vue' | 485 | import ActivityCard from '@/components/ui/ActivityCard.vue' |
| 537 | import SummerCampCard from '@/components/ui/SummerCampCard.vue' | 486 | import SummerCampCard from '@/components/ui/SummerCampCard.vue' |
| 538 | import VideoPlayer from '@/components/ui/VideoPlayer.vue' | 487 | import VideoPlayer from '@/components/ui/VideoPlayer.vue' |
| 488 | +import CheckInList from '@/components/ui/CheckInList.vue' | ||
| 539 | 489 | ||
| 540 | // TODO: 导入模拟数据和工具函数 | 490 | // TODO: 导入模拟数据和工具函数 |
| 541 | import { liveStreams } from '@/utils/mockData' | 491 | import { liveStreams } from '@/utils/mockData' |
| ... | @@ -545,7 +495,7 @@ import { showToast } from 'vant' | ... | @@ -545,7 +495,7 @@ import { showToast } from 'vant' |
| 545 | 495 | ||
| 546 | // 导入接口 | 496 | // 导入接口 |
| 547 | import { getCourseListAPI } from "@/api/course"; | 497 | import { getCourseListAPI } from "@/api/course"; |
| 548 | -import { getTaskListAPI, checkinTaskAPI } from "@/api/checkin"; | 498 | +import { getTaskListAPI } from "@/api/checkin"; |
| 549 | 499 | ||
| 550 | // 视频播放状态管理 | 500 | // 视频播放状态管理 |
| 551 | const activeVideoIndex = ref(null); // 当前播放的视频索引 | 501 | const activeVideoIndex = ref(null); // 当前播放的视频索引 |
| ... | @@ -567,11 +517,10 @@ const { currentUser } = useAuth() | ... | @@ -567,11 +517,10 @@ const { currentUser } = useAuth() |
| 567 | 517 | ||
| 568 | // 响应式状态管理 | 518 | // 响应式状态管理 |
| 569 | const activeTab = ref('推荐') // 当前激活的内容标签页 | 519 | const activeTab = ref('推荐') // 当前激活的内容标签页 |
| 570 | -const selectedCheckIn = ref(null) // 选中的打卡类型 | 520 | +// 已移除:选中项与提交逻辑由通用组件内部处理 |
| 571 | -const checkInContent = ref('') // 打卡内容 | ||
| 572 | const currentSlide = ref(0) // 当前轮播图索引 | 521 | const currentSlide = ref(0) // 当前轮播图索引 |
| 573 | -const isCheckingIn = ref(false) // 打卡提交状态 | 522 | +// const isCheckingIn = ref(false) |
| 574 | -const checkInSuccess = ref(false) // 打卡成功状态 | 523 | +// const checkInSuccess = ref(false) |
| 575 | const displayedRecommendations = ref([]) // 当前显示的推荐内容 | 524 | const displayedRecommendations = ref([]) // 当前显示的推荐内容 |
| 576 | 525 | ||
| 577 | // | 526 | // |
| ... | @@ -738,48 +687,23 @@ const scrollToSlide = (index) => { | ... | @@ -738,48 +687,23 @@ const scrollToSlide = (index) => { |
| 738 | } | 687 | } |
| 739 | } | 688 | } |
| 740 | 689 | ||
| 741 | -// 打卡功能:处理打卡类型选择 | 690 | +/** |
| 742 | -const handleCheckInSelect = (checkInType) => { | 691 | + * @function handleHomeCheckInSuccess |
| 743 | - if (checkInType.is_gray && checkInType.task_type === 'checkin') { | 692 | + * @description 首页打卡成功后刷新签到任务列表,更新置灰状态,并给出轻提示。 |
| 744 | - showToast('您已经完成了今天的打卡') | 693 | + * @returns {Promise<void>} |
| 745 | - return | 694 | + */ |
| 746 | - } | 695 | +const handleHomeCheckInSuccess = async () => { |
| 747 | - if (checkInType.task_type === 'upload') { | 696 | + // 轻提示 |
| 748 | - $router.push({ | 697 | + showToast('打卡成功') |
| 749 | - path: '/checkin/index', | 698 | + // 统一刷新:重新获取签到任务列表并更新置灰状态 |
| 750 | - query: { | 699 | + const task = await getTaskListAPI() |
| 751 | - id: checkInType.id, | 700 | + if (task?.code) { |
| 752 | - }, | 701 | + checkInTypes.value = (task.data || []).map(item => ({ |
| 753 | - }) | 702 | + id: item.id, |
| 754 | - } else { | 703 | + name: item.title, |
| 755 | - selectedCheckIn.value = checkInType // 更新选中的打卡类型 | 704 | + task_type: item.task_type, |
| 756 | - checkInContent.value = '' // 清空打卡内容 | 705 | + is_gray: item.is_gray |
| 757 | - } | 706 | + })) |
| 758 | -} | ||
| 759 | - | ||
| 760 | -// 打卡功能:处理打卡提交 | ||
| 761 | -const handleCheckInSubmit = async () => { | ||
| 762 | - // 表单验证 | ||
| 763 | - if (!selectedCheckIn.value) { | ||
| 764 | - showToast('请选择打卡项目') | ||
| 765 | - return | ||
| 766 | - } | ||
| 767 | - // if (!checkInContent.value.trim()) { | ||
| 768 | - // showToast('请输入打卡内容') | ||
| 769 | - // return | ||
| 770 | - // } | ||
| 771 | - | ||
| 772 | - // API调用 | ||
| 773 | - const { code, data } = await checkinTaskAPI({ task_id: selectedCheckIn.value.id }); | ||
| 774 | - if (code) { | ||
| 775 | - isCheckingIn.value = true | ||
| 776 | - checkInSuccess.value = true | ||
| 777 | - checkInContent.value = '' | ||
| 778 | - setTimeout(() => { | ||
| 779 | - isCheckingIn.value = false | ||
| 780 | - selectedCheckIn.value = null | ||
| 781 | - checkInSuccess.value = false | ||
| 782 | - }, 1000); | ||
| 783 | } | 707 | } |
| 784 | } | 708 | } |
| 785 | 709 | ... | ... |
-
Please register or login to post a comment