hookehuyr

feat(CheckInList): 添加二级弹框课程选择功能

为CheckInList组件添加二级弹框功能,支持显示课程列表并选择
移除独立的less文件,将样式内联到组件中
通过provide/inject实现父子弹框联动控制
...@@ -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>
......
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">
......