feat(CheckInList): 添加二级弹框课程选择功能
为CheckInList组件添加二级弹框功能,支持显示课程列表并选择 移除独立的less文件,将样式内联到组件中 通过provide/inject实现父子弹框联动控制
Showing
4 changed files
with
176 additions
and
29 deletions
| ... | @@ -20,7 +20,7 @@ | ... | @@ -20,7 +20,7 @@ |
| 20 | </template> | 20 | </template> |
| 21 | 21 | ||
| 22 | <script setup> | 22 | <script setup> |
| 23 | -import { ref, computed, onMounted } from 'vue' | 23 | +import { ref, computed, onMounted, provide } from 'vue' |
| 24 | import { useRoute, useRouter } from 'vue-router' | 24 | import { useRoute, useRouter } from 'vue-router' |
| 25 | import CheckInList from '@/components/ui/CheckInList.vue' | 25 | import CheckInList from '@/components/ui/CheckInList.vue' |
| 26 | import { getTaskListAPI } from "@/api/checkin"; | 26 | import { getTaskListAPI } from "@/api/checkin"; |
| ... | @@ -113,4 +113,20 @@ onMounted(async () => { | ... | @@ -113,4 +113,20 @@ onMounted(async () => { |
| 113 | await refresh_checkin_list() | 113 | await refresh_checkin_list() |
| 114 | } | 114 | } |
| 115 | }) | 115 | }) |
| 116 | + | ||
| 117 | +// 向子组件提供父级弹框的联动控制方法 | ||
| 118 | +provide('parent_popup_control', { | ||
| 119 | + /** | ||
| 120 | + * @function hideParent | ||
| 121 | + * @description 隐藏父级弹框。 | ||
| 122 | + * @returns {void} | ||
| 123 | + */ | ||
| 124 | + hideParent: () => emit('update:show', false), | ||
| 125 | + /** | ||
| 126 | + * @function reopenParent | ||
| 127 | + * @description 重新打开父级弹框。 | ||
| 128 | + * @returns {void} | ||
| 129 | + */ | ||
| 130 | + reopenParent: () => emit('update:show', true) | ||
| 131 | +}) | ||
| 116 | </script> | 132 | </script> | ... | ... |
src/components/ui/CheckInList.less
deleted
100644 → 0
| 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 | -} |
| ... | @@ -32,13 +32,54 @@ | ... | @@ -32,13 +32,54 @@ |
| 32 | <template v-else>提交打卡</template> | 32 | <template v-else>提交打卡</template> |
| 33 | </button> | 33 | </button> |
| 34 | </div> | 34 | </div> |
| 35 | + | ||
| 36 | + <!-- 二级弹框(课程列表,使用mock数据) --> | ||
| 37 | + <van-popup | ||
| 38 | + :show="inner_popup_show" | ||
| 39 | + @update:show="(v) => inner_popup_show = v" | ||
| 40 | + round | ||
| 41 | + position="bottom" | ||
| 42 | + teleport="body" | ||
| 43 | + :close-on-click-overlay="false" | ||
| 44 | + :style="{ minHeight: '40%', maxHeight: '80%', width: '100%' }" | ||
| 45 | + > | ||
| 46 | + <div class="p-4"> | ||
| 47 | + <div class="flex justify-between items-center mb-3"> | ||
| 48 | + <h3 class="font-medium">课程列表</h3> | ||
| 49 | + <van-icon name="cross" @click="close_inner_popup" /> | ||
| 50 | + </div> | ||
| 51 | + <div class="grid grid-cols-2 gap-4"> | ||
| 52 | + <div v-for="course in inner_courses" :key="course.id" class="rounded-xl overflow-hidden bg-white/80"> | ||
| 53 | + <div class="h-24 relative"> | ||
| 54 | + <img | ||
| 55 | + :src="format_cdn_image(course.imageUrl)" | ||
| 56 | + :alt="course.title" | ||
| 57 | + class="w-full h-full object-cover" | ||
| 58 | + /> | ||
| 59 | + <div v-if="course.isPurchased" class="absolute top-0 left-0 bg-amber-500 text-white text-xs px-2 py-1 rounded-br-lg font-medium" style="background-color: rgba(249, 115, 22, 0.85)"> | ||
| 60 | + 已购 | ||
| 61 | + </div> | ||
| 62 | + </div> | ||
| 63 | + <div class="p-2"> | ||
| 64 | + <h4 class="text-sm font-medium line-clamp-1">{{ course.title }}</h4> | ||
| 65 | + <p class="text-xs text-gray-500 line-clamp-1">{{ course.subtitle }}</p> | ||
| 66 | + <div class="flex justify-between items-center mt-2"> | ||
| 67 | + <span class="text-xs text-green-600">¥{{ course.price }}</span> | ||
| 68 | + <button class="text-xs px-2 py-1 bg-green-600 text-white rounded" @click="select_inner_course(course)">选择</button> | ||
| 69 | + </div> | ||
| 70 | + </div> | ||
| 71 | + </div> | ||
| 72 | + </div> | ||
| 73 | + </div> | ||
| 74 | + </van-popup> | ||
| 35 | </template> | 75 | </template> |
| 36 | 76 | ||
| 37 | <script setup> | 77 | <script setup> |
| 38 | -import { ref, computed } from 'vue' | 78 | +import { ref, computed, inject } from 'vue' |
| 39 | import { useRouter } from 'vue-router' | 79 | import { useRouter } from 'vue-router' |
| 40 | import { checkinTaskAPI } from '@/api/checkin' | 80 | import { checkinTaskAPI } from '@/api/checkin' |
| 41 | import { showToast } from 'vant' | 81 | import { showToast } from 'vant' |
| 82 | +import { courses as mock_courses } from '@/utils/mockData' | ||
| 42 | 83 | ||
| 43 | /** | 84 | /** |
| 44 | * @typedef {Object} CheckInItem | 85 | * @typedef {Object} CheckInItem |
| ... | @@ -56,6 +97,7 @@ const props = defineProps({ | ... | @@ -56,6 +97,7 @@ const props = defineProps({ |
| 56 | items: { type: Array, default: () => [] }, | 97 | items: { type: Array, default: () => [] }, |
| 57 | dense: { type: Boolean, default: false }, | 98 | dense: { type: Boolean, default: false }, |
| 58 | scroll: { type: Boolean, default: false }, | 99 | scroll: { type: Boolean, default: false }, |
| 100 | + plain: { type: Boolean, default: false }, | ||
| 59 | }) | 101 | }) |
| 60 | 102 | ||
| 61 | /** | 103 | /** |
| ... | @@ -67,6 +109,11 @@ const emit = defineEmits(['submit-success']) | ... | @@ -67,6 +109,11 @@ const emit = defineEmits(['submit-success']) |
| 67 | const router = useRouter() | 109 | const router = useRouter() |
| 68 | const selected_item = ref(null) | 110 | const selected_item = ref(null) |
| 69 | const submitting = ref(false) | 111 | const submitting = ref(false) |
| 112 | +// 父弹框联动(仅弹框模式下有效) | ||
| 113 | +const parent_popup = inject('parent_popup_control', null) | ||
| 114 | +// 二级弹框与数据 | ||
| 115 | +const inner_popup_show = ref(false) | ||
| 116 | +const inner_courses = ref([]) | ||
| 70 | 117 | ||
| 71 | /** | 118 | /** |
| 72 | * @function wrapper_class | 119 | * @function wrapper_class |
| ... | @@ -95,10 +142,30 @@ const scroll_style = computed(() => { | ... | @@ -95,10 +142,30 @@ const scroll_style = computed(() => { |
| 95 | * @returns {void} | 142 | * @returns {void} |
| 96 | */ | 143 | */ |
| 97 | const handle_select = (item) => { | 144 | const handle_select = (item) => { |
| 145 | + // TODO: 想要判断是否有二级菜单 | ||
| 146 | + const has_submenu = item.children && item.children.length > 0; | ||
| 147 | + // 如果有二级菜单需要特殊处理 | ||
| 148 | + if (has_submenu) { | ||
| 149 | + // 不同模式下弹框的显示逻辑是不一样的 | ||
| 150 | + if (props.plain) { | ||
| 151 | + // 普通模式:直接弹出本组件的popup | ||
| 152 | + open_inner_popup() | ||
| 153 | + } else { | ||
| 154 | + // 弹框模式:先隐藏父级弹框,再弹出本组件的popup,关闭后重新打开父级弹框 | ||
| 155 | + if (parent_popup && typeof parent_popup.hideParent === 'function') { | ||
| 156 | + parent_popup.hideParent() | ||
| 157 | + } | ||
| 158 | + // 略微延迟以确保父弹框状态切换完成 | ||
| 159 | + setTimeout(() => open_inner_popup(), 50) | ||
| 160 | + } | ||
| 161 | + return | ||
| 162 | + } | ||
| 163 | + // 点击已完成打卡项提示 | ||
| 98 | if (item.is_gray && item.task_type === 'checkin') { | 164 | if (item.is_gray && item.task_type === 'checkin') { |
| 99 | showToast('您已经完成了今天的打卡') | 165 | showToast('您已经完成了今天的打卡') |
| 100 | return | 166 | return |
| 101 | } | 167 | } |
| 168 | + // 点击上传项跳转上传页 | ||
| 102 | if (item.task_type === 'upload') { | 169 | if (item.task_type === 'upload') { |
| 103 | router.push({ | 170 | router.push({ |
| 104 | path: '/checkin/index', | 171 | path: '/checkin/index', |
| ... | @@ -133,8 +200,97 @@ const handle_submit = async () => { | ... | @@ -133,8 +200,97 @@ const handle_submit = async () => { |
| 133 | submitting.value = false | 200 | submitting.value = false |
| 134 | } | 201 | } |
| 135 | } | 202 | } |
| 203 | + | ||
| 204 | +/** | ||
| 205 | + * @function open_inner_popup | ||
| 206 | + * @description 打开二级弹框并填充课程列表(mock 数据)。 | ||
| 207 | + * @returns {void} | ||
| 208 | + */ | ||
| 209 | +const open_inner_popup = () => { | ||
| 210 | + inner_courses.value = build_course_list() | ||
| 211 | + inner_popup_show.value = true | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +/** | ||
| 215 | + * @function close_inner_popup | ||
| 216 | + * @description 关闭二级弹框;若处于弹框模式则重新打开父级弹框。 | ||
| 217 | + * @returns {void} | ||
| 218 | + */ | ||
| 219 | +const close_inner_popup = () => { | ||
| 220 | + inner_popup_show.value = false | ||
| 221 | + if (!props.plain && parent_popup && typeof parent_popup.reopenParent === 'function') { | ||
| 222 | + // 略微延迟,避免与二级弹框关闭动画冲突 | ||
| 223 | + setTimeout(() => parent_popup.reopenParent(), 150) | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +/** | ||
| 228 | + * @function build_course_list | ||
| 229 | + * @description 构造课程列表(来源于 mock 数据)。 | ||
| 230 | + * @returns {Array} | ||
| 231 | + */ | ||
| 232 | +const build_course_list = () => { | ||
| 233 | + return (mock_courses || []).map(c => ({ | ||
| 234 | + id: c.id, | ||
| 235 | + title: c.title, | ||
| 236 | + subtitle: c.subtitle, | ||
| 237 | + imageUrl: c.imageUrl, | ||
| 238 | + price: c.price, | ||
| 239 | + isPurchased: !!c.isPurchased | ||
| 240 | + })) | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +/** | ||
| 244 | + * @function select_inner_course | ||
| 245 | + * @description 选择二级弹框中的课程(占位行为:提示并关闭二级弹框)。 | ||
| 246 | + * @param {Object} course - 课程对象。 | ||
| 247 | + * @returns {void} | ||
| 248 | + */ | ||
| 249 | +const select_inner_course = (course) => { | ||
| 250 | + showToast(`已选择课程:${course.title}`) | ||
| 251 | + close_inner_popup() | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +/** | ||
| 255 | + * @function format_cdn_image | ||
| 256 | + * @description 若图片来自 cdn.ipadbiz.cn,则追加压缩参数;否则原样返回。 | ||
| 257 | + * @param {string} url - 图片地址。 | ||
| 258 | + * @returns {string} | ||
| 259 | + */ | ||
| 260 | +const format_cdn_image = (url) => { | ||
| 261 | + if (!url) return '' | ||
| 262 | + const host = 'cdn.ipadbiz.cn' | ||
| 263 | + if (url.includes(host)) { | ||
| 264 | + return `${url}?imageMogr2/thumbnail/200x/strip/quality/70` | ||
| 265 | + } | ||
| 266 | + return url | ||
| 267 | +} | ||
| 136 | </script> | 268 | </script> |
| 137 | 269 | ||
| 138 | <style lang="less" scoped> | 270 | <style lang="less" scoped> |
| 139 | -@import './CheckInList.less'; | 271 | +.CheckInListWrapper { |
| 272 | + // 列表项样式 | ||
| 273 | + .CheckInListItem { | ||
| 274 | + // 选中态样式 | ||
| 275 | + &.is-active { | ||
| 276 | + border-color: #bbf7d0; // 绿色边框 | ||
| 277 | + background-color: rgba(16, 185, 129, 0.1); // 轻微绿色背景 | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + // 图标样式 | ||
| 281 | + .Icon { | ||
| 282 | + &.is-active { | ||
| 283 | + background-color: #10b981; // 绿色激活背景 | ||
| 284 | + color: #ffffff; // 白色图标 | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + // 提交按钮样式 | ||
| 290 | + .SubmitBtn { | ||
| 291 | + &:disabled { | ||
| 292 | + opacity: 0.7; // 禁用态透明度 | ||
| 293 | + } | ||
| 294 | + } | ||
| 295 | +} | ||
| 140 | </style> | 296 | </style> | ... | ... |
| ... | @@ -80,7 +80,7 @@ | ... | @@ -80,7 +80,7 @@ |
| 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 | <template v-if="checkInTypes.length"> | 82 | <template v-if="checkInTypes.length"> |
| 83 | - <CheckInList :items="checkInTypes" dense scroll @submit-success="handleHomeCheckInSuccess" /> | 83 | + <CheckInList :items="checkInTypes" dense scroll :plain="true" @submit-success="handleHomeCheckInSuccess" /> |
| 84 | </template> | 84 | </template> |
| 85 | <template v-else> | 85 | <template v-else> |
| 86 | <div class="text-center"> | 86 | <div class="text-center"> | ... | ... |
-
Please register or login to post a comment