feat(JoinFamily): 添加家庭选择弹窗功能
实现家庭选择弹窗组件,包含搜索、列表展示和确认功能 将@click事件统一改为@tap以优化移动端体验 添加过渡动画和样式优化
Showing
3 changed files
with
326 additions
and
13 deletions
| ... | @@ -17,6 +17,7 @@ declare module 'vue' { | ... | @@ -17,6 +17,7 @@ declare module 'vue' { |
| 17 | NutInput: typeof import('@nutui/nutui-taro')['Input'] | 17 | NutInput: typeof import('@nutui/nutui-taro')['Input'] |
| 18 | NutPicker: typeof import('@nutui/nutui-taro')['Picker'] | 18 | NutPicker: typeof import('@nutui/nutui-taro')['Picker'] |
| 19 | NutPopup: typeof import('@nutui/nutui-taro')['Popup'] | 19 | NutPopup: typeof import('@nutui/nutui-taro')['Popup'] |
| 20 | + NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar'] | ||
| 20 | NutToast: typeof import('@nutui/nutui-taro')['Toast'] | 21 | NutToast: typeof import('@nutui/nutui-taro')['Toast'] |
| 21 | Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] | 22 | Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] |
| 22 | PointsCollector: typeof import('./src/components/PointsCollector.vue')['default'] | 23 | PointsCollector: typeof import('./src/components/PointsCollector.vue')['default'] | ... | ... |
| ... | @@ -15,6 +15,7 @@ | ... | @@ -15,6 +15,7 @@ |
| 15 | display: flex; | 15 | display: flex; |
| 16 | align-items: center; | 16 | align-items: center; |
| 17 | justify-content: center; | 17 | justify-content: center; |
| 18 | + transition: border-color 0.2s; | ||
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | .motto-input { | 21 | .motto-input { |
| ... | @@ -32,7 +33,48 @@ | ... | @@ -32,7 +33,48 @@ |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 34 | .identity-title { | 35 | .identity-title { |
| 35 | - font-size: 1.25rem; | ||
| 36 | - font-weight: 700; | ||
| 37 | - margin-bottom: 1rem; | ||
| 38 | -} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 36 | + font-size: 32rpx; | ||
| 37 | + font-weight: 600; | ||
| 38 | + color: #374151; | ||
| 39 | + margin-bottom: 24rpx; | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +// 家庭选择弹窗样式 | ||
| 43 | +.family-selector-container { | ||
| 44 | + display: flex; | ||
| 45 | + flex-direction: column; | ||
| 46 | + height: 100%; | ||
| 47 | + background: white; | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +.family-item { | ||
| 51 | + cursor: pointer; | ||
| 52 | + transition: all 0.2s ease; | ||
| 53 | + | ||
| 54 | + &:hover { | ||
| 55 | + transform: translateY(-2rpx); | ||
| 56 | + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + &:active { | ||
| 60 | + transform: translateY(0); | ||
| 61 | + } | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +// 文本截断样式 | ||
| 65 | +.line-clamp-2 { | ||
| 66 | + display: -webkit-box; | ||
| 67 | + -webkit-line-clamp: 2; | ||
| 68 | + -webkit-box-orient: vertical; | ||
| 69 | + overflow: hidden; | ||
| 70 | + text-overflow: ellipsis; | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +// 间距样式 | ||
| 74 | +.space-y-3 > * + * { | ||
| 75 | + margin-top: 24rpx; | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +.space-x-3 > * + * { | ||
| 79 | + margin-left: 24rpx; | ||
| 80 | +} | ... | ... |
| ... | @@ -45,7 +45,7 @@ | ... | @@ -45,7 +45,7 @@ |
| 45 | <view | 45 | <view |
| 46 | v-for="role in familyRoles" | 46 | v-for="role in familyRoles" |
| 47 | :key="role.id" | 47 | :key="role.id" |
| 48 | - @click="selectedRole = role.id" | 48 | + @tap="selectedRole = role.id" |
| 49 | :class="[ | 49 | :class="[ |
| 50 | 'w-[calc(49%-4rpx)] py-3 rounded-lg border text-center flex flex-col items-center gap-1', | 50 | 'w-[calc(49%-4rpx)] py-3 rounded-lg border text-center flex flex-col items-center gap-1', |
| 51 | selectedRole === role.id | 51 | selectedRole === role.id |
| ... | @@ -60,7 +60,7 @@ | ... | @@ -60,7 +60,7 @@ |
| 60 | </view> | 60 | </view> |
| 61 | <!-- Submit Button --> | 61 | <!-- Submit Button --> |
| 62 | <view | 62 | <view |
| 63 | - @click="handleJoinFamily" | 63 | + @tap="handleJoinFamily" |
| 64 | :disabled="!isComplete" | 64 | :disabled="!isComplete" |
| 65 | :class="[ | 65 | :class="[ |
| 66 | 'w-full py-3 text-white text-lg font-medium rounded-lg mt-auto text-center', | 66 | 'w-full py-3 text-white text-lg font-medium rounded-lg mt-auto text-center', |
| ... | @@ -70,13 +70,104 @@ | ... | @@ -70,13 +70,104 @@ |
| 70 | 加入家庭 | 70 | 加入家庭 |
| 71 | </view> | 71 | </view> |
| 72 | </view> | 72 | </view> |
| 73 | + | ||
| 74 | + <!-- 家庭选择弹窗 --> | ||
| 75 | + <nut-popup | ||
| 76 | + v-model:visible="showFamilySelector" | ||
| 77 | + position="bottom" | ||
| 78 | + :style="{ height: '80vh' }" | ||
| 79 | + round | ||
| 80 | + closeable | ||
| 81 | + @close="closeFamilySelector" | ||
| 82 | + > | ||
| 83 | + <view class="family-selector-container"> | ||
| 84 | + <!-- 标题 --> | ||
| 85 | + <view class="text-lg font-bold text-center py-4 border-b border-gray-100"> | ||
| 86 | + 选择要加入的家庭 | ||
| 87 | + </view> | ||
| 88 | + | ||
| 89 | + <!-- 搜索框 --> | ||
| 90 | + <view class="p-4"> | ||
| 91 | + <nut-searchbar | ||
| 92 | + v-model="searchKeyword" | ||
| 93 | + placeholder="搜索家庭名称" | ||
| 94 | + @search="handleSearch" | ||
| 95 | + @clear="handleClearSearch" | ||
| 96 | + /> | ||
| 97 | + </view> | ||
| 98 | + | ||
| 99 | + <!-- 家庭列表 --> | ||
| 100 | + <view class="flex-1 px-4 pb-4"> | ||
| 101 | + <view | ||
| 102 | + ref="familyListContainer" | ||
| 103 | + class="space-y-3 overflow-y-auto" | ||
| 104 | + :style="{ maxHeight: familyListHeight + 'rpx' }" | ||
| 105 | + > | ||
| 106 | + <view | ||
| 107 | + v-for="family in filteredFamilies" | ||
| 108 | + :key="family.id" | ||
| 109 | + @tap="selectFamily(family.id)" | ||
| 110 | + :class="[ | ||
| 111 | + 'family-item p-4 border rounded-lg flex items-center space-x-3', | ||
| 112 | + selectedFamilyId === family.id | ||
| 113 | + ? 'border-blue-500 bg-blue-50' | ||
| 114 | + : 'border-gray-200 bg-white' | ||
| 115 | + ]" | ||
| 116 | + > | ||
| 117 | + <!-- 家庭头像 --> | ||
| 118 | + <view class="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden"> | ||
| 119 | + <image | ||
| 120 | + v-if="family.avatar" | ||
| 121 | + :src="family.avatar" | ||
| 122 | + class="w-full h-full object-cover" | ||
| 123 | + /> | ||
| 124 | + <view v-else class="text-gray-500 text-xs">头像</view> | ||
| 125 | + </view> | ||
| 126 | + | ||
| 127 | + <!-- 家庭信息 --> | ||
| 128 | + <view class="flex-1"> | ||
| 129 | + <view class="font-medium text-gray-900 mb-1">{{ family.name }}</view> | ||
| 130 | + <view class="text-sm text-gray-600 line-clamp-2">{{ family.description }}</view> | ||
| 131 | + </view> | ||
| 132 | + | ||
| 133 | + <!-- 选中状态 --> | ||
| 134 | + <view v-if="selectedFamilyId === family.id" class="text-blue-500"> | ||
| 135 | + <Check size="20" /> | ||
| 136 | + </view> | ||
| 137 | + </view> | ||
| 138 | + </view> | ||
| 139 | + </view> | ||
| 140 | + | ||
| 141 | + <!-- 底部按钮 --> | ||
| 142 | + <view class="flex gap-3 p-4 border-t border-gray-100"> | ||
| 143 | + <nut-button | ||
| 144 | + @click="closeFamilySelector" | ||
| 145 | + class="flex-1" | ||
| 146 | + type="default" | ||
| 147 | + size="large" | ||
| 148 | + plain | ||
| 149 | + > | ||
| 150 | + 关闭 | ||
| 151 | + </nut-button> | ||
| 152 | + <nut-button | ||
| 153 | + @click="confirmJoinFamily" | ||
| 154 | + class="flex-1" | ||
| 155 | + :color="selectedFamilyId ? '#3b82f6' : 'gray'" | ||
| 156 | + :disabled="!selectedFamilyId" | ||
| 157 | + size="large" | ||
| 158 | + > | ||
| 159 | + 确认 | ||
| 160 | + </nut-button> | ||
| 161 | + </view> | ||
| 162 | + </view> | ||
| 163 | + </nut-popup> | ||
| 73 | </view> | 164 | </view> |
| 74 | </template> | 165 | </template> |
| 75 | 166 | ||
| 76 | <script setup> | 167 | <script setup> |
| 77 | -import { ref, computed, nextTick } from 'vue'; | 168 | +import { ref, computed, nextTick, onMounted, watch } from 'vue'; |
| 78 | import Taro from '@tarojs/taro'; | 169 | import Taro from '@tarojs/taro'; |
| 79 | -import { My } from '@nutui/icons-vue-taro'; | 170 | +import { My, Check } from '@nutui/icons-vue-taro'; |
| 80 | import AppHeader from '../../components/AppHeader.vue'; | 171 | import AppHeader from '../../components/AppHeader.vue'; |
| 81 | 172 | ||
| 82 | const mottoChars = ref(['', '', '', '']); | 173 | const mottoChars = ref(['', '', '', '']); |
| ... | @@ -84,6 +175,14 @@ const selectedRole = ref(''); | ... | @@ -84,6 +175,14 @@ const selectedRole = ref(''); |
| 84 | const inputRefs = ref([]); | 175 | const inputRefs = ref([]); |
| 85 | const focusedIndex = ref(-1); | 176 | const focusedIndex = ref(-1); |
| 86 | 177 | ||
| 178 | +// 弹窗相关数据 | ||
| 179 | +const showFamilySelector = ref(false); | ||
| 180 | +const searchKeyword = ref(''); | ||
| 181 | +const selectedFamilyId = ref(''); | ||
| 182 | +const mockFamilies = ref([]); | ||
| 183 | +const familyListContainer = ref(null); | ||
| 184 | +const familyListHeight = ref(400); // 默认高度 | ||
| 185 | + | ||
| 87 | const handleInputChange = (index, value) => { | 186 | const handleInputChange = (index, value) => { |
| 88 | // 允许输入多个字符,但只保留第一个有效字符(汉字、数字、大小写字母),兼容输入法 | 187 | // 允许输入多个字符,但只保留第一个有效字符(汉字、数字、大小写字母),兼容输入法 |
| 89 | if (value) { | 188 | if (value) { |
| ... | @@ -141,15 +240,186 @@ const familyRoles = [ | ... | @@ -141,15 +240,186 @@ const familyRoles = [ |
| 141 | { id: 'maternal-granddaughter', label: '外孙女' } | 240 | { id: 'maternal-granddaughter', label: '外孙女' } |
| 142 | ]; | 241 | ]; |
| 143 | 242 | ||
| 243 | +// Mock家庭数据 | ||
| 244 | +const generateMockFamilies = () => { | ||
| 245 | + return [ | ||
| 246 | + { | ||
| 247 | + id: '1', | ||
| 248 | + name: '幸福之家', | ||
| 249 | + description: '我们是一个温馨和睦的大家庭,每天一起运动健身,分享生活的美好时光。', | ||
| 250 | + avatar: 'https://placehold.co/100x100/e2f3ff/0369a1?text=幸福&font=roboto' | ||
| 251 | + }, | ||
| 252 | + { | ||
| 253 | + id: '2', | ||
| 254 | + name: '健康家园', | ||
| 255 | + description: '注重健康生活,坚持每日步行锻炼,用积分兑换健康好礼。', | ||
| 256 | + avatar: 'https://placehold.co/100x100/f0f9ff/0284c7?text=健康&font=roboto' | ||
| 257 | + }, | ||
| 258 | + { | ||
| 259 | + id: '3', | ||
| 260 | + name: '快乐一家人', | ||
| 261 | + description: '快乐是我们家的主旋律,一起运动,一起成长,一起享受生活。', | ||
| 262 | + avatar: 'https://placehold.co/100x100/fef3c7/d97706?text=快乐&font=roboto' | ||
| 263 | + }, | ||
| 264 | + { | ||
| 265 | + id: '4', | ||
| 266 | + name: '幸福一家人', | ||
| 267 | + description: '快乐是我们家的主旋律,一起运动,一起成长,一起享受生活。', | ||
| 268 | + avatar: 'https://placehold.co/100x100/fef3c7/d97706?text=快乐&font=roboto' | ||
| 269 | + }, | ||
| 270 | + { | ||
| 271 | + id: '5', | ||
| 272 | + name: '幸福一家人', | ||
| 273 | + description: '快乐是我们家的主旋律,一起运动,一起成长,一起享受生活。', | ||
| 274 | + avatar: 'https://placehold.co/100x100/fef3c7/d97706?text=快乐&font=roboto' | ||
| 275 | + }, | ||
| 276 | + ] | ||
| 277 | +}; | ||
| 278 | + | ||
| 144 | const isComplete = computed(() => { | 279 | const isComplete = computed(() => { |
| 145 | return mottoChars.value.every((char) => char) && selectedRole.value; | 280 | return mottoChars.value.every((char) => char) && selectedRole.value; |
| 146 | }); | 281 | }); |
| 147 | 282 | ||
| 148 | -const handleJoinFamily = () => { | 283 | +// 过滤后的家庭列表 |
| 149 | - if (isComplete.value) { | 284 | +const filteredFamilies = computed(() => { |
| 150 | - Taro.reLaunch({ | 285 | + if (!searchKeyword.value) { |
| 151 | - url: '/pages/Dashboard/index' | 286 | + return mockFamilies.value |
| 152 | - }); | 287 | + } |
| 288 | + return mockFamilies.value.filter(family => | ||
| 289 | + family.name.includes(searchKeyword.value) || | ||
| 290 | + family.description.includes(searchKeyword.value) | ||
| 291 | + ) | ||
| 292 | +}); | ||
| 293 | + | ||
| 294 | +// 处理搜索 | ||
| 295 | +const handleSearch = (value) => { | ||
| 296 | + searchKeyword.value = value | ||
| 297 | + // 搜索时重置选中的家庭 | ||
| 298 | + selectedFamilyId.value = '' | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +// 清除搜索 | ||
| 302 | +const handleClearSearch = () => { | ||
| 303 | + searchKeyword.value = '' | ||
| 304 | + // 清除搜索时重置选中的家庭 | ||
| 305 | + selectedFamilyId.value = '' | ||
| 306 | +}; | ||
| 307 | + | ||
| 308 | +// 选择家庭 | ||
| 309 | +const selectFamily = (familyId) => { | ||
| 310 | + selectedFamilyId.value = familyId | ||
| 311 | +}; | ||
| 312 | + | ||
| 313 | +// 关闭家庭选择器 | ||
| 314 | +const closeFamilySelector = () => { | ||
| 315 | + showFamilySelector.value = false | ||
| 316 | + selectedFamilyId.value = '' | ||
| 317 | + searchKeyword.value = '' | ||
| 318 | +} | ||
| 319 | + | ||
| 320 | +/** | ||
| 321 | + * 计算家庭列表容器的可用高度 | ||
| 322 | + */ | ||
| 323 | +const calculateFamilyListHeight = async () => { | ||
| 324 | + try { | ||
| 325 | + // 获取系统信息 | ||
| 326 | + const systemInfo = await Taro.getSystemInfo() | ||
| 327 | + const windowHeight = systemInfo.windowHeight | ||
| 328 | + | ||
| 329 | + // 弹窗高度为70vh | ||
| 330 | + const popupHeight = windowHeight * 0.8 | ||
| 331 | + | ||
| 332 | + // 减去固定元素的高度(估算值,单位px转rpx需要乘以2) | ||
| 333 | + const titleHeight = 60 * 2 // 标题区域 | ||
| 334 | + const searchHeight = 80 * 2 // 搜索区域 | ||
| 335 | + const buttonHeight = 80 * 2 // 底部按钮区域 | ||
| 336 | + const padding = 32 * 2 // 内边距 | ||
| 337 | + | ||
| 338 | + // 计算可用高度(px转rpx) | ||
| 339 | + const availableHeight = (popupHeight - (titleHeight + searchHeight + buttonHeight + padding) / 2) | ||
| 340 | + | ||
| 341 | + // 设置最小高度和最大高度 | ||
| 342 | + familyListHeight.value = Math.max(200, Math.min(availableHeight * 2, 800)) | ||
| 343 | + } catch (error) { | ||
| 344 | + console.error('计算高度失败:', error) | ||
| 345 | + familyListHeight.value = 400 // 使用默认值 | ||
| 346 | + } | ||
| 347 | +} | ||
| 348 | + | ||
| 349 | +// 监听弹窗显示状态,动态计算高度 | ||
| 350 | +watch(showFamilySelector, async (newVal) => { | ||
| 351 | + if (newVal) { | ||
| 352 | + await nextTick() | ||
| 353 | + await calculateFamilyListHeight() | ||
| 354 | + } | ||
| 355 | +}) | ||
| 356 | + | ||
| 357 | +// 监听搜索关键词变化,重置选中的家庭 | ||
| 358 | +watch(searchKeyword, () => { | ||
| 359 | + selectedFamilyId.value = '' | ||
| 360 | +}) | ||
| 361 | + | ||
| 362 | +// 确认加入家庭 | ||
| 363 | +const confirmJoinFamily = () => { | ||
| 364 | + if (!selectedFamilyId.value) { | ||
| 365 | + Taro.showToast({ | ||
| 366 | + title: '请选择一个家庭', | ||
| 367 | + icon: 'none' | ||
| 368 | + }) | ||
| 369 | + return | ||
| 370 | + } | ||
| 371 | + | ||
| 372 | + const selectedFamily = mockFamilies.value.find(f => f.id === selectedFamilyId.value) | ||
| 373 | + console.log('确认加入家庭:', selectedFamily) | ||
| 374 | + | ||
| 375 | + // 关闭弹窗 | ||
| 376 | + closeFamilySelector() | ||
| 377 | + | ||
| 378 | + // 跳转到Dashboard | ||
| 379 | + Taro.redirectTo({ | ||
| 380 | + url: '/pages/Dashboard/index' | ||
| 381 | + }) | ||
| 382 | +}; | ||
| 383 | + | ||
| 384 | +const handleJoinFamily = async () => { | ||
| 385 | + if (!isComplete.value) return | ||
| 386 | + | ||
| 387 | + const motto = mottoChars.value.join('') | ||
| 388 | + | ||
| 389 | + try { | ||
| 390 | + // TODO: 调用API查询家庭 | ||
| 391 | + // const families = await api.queryFamiliesByMotto(motto) | ||
| 392 | + | ||
| 393 | + // 模拟API响应 - 生成mock数据 | ||
| 394 | + const families = generateMockFamilies() | ||
| 395 | + | ||
| 396 | + console.log('查询家庭:', { motto, role: selectedRole.value, families }) | ||
| 397 | + | ||
| 398 | + if (families.length === 0) { | ||
| 399 | + Taro.showToast({ | ||
| 400 | + title: '未找到匹配的家庭', | ||
| 401 | + icon: 'none' | ||
| 402 | + }) | ||
| 403 | + return | ||
| 404 | + } | ||
| 405 | + | ||
| 406 | + if (families.length === 1) { | ||
| 407 | + // 只有一个家庭,直接跳转 | ||
| 408 | + console.log('直接加入家庭:', families[0]) | ||
| 409 | + Taro.redirectTo({ | ||
| 410 | + url: '/pages/Dashboard/index' | ||
| 411 | + }) | ||
| 412 | + } else { | ||
| 413 | + // 多个家庭,显示选择弹窗 | ||
| 414 | + mockFamilies.value = families | ||
| 415 | + showFamilySelector.value = true | ||
| 416 | + } | ||
| 417 | + } catch (error) { | ||
| 418 | + console.error('加入家庭失败:', error) | ||
| 419 | + Taro.showToast({ | ||
| 420 | + title: '加入失败,请重试', | ||
| 421 | + icon: 'none' | ||
| 422 | + }) | ||
| 153 | } | 423 | } |
| 154 | }; | 424 | }; |
| 155 | </script> | 425 | </script> | ... | ... |
-
Please register or login to post a comment