hookehuyr

feat(plan): 实现全局弹窗管理器解决嵌套弹窗遮挡问题

新增功能:
- 创建 GlobalPopupManager 全局弹窗管理器
- 实现 useParentPopup 和 useGlobalPopup 接口
- 支持多弹窗同时打开和多层嵌套

新增组件:
- PlanPopupNew: 支持全局弹窗管理的父弹窗组件
- DatePickerGlobal: 使用全局管理器的日期选择器
- SelectPickerGlobal: 使用全局管理器的下拉选择器
- AgePickerGlobal: 使用全局管理器的年龄选择器

技术方案:
- 子弹窗打开时自动隐藏父弹窗底部按钮
- 所有子弹窗关闭时自动恢复底部按钮
- 使用 watch 监听全局状态,解决时序问题
- 支持多个子弹窗同时打开

迁移工作:
- 更新 PlanFormContainer 使用 PlanPopupNew
- 更新所有计划模板使用 Global 版本字段组件

文档:
- 创建 GlobalPopupManager 技术文档
- 包含架构设计、API 文档、使用指南

影响文件:
- src/components/PlanFormContainer.vue (修复结束标签错误)
- src/components/PlanPopupNew.vue (新组件)
- src/components/PlanFields/GlobalPopupManager.js (核心管理器)
- src/components/PlanFields/DatePickerGlobal.vue (新组件)
- src/components/PlanFields/SelectPickerGlobal.vue (新组件)
- src/components/PlanFields/AgePickerGlobal.vue (新组件)
- src/components/PlanTemplates/*.vue (更新导入)
- docs/GlobalPopupManager-弹窗管理器.md (技术文档)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -8,9 +8,11 @@ export {} ...@@ -8,9 +8,11 @@ export {}
8 declare module 'vue' { 8 declare module 'vue' {
9 export interface GlobalComponents { 9 export interface GlobalComponents {
10 AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default'] 10 AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default']
11 + AgePickerGlobal: typeof import('./src/components/PlanFields/AgePickerGlobal.vue')['default']
11 AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default'] 12 AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default']
12 CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default'] 13 CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default']
13 DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default'] 14 DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default']
15 + DatePickerGlobal: typeof import('./src/components/PlanFields/DatePickerGlobal.vue')['default']
14 DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default'] 16 DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default']
15 FilterTabs: typeof import('./src/components/FilterTabs.vue')['default'] 17 FilterTabs: typeof import('./src/components/FilterTabs.vue')['default']
16 'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default'] 18 'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default']
...@@ -36,7 +38,8 @@ declare module 'vue' { ...@@ -36,7 +38,8 @@ declare module 'vue' {
36 PdfPreview: typeof import('./src/components/PdfPreview.vue')['default'] 38 PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
37 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] 39 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
38 PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default'] 40 PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
39 - PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default'] 41 + PlanPopup: typeof import('./src/components/PlanPopup/index.vue')['default']
42 + PlanPopupNew: typeof import('./src/components/PlanPopupNew.vue')['default']
40 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] 43 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
41 ProductCard: typeof import('./src/components/ProductCard.vue')['default'] 44 ProductCard: typeof import('./src/components/ProductCard.vue')['default']
42 QrCode: typeof import('./src/components/qrCode.vue')['default'] 45 QrCode: typeof import('./src/components/qrCode.vue')['default']
...@@ -51,6 +54,7 @@ declare module 'vue' { ...@@ -51,6 +54,7 @@ declare module 'vue' {
51 SectionCard: typeof import('./src/components/SectionCard.vue')['default'] 54 SectionCard: typeof import('./src/components/SectionCard.vue')['default']
52 SectionItem: typeof import('./src/components/SectionItem.vue')['default'] 55 SectionItem: typeof import('./src/components/SectionItem.vue')['default']
53 SelectPicker: typeof import('./src/components/PlanFields/SelectPicker.vue')['default'] 56 SelectPicker: typeof import('./src/components/PlanFields/SelectPicker.vue')['default']
57 + SelectPickerGlobal: typeof import('./src/components/PlanFields/SelectPickerGlobal.vue')['default']
54 TabBar: typeof import('./src/components/TabBar.vue')['default'] 58 TabBar: typeof import('./src/components/TabBar.vue')['default']
55 } 59 }
56 } 60 }
......
1 +# 全局弹窗管理器 (GlobalPopupManager)
2 +
3 +> **版本**: 2.0.0
4 +> **作者**: Claude Code
5 +> **创建日期**: 2026-02-08
6 +> **状态**: ✅ 已实现并测试通过
7 +
8 +---
9 +
10 +## 📋 目录
11 +
12 +- [问题背景](#问题背景)
13 +- [解决方案](#解决方案)
14 +- [架构设计](#架构设计)
15 +- [使用指南](#使用指南)
16 +- [API 文档](#api-文档)
17 +- [实现细节](#实现细节)
18 +- [常见问题](#常见问题)
19 +
20 +---
21 +
22 +## 问题背景
23 +
24 +### 原始问题
25 +
26 +在计划书功能中,当父弹窗(`PlanPopupNew`)内嵌套子弹窗(如 `DatePickerGlobal``SelectPickerGlobal``AgePickerGlobal`)时,出现以下问题:
27 +
28 +1. **底部按钮遮挡**:子弹窗被父弹窗的底部按钮遮挡
29 +2. **用户体验差**:用户无法看到或操作子弹窗的内容
30 +
31 +### 技术原因
32 +
33 +NutUI 的 `nut-popup` 组件使用 `z-index` 控制层级,但:
34 +- 父弹窗和子弹窗都是 `position: fixed` 定位
35 +- 即使子弹窗 `z-index` 更高,也无法遮挡父弹窗的非子元素(如底部按钮)
36 +- 底部按钮在 DOM 结构上与子弹窗同级,因此会覆盖子弹窗
37 +
38 +---
39 +
40 +## 解决方案
41 +
42 +### 核心思路
43 +
44 +**当子弹窗打开时,自动隐藏父弹窗的底部按钮**
45 +
46 +### 实现方式
47 +
48 +通过全局弹窗管理器(`GlobalPopupManager`)协调父弹窗和子弹窗的状态:
49 +
50 +1. **子弹窗注册**:每个子弹窗组件在挂载时注册,获得唯一 ID
51 +2. **激活/停用**:子弹窗打开时激活,关闭时停用
52 +3. **状态同步**:管理器通知父弹窗隐藏/显示底部按钮
53 +4. **响应式更新**:父弹窗通过 `watch` 监听全局状态变化
54 +
55 +---
56 +
57 +## 架构设计
58 +
59 +### 组件关系图
60 +
61 +```
62 +PlanFormContainer (表单容器)
63 + └── PlanPopupNew (父弹窗)
64 + ├── Footer Buttons (底部按钮)
65 + └── <slot> (表单内容)
66 + ├── LifeInsuranceTemplate
67 + │ ├── AgePickerGlobal (子弹窗)
68 + │ ├── DatePickerGlobal (子弹窗)
69 + │ └── SelectPickerGlobal (子弹窗)
70 + ├── CriticalIllnessTemplate
71 + └── SavingsTemplate
72 +```
73 +
74 +### 数据流图
75 +
76 +```
77 +┌─────────────────────────────────────────────────────────────┐
78 +│ GlobalPopupManager │
79 +│ ┌─────────────────────────────────────────────────────────┐ │
80 +│ │ 全局状态: activePopups: Ref<string[]> │ │
81 +│ │ parentPopupCallbacks: Function[] │ │
82 +│ └─────────────────────────────────────────────────────────┘ │
83 +│ │
84 +│ useParentPopup() useGlobalPopup() │
85 +│ ┌─────────────────┐ ┌─────────────────┐ │
86 +│ │ registerCallback │ │ registerPopup()│ │
87 +│ │ hasActiveChildPopup│ │ activatePopup()│ │
88 +│ │ notifyCallbacks()│ │ deactivatePopup()│ │
89 +│ └─────────────────┘ └─────────────────┘ │
90 +│ ▲ ▲ │
91 +│ │ │ │
92 +│ ▼ ▼ │
93 +│ PlanPopupNew.vue DatePickerGlobal.vue │
94 +│ ┌─────────────────┐ ┌─────────────────┐ │
95 +│ │ showFooter: ref│ │ popupId: ref │ │
96 +│ │ watch( │ │ onMounted() │ │
97 +│ │ isActive) │ │ openPicker() │ │
98 +│ └─────────────────┘ │ onConfirm() │ │
99 +│ │ onCancel() │ │
100 +│ └─────────────────┘ │
101 +└─────────────────────────────────────────────────────────────┘
102 +```
103 +
104 +---
105 +
106 +## 使用指南
107 +
108 +### 快速开始
109 +
110 +#### 1. 父弹窗组件(PlanPopupNew)
111 +
112 +```vue
113 +<script setup>
114 +import { useParentPopup } from './PlanFields/GlobalPopupManager.js'
115 +
116 +const { registerFooterCallback, hasActiveChildPopup } = useParentPopup()
117 +
118 +// 注册回调(监听子弹窗状态)
119 +const unregister = registerFooterCallback((shouldShowFooter) => {
120 + showFooter.value = shouldShowFooter
121 +})
122 +
123 +// 监听全局状态变化(解决时序问题)
124 +watch(hasActiveChildPopup, (isActive) => {
125 + showFooter.value = !isActive
126 +})
127 +
128 +// 组件卸载时取消注册
129 +onUnmounted(() => {
130 + unregister()
131 +})
132 +</script>
133 +
134 +<template>
135 + <div class="footer-buttons" v-show="showFooter">
136 + <button>取消</button>
137 + <button>确定</button>
138 + </div>
139 +</template>
140 +```
141 +
142 +#### 2. 子弹窗组件(DatePickerGlobal)
143 +
144 +```vue
145 +<script setup>
146 +import { useGlobalPopup } from './GlobalPopupManager.js'
147 +
148 +const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
149 +const popupId = ref(null)
150 +
151 +// 组件挂载时注册弹窗
152 +onMounted(() => {
153 + popupId.value = registerPopup()
154 +})
155 +
156 +// 打开子弹窗时激活
157 +const openPicker = () => {
158 + if (popupId.value) {
159 + activatePopup(popupId.value) // 隐藏父弹窗底部按钮
160 + }
161 + showPicker.value = true
162 +}
163 +
164 +// 关闭子弹窗时停用
165 +const closePicker = () => {
166 + if (popupId.value) {
167 + deactivatePopup(popupId.value) // 恢复父弹窗底部按钮
168 + }
169 + showPicker.value = false
170 +}
171 +</script>
172 +```
173 +
174 +---
175 +
176 +## API 文档
177 +
178 +### `useParentPopup()`
179 +
180 +**用途**: 父弹窗组件使用
181 +
182 +**返回值**:
183 +```typescript
184 +{
185 + registerFooterCallback: (callback: (shouldShowFooter: boolean) => void) => () => void,
186 + hasActiveChildPopup: ComputedRef<boolean>,
187 + notifyCallbacks: (shouldShowFooter: boolean) => void
188 +}
189 +```
190 +
191 +**方法**:
192 +
193 +#### `registerFooterCallback(callback)`
194 +
195 +注册底部按钮回调函数
196 +
197 +**参数**:
198 +- `callback: (shouldShowFooter: boolean) => void` - 回调函数
199 + - `shouldShowFooter = true` - 显示底部按钮
200 + - `shouldShowFooter = false` - 隐藏底部按钮
201 +
202 +**返回值**: `() => void` - 取消注册函数
203 +
204 +**示例**:
205 +```javascript
206 +const unregister = registerFooterCallback((shouldShowFooter) => {
207 + showFooter.value = shouldShowFooter
208 +})
209 +
210 +// 组件卸载时取消注册
211 +onUnmounted(() => {
212 + unregister()
213 +})
214 +```
215 +
216 +#### `hasActiveChildPopup`
217 +
218 +计算属性:当前是否有活动的子弹窗
219 +
220 +**类型**: `ComputedRef<boolean>`
221 +
222 +**示例**:
223 +```javascript
224 +watch(hasActiveChildPopup, (isActive) => {
225 + console.log('是否有活动子弹窗:', isActive)
226 + showFooter.value = !isActive
227 +})
228 +```
229 +
230 +---
231 +
232 +### `useGlobalPopup()`
233 +
234 +**用途**: 子弹窗组件使用
235 +
236 +**返回值**:
237 +```typescript
238 +{
239 + registerPopup: () => string,
240 + activatePopup: (popupId: string) => void,
241 + deactivatePopup: (popupId: string) => void,
242 + hasActiveChildPopup: ComputedRef<boolean>
243 +}
244 +```
245 +
246 +**方法**:
247 +
248 +#### `registerPopup()`
249 +
250 +注册弹窗并获取唯一 ID
251 +
252 +**返回值**: `string` - 弹窗 ID(格式:`popup-1`, `popup-2`, ...)
253 +
254 +**示例**:
255 +```javascript
256 +const popupId = ref(null)
257 +
258 +onMounted(() => {
259 + popupId.value = registerPopup()
260 + console.log('弹窗 ID:', popupId.value) // 输出: popup-1
261 +})
262 +```
263 +
264 +#### `activatePopup(popupId)`
265 +
266 +激活弹窗(隐藏父弹窗底部按钮)
267 +
268 +**参数**:
269 +- `popupId: string` - 弹窗 ID
270 +
271 +**示例**:
272 +```javascript
273 +const openPicker = () => {
274 + activatePopup(popupId.value) // 通知父弹窗隐藏底部按钮
275 + showPicker.value = true
276 +}
277 +```
278 +
279 +#### `deactivatePopup(popupId)`
280 +
281 +停用弹窗(恢复父弹窗底部按钮)
282 +
283 +**参数**:
284 +- `popupId: string` - 弹窗 ID
285 +
286 +**示例**:
287 +```javascript
288 +const closePicker = () => {
289 + deactivatePopup(popupId.value) // 通知父弹窗显示底部按钮
290 + showPicker.value = false
291 +}
292 +```
293 +
294 +---
295 +
296 +### `resetPopupState()`
297 +
298 +**用途**: 重置所有弹窗状态(用于测试或异常恢复)
299 +
300 +**示例**:
301 +```javascript
302 +import { resetPopupState } from './GlobalPopupManager.js'
303 +
304 +// 重置所有弹窗状态
305 +resetPopupState()
306 +```
307 +
308 +---
309 +
310 +## 实现细节
311 +
312 +### 全局状态管理
313 +
314 +```javascript
315 +// 活动弹窗列表
316 +const activePopups = ref([])
317 +
318 +// 是否有活动的子弹窗
319 +const hasActiveChildPopup = computed(() => activePopups.value.length > 0)
320 +
321 +// 父弹窗回调列表
322 +const parentPopupCallbacks = []
323 +```
324 +
325 +### 激活/停用逻辑
326 +
327 +#### 激活弹窗
328 +
329 +```javascript
330 +const activatePopup = (popupId) => {
331 + if (!activePopups.value.includes(popupId)) {
332 + activePopups.value.push(popupId)
333 +
334 + // 通知所有父弹窗隐藏底部按钮
335 + parentPopupCallbacks.forEach((callback) => {
336 + callback(false) // false = 隐藏
337 + })
338 + }
339 +}
340 +```
341 +
342 +#### 停用弹窗
343 +
344 +```javascript
345 +const deactivatePopup = (popupId) => {
346 + const index = activePopups.value.indexOf(popupId)
347 + if (index > -1) {
348 + activePopups.value.splice(index, 1)
349 +
350 + // 如果没有其他活动弹窗了,通知所有父弹窗显示底部按钮
351 + if (activePopups.value.length === 0) {
352 + parentPopupCallbacks.forEach((callback) => {
353 + callback(true) // true = 显示
354 + })
355 + }
356 + }
357 +}
358 +```
359 +
360 +### 时序问题解决
361 +
362 +使用 `watch` 监听全局状态,解决子弹窗先于父弹窗挂载的问题:
363 +
364 +```javascript
365 +// PlanPopupNew.vue
366 +watch(hasActiveChildPopup, (isActive) => {
367 + showFooter.value = !isActive
368 +})
369 +```
370 +
371 +---
372 +
373 +## 常见问题
374 +
375 +### Q1: 为什么不直接使用 `provide/inject`?
376 +
377 +**A**: `provide/inject` 模式存在以下问题:
378 +
379 +1. **紧耦合**:子组件必须知道父组件提供的 `provide` key
380 +2. **层级限制**:只能用于父子关系,跨层级使用困难
381 +3. **维护困难**:多层嵌套时难以追踪数据流
382 +
383 +**GlobalPopupManager** 优势:
384 +- ✅ 松耦合:子组件无需知道父组件
385 +- ✅ 全局管理:所有弹窗共享状态
386 +- ✅ 易于扩展:支持多个父弹窗和多个子弹窗
387 +
388 +---
389 +
390 +### Q2: 如果同时打开多个子弹窗会怎样?
391 +
392 +**A**: 系统支持多个子弹窗同时打开:
393 +
394 +- 第一个子弹窗打开 → `activePopups = ['popup-1']` → 隐藏底部按钮
395 +- 第二个子弹窗打开 → `activePopups = ['popup-1', 'popup-2']` → 保持隐藏
396 +- 第一个子弹窗关闭 → `activePopups = ['popup-2']` → 保持隐藏
397 +- 第二个子弹窗关闭 → `activePopups = []` → 显示底部按钮
398 +
399 +只有当所有子弹窗都关闭时,底部按钮才会重新显示。
400 +
401 +---
402 +
403 +### Q3: 如何调试弹窗状态?
404 +
405 +**A**: 添加临时日志:
406 +
407 +```javascript
408 +// 在 activatePopup 中添加日志
409 +const activatePopup = (popupId) => {
410 + console.log('[DEBUG] 激活弹窗:', popupId)
411 + console.log('[DEBUG] 当前活动弹窗:', activePopups.value)
412 + console.log('[DEBUG] 父弹窗回调数量:', parentPopupCallbacks.length)
413 + // ...
414 +}
415 +```
416 +
417 +---
418 +
419 +### Q4: 支持多层嵌套吗?
420 +
421 +**A**: 支持!例如:
422 +
423 +```
424 +PlanPopupNew (父弹窗)
425 + └── SelectPickerGlobal (子弹窗)
426 + └── DatePickerGlobal (孙弹窗)
427 +```
428 +
429 +所有层级的弹窗都会正确注册和激活,底部按钮会正确隐藏和显示。
430 +
431 +---
432 +
433 +## 相关文件
434 +
435 +- `src/components/PlanFields/GlobalPopupManager.js` - 核心管理器
436 +- `src/components/PlanPopupNew.vue` - 父弹窗组件
437 +- `src/components/PlanFields/DatePickerGlobal.vue` - 日期选择器(子弹窗)
438 +- `src/components/PlanFields/SelectPickerGlobal.vue` - 下拉选择器(子弹窗)
439 +- `src/components/PlanFields/AgePickerGlobal.vue` - 年龄选择器(子弹窗)
440 +
441 +---
442 +
443 +## 更新日志
444 +
445 +### v2.0.0 (2026-02-08)
446 +
447 +- ✅ 实现 GlobalPopupManager 全局弹窗管理器
448 +- ✅ 支持 `useParentPopup()``useGlobalPopup()` 接口
449 +- ✅ 解决嵌套弹窗底部按钮遮挡问题
450 +- ✅ 添加时序问题解决方案(`watch` 监听)
451 +- ✅ 清除调试日志,生产就绪
452 +
453 +---
454 +
455 +**维护者**: Claude Code
456 +**最后更新**: 2026-02-08
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
5 + <span v-if="required" class="text-red-500 mr-1">*</span>
6 + <span>{{ label }}</span>
7 + </div>
8 +
9 + <!-- 触发区域 -->
10 + <div
11 + class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
12 + :class="{ 'bg-gray-50': showPicker }"
13 + @tap="handleTap"
14 + >
15 + <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
16 + {{ displayValue || placeholder }}
17 + </span>
18 + <IconFont name="right" size="14" color="#9CA3AF" />
19 + </div>
20 +
21 + <!-- Picker 弹窗 -->
22 + <nut-popup
23 + position="bottom"
24 + v-model:visible="showPicker"
25 + :z-index="9999"
26 + :overlay="true"
27 + >
28 + <nut-picker
29 + v-model="pickerValue"
30 + :columns="ageColumns"
31 + @confirm="onConfirm"
32 + @cancel="onCancel"
33 + />
34 + </nut-popup>
35 + </div>
36 +</template>
37 +
38 +<script setup>
39 +/**
40 + * 年龄选择器组件(全局弹窗管理器版本)
41 + *
42 + * @description 使用 NutUI Popup + Picker 实现年龄选择
43 + * - 显示格式:3位数字(如 018 表示 18 岁)
44 + * - 提交格式:数字(如 18)
45 + * - 年龄范围:0-120 岁
46 + * - 使用 GlobalPopupManager 管理弹窗层级
47 + * @author Claude Code
48 + * @version 2.0.0 - 支持全局弹窗管理器
49 + * @example
50 + * <AgePickerGlobal
51 + * v-model="age"
52 + * label="年龄"
53 + * placeholder="请选择年龄"
54 + * />
55 + */
56 +import { ref, computed, watch, onMounted } from 'vue'
57 +import IconFont from '@/components/IconFont.vue'
58 +import { useGlobalPopup } from './GlobalPopupManager'
59 +
60 +/**
61 + * 使用全局弹窗管理器
62 + */
63 +const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
64 +
65 +/**
66 + * 弹窗 ID(由 GlobalPopupManager 分配)
67 + * @type {Ref<string|null>}
68 + */
69 +const popupId = ref(null)
70 +
71 +/**
72 + * 组件挂载时注册弹窗
73 + */
74 +onMounted(() => {
75 + popupId.value = registerPopup()
76 +})
77 +
78 +/**
79 + * 组件属性
80 + */
81 +const props = defineProps({
82 + /**
83 + * 标签文本
84 + * @type {string}
85 + */
86 + label: {
87 + type: String,
88 + default: ''
89 + },
90 +
91 + /**
92 + * 是否必填
93 + * @type {boolean}
94 + */
95 + required: {
96 + type: Boolean,
97 + default: false
98 + },
99 +
100 + /**
101 + * 占位符文本
102 + * @type {string}
103 + */
104 + placeholder: {
105 + type: String,
106 + default: '请选择年龄'
107 + },
108 +
109 + /**
110 + * 绑定的值(数字)
111 + * @type {number}
112 + */
113 + modelValue: {
114 + type: Number,
115 + default: null
116 + }
117 +})
118 +
119 +/**
120 + * 组件事件
121 + */
122 +const emit = defineEmits([
123 + /**
124 + * 更新值事件
125 + * @event update:modelValue
126 + * @param {number} value - 选中的年龄
127 + */
128 + 'update:modelValue',
129 +
130 + /**
131 + * 值变化事件
132 + * @event change
133 + * @param {number} value - 选中的年龄
134 + */
135 + 'change'
136 +])
137 +
138 +/**
139 + * 控制 Picker 显示
140 + */
141 +const showPicker = ref(false)
142 +
143 +/**
144 + * Picker 当前值(3位数字格式)
145 + */
146 +const pickerValue = ref(['018'])
147 +
148 +/**
149 + * 年龄选项列(0-120 岁,3位数字格式)
150 + */
151 +const ageColumns = computed(() => {
152 + return [
153 + Array.from({ length: 121 }, (_, i) => ({
154 + text: `${i} 岁`,
155 + value: String(i).padStart(3, '0')
156 + }))
157 + ]
158 +})
159 +
160 +/**
161 + * 显示的值(转换为中文格式)
162 + */
163 +const displayValue = computed(() => {
164 + if (props.modelValue === null || props.modelValue === undefined) {
165 + return ''
166 + }
167 + return `${props.modelValue} 岁`
168 +})
169 +
170 +/**
171 + * 点击触发区域
172 + */
173 +const handleTap = () => {
174 + // 激活弹窗(隐藏父弹窗底部按钮)
175 + if (popupId.value) {
176 + activatePopup(popupId.value)
177 + }
178 +
179 + // 如果有值,转换为3位数字格式
180 + if (props.modelValue !== null && props.modelValue !== undefined) {
181 + pickerValue.value = [String(props.modelValue).padStart(3, '0')]
182 + }
183 +
184 + showPicker.value = true
185 +}
186 +
187 +/**
188 + * 确认选择
189 + * @param {Object} { selectedValue } - Picker 返回的值
190 + *
191 + * @example
192 + * // 用户选择 18 岁
193 + * onConfirm({ selectedValue: ['018'] })
194 + */
195 +const onConfirm = ({ selectedValue }) => {
196 + // 将3位数字格式转换为普通数字
197 + const age = parseInt(selectedValue[0], 10)
198 +
199 + emit('update:modelValue', age)
200 + emit('change', age)
201 +
202 + // 停用弹窗(恢复父弹窗底部按钮)
203 + if (popupId.value) {
204 + deactivatePopup(popupId.value)
205 + }
206 +
207 + showPicker.value = false
208 +}
209 +
210 +/**
211 + * 取消选择
212 + */
213 +const onCancel = () => {
214 + // 停用弹窗(恢复父弹窗底部按钮)
215 + if (popupId.value) {
216 + deactivatePopup(popupId.value)
217 + }
218 +
219 + showPicker.value = false
220 +}
221 +
222 +/**
223 + * 监听 modelValue 变化,同步到 pickerValue
224 + */
225 +watch(
226 + () => props.modelValue,
227 + (newVal) => {
228 + if (newVal !== null && newVal !== undefined) {
229 + pickerValue.value = [String(newVal).padStart(3, '0')]
230 + }
231 + }
232 +)
233 +</script>
234 +
235 +<style lang="less">
236 +/* 组件样式 */
237 +</style>
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
5 + <span v-if="required" class="text-red-500 mr-1">*</span>
6 + <span>{{ label }}</span>
7 + </div>
8 +
9 + <!-- 触发区域 -->
10 + <div
11 + class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
12 + :class="{ 'bg-gray-50': showDatePicker }"
13 + @tap="openDatePicker"
14 + >
15 + <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
16 + {{ displayValue || placeholder }}
17 + </span>
18 + <IconFont name="right" size="14" color="#9CA3AF" />
19 + </div>
20 +
21 + <!-- DatePicker 弹窗 -->
22 + <nut-popup position="bottom" v-model:visible="showDatePicker">
23 + <nut-date-picker
24 + v-model="currentDate"
25 + :min-date="minDate"
26 + :max-date="maxDate"
27 + :is-show-chinese="true"
28 + @confirm="onConfirm"
29 + @cancel="onCancel"
30 + >
31 + </nut-date-picker>
32 + </nut-popup>
33 + </div>
34 +</template>
35 +
36 +<script setup>
37 +/**
38 + * 日期选择器组件(全局弹窗管理器版本)
39 + *
40 + * @description 使用 NutUI DatePicker + Popup 实现日期选择
41 + * - 支持年龄范围限制(minAge, maxAge)
42 + * - 格式:YYYY-MM-DD
43 + * - 可触发自动计算年龄
44 + * - 使用 GlobalPopupManager 管理弹窗层级
45 + * @author Claude Code
46 + * @version 2.0.0 - 支持全局弹窗管理器
47 + * @example
48 + * <DatePickerGlobal
49 + * v-model="birthday"
50 + * label="出生年月日"
51 + * placeholder="请选择日期"
52 + * :min-age="0"
53 + * :max-age="120"
54 + * @change="onBirthdayChange"
55 + * />
56 + */
57 +import { ref, computed, watch, onMounted } from 'vue'
58 +import IconFont from '@/components/IconFont.vue'
59 +import { useGlobalPopup } from './GlobalPopupManager'
60 +
61 +/**
62 + * 使用全局弹窗管理器
63 + */
64 +const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
65 +
66 +/**
67 + * 弹窗 ID(由 GlobalPopupManager 分配)
68 + * @type {Ref<string|null>}
69 + */
70 +const popupId = ref(null)
71 +
72 +/**
73 + * 组件挂载时注册弹窗
74 + */
75 +onMounted(() => {
76 + popupId.value = registerPopup()
77 +})
78 +
79 +/**
80 + * 组件属性
81 + */
82 +const props = defineProps({
83 + /**
84 + * 标签文本
85 + * @type {string}
86 + */
87 + label: {
88 + type: String,
89 + default: ''
90 + },
91 +
92 + /**
93 + * 是否必填
94 + * @type {boolean}
95 + */
96 + required: {
97 + type: Boolean,
98 + default: false
99 + },
100 +
101 + /**
102 + * 占位符文本
103 + * @type {string}
104 + */
105 + placeholder: {
106 + type: String,
107 + default: '请选择日期'
108 + },
109 +
110 + /**
111 + * 绑定的值(格式:YYYY-MM-DD)
112 + * @type {string}
113 + */
114 + modelValue: {
115 + type: String,
116 + default: ''
117 + },
118 +
119 + /**
120 + * 最小年龄(用于计算最大出生日期)
121 + * @type {number}
122 + * @default 0
123 + */
124 + minAge: {
125 + type: Number,
126 + default: 0
127 + },
128 +
129 + /**
130 + * 最大年龄(用于计算最小出生日期)
131 + * @type {number}
132 + * @default 120
133 + */
134 + maxAge: {
135 + type: Number,
136 + default: 120
137 + }
138 +})
139 +
140 +/**
141 + * 组件事件
142 + */
143 +const emit = defineEmits([
144 + /**
145 + * 更新值事件
146 + * @event update:modelValue
147 + * @param {string} value - 选中的日期(格式:YYYY-MM-DD)
148 + */
149 + 'update:modelValue',
150 +
151 + /**
152 + * 值变化事件(可用于触发自动计算年龄)
153 + * @event change
154 + * @param {string} value - 选中的日期(格式:YYYY-MM-DD)
155 + */
156 + 'change',
157 + /**
158 + * 弹窗打开事件
159 + * @event open
160 + */
161 + 'open',
162 + /**
163 + * 弹窗关闭事件
164 + * @event close
165 + */
166 + 'close'
167 +])
168 +
169 +/**
170 + * 控制 DatePicker 显示
171 + */
172 +const showDatePicker = ref(false)
173 +
174 +/**
175 + * 当前选中的日期(Date 对象)
176 + * 用于绑定给 nut-date-picker
177 + */
178 +const currentDate = ref(new Date())
179 +
180 +/**
181 + * 打开日期选择器
182 + * @description 打开时将传入的 modelValue 转换为 Date 对象
183 + */
184 +const openDatePicker = () => {
185 + // 激活弹窗(隐藏父弹窗底部按钮)
186 + if (popupId.value) {
187 + activatePopup(popupId.value)
188 + }
189 +
190 + if (props.modelValue) {
191 + // 兼容 iOS 的日期格式 (YYYY/MM/DD)
192 + const dateStr = props.modelValue.replace(/-/g, '/')
193 + const date = new Date(dateStr)
194 + if (!Number.isNaN(date.getTime())) {
195 + currentDate.value = date
196 + }
197 + } else {
198 + currentDate.value = new Date()
199 + }
200 +
201 + showDatePicker.value = true
202 + emit('open')
203 +}
204 +
205 +/**
206 + * 计算最小可选日期(基于最大年龄)
207 + * @description maxAge 岁对应的出生日期
208 + * @example
209 + * // maxAge = 75, 当前日期 = 2026-02-06
210 + * // minDate() // 返回: 1951-02-06
211 + */
212 +const minDate = computed(() => {
213 + const date = new Date()
214 + date.setFullYear(date.getFullYear() - props.maxAge)
215 + return date
216 +})
217 +
218 +/**
219 + * 计算最大可选日期(基于最小年龄)
220 + * @description minAge 岁对应的出生日期
221 + * @example
222 + * // minAge = 0, 当前日期 = 2026-02-06
223 + * // maxDate() // 返回: 2026-02-06
224 + */
225 +const maxDate = computed(() => {
226 + const date = new Date()
227 + date.setFullYear(date.getFullYear() - props.minAge)
228 + return date
229 +})
230 +
231 +/**
232 + * 显示的值
233 + */
234 +const displayValue = computed(() => {
235 + return props.modelValue || ''
236 +})
237 +
238 +/**
239 + * 确认选择
240 + * @param {Object} { selectedValue } - DatePicker 返回的日期对象
241 + *
242 + * @example
243 + * // 用户选择 2020-01-01
244 + * onConfirm({ selectedValue: ['2020', '01', '01'] })
245 + */
246 +const onConfirm = ({ selectedValue }) => {
247 + // NutUI DatePicker confirm 事件返回 { selectedValue: [year, month, day], selectedOptions: [...] }
248 + // 或者直接返回 Date 对象,取决于版本。
249 + // 安全起见,我们查看 currentDate.value,它会被 v-model 更新
250 +
251 + const date = currentDate.value
252 + const year = date.getFullYear()
253 + const month = String(date.getMonth() + 1).padStart(2, '0')
254 + const day = String(date.getDate()).padStart(2, '0')
255 +
256 + const formattedDate = `${year}-${month}-${day}`
257 + emit('update:modelValue', formattedDate)
258 + emit('change', formattedDate)
259 +
260 + // 停用弹窗(恢复父弹窗底部按钮)
261 + if (popupId.value) {
262 + deactivatePopup(popupId.value)
263 + }
264 +
265 + showDatePicker.value = false
266 + emit('close')
267 +}
268 +
269 +/**
270 + * 取消选择
271 + */
272 +const onCancel = () => {
273 + // 停用弹窗(恢复父弹窗底部按钮)
274 + if (popupId.value) {
275 + deactivatePopup(popupId.value)
276 + }
277 +
278 + showDatePicker.value = false
279 + emit('close')
280 +}
281 +</script>
282 +
283 +<style lang="less">
284 +/* 组件样式 */
285 +</style>
1 +/**
2 + * 全局弹窗管理器
3 + *
4 + * @description 管理嵌套弹窗的层级和显示状态,解决弹窗遮挡问题
5 + * @module GlobalPopupManager
6 + * @author Claude Code
7 + * @version 2.0.0
8 + */
9 +
10 +import { ref, computed } from 'vue'
11 +
12 +/**
13 + * 全局状态:当前活动的弹窗列表
14 + * @type {Ref<string[]>}
15 + */
16 +const activePopups = ref([])
17 +
18 +/**
19 + * 是否有活动的子弹窗
20 + * @type {ComputedRef<boolean>}
21 + */
22 +const hasActiveChildPopup = computed(() => activePopups.value.length > 0)
23 +
24 +/**
25 + * 弹窗计数器(用于生成唯一 ID)
26 + * @type {number}
27 + */
28 +let popupCounter = 0
29 +
30 +/**
31 + * 父弹窗回调函数列表(全局共享)
32 + * @type {Function[]}
33 + */
34 +const parentPopupCallbacks = []
35 +
36 +/**
37 + * 注册父弹窗(用于 PlanPopupNew)
38 + *
39 + * @description 提供给父弹窗使用的 composable
40 + * @returns {Object} 父弹窗控制方法
41 + *
42 + * @example
43 + * const { registerFooterCallback, hasActiveChildPopup } = useParentPopup()
44 + *
45 + * // 注册回调,当子弹窗打开时自动隐藏父级 footer
46 + * const unregister = registerFooterCallback((shouldShowFooter) => {
47 + * showFooter.value = shouldShowFooter
48 + * })
49 + */
50 +export function useParentPopup() {
51 + /**
52 + * 注册底部按钮回调
53 + *
54 + * @param {Function} callback - 回调函数,接收 shouldShowFooter 参数
55 + * @returns {Function} 取消注册函数
56 + */
57 + const registerFooterCallback = (callback) => {
58 + parentPopupCallbacks.push(callback)
59 +
60 + // 返回取消注册函数
61 + return () => {
62 + const index = parentPopupCallbacks.indexOf(callback)
63 + if (index > -1) {
64 + parentPopupCallbacks.splice(index, 1)
65 + }
66 + }
67 + }
68 +
69 + /**
70 + * 通知所有回调
71 + *
72 + * @param {boolean} shouldShowFooter - 是否显示底部按钮
73 + */
74 + const notifyCallbacks = (shouldShowFooter) => {
75 + parentPopupCallbacks.forEach(callback => callback(shouldShowFooter))
76 + }
77 +
78 + return {
79 + registerFooterCallback,
80 + hasActiveChildPopup,
81 + notifyCallbacks
82 + }
83 +}
84 +
85 +/**
86 + * 注册全局弹窗(用于 DatePickerGlobal, SelectPickerGlobal 等)
87 + *
88 + * @description 提供给需要全局管理的弹窗组件使用
89 + * @returns {Object} 弹窗控制方法
90 + *
91 + * @example
92 + * const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
93 + *
94 + * // 组件挂载时注册
95 + * const popupId = ref(null)
96 + * onMounted(() => {
97 + * popupId.value = registerPopup()
98 + * })
99 + *
100 + * // 弹窗打开时激活
101 + * activatePopup(popupId.value)
102 + *
103 + * // 弹窗关闭时停用
104 + * deactivatePopup(popupId.value)
105 + */
106 +export function useGlobalPopup() {
107 + /**
108 + * 注册弹窗
109 + *
110 + * @description 生成唯一的弹窗 ID
111 + * @returns {string} 弹窗 ID(格式:popup-1, popup-2, ...)
112 + */
113 + const registerPopup = () => {
114 + popupCounter++
115 + return `popup-${popupCounter}`
116 + }
117 +
118 + /**
119 + * 激活弹窗
120 + *
121 + * @description 将弹窗添加到活动列表,触发父弹窗隐藏底部按钮
122 + * @param {string} popupId - 弹窗 ID
123 + */
124 + const activatePopup = (popupId) => {
125 + if (!activePopups.value.includes(popupId)) {
126 + activePopups.value.push(popupId)
127 +
128 + // 通知所有父弹窗隐藏底部按钮
129 + parentPopupCallbacks.forEach((callback) => {
130 + callback(false)
131 + })
132 + }
133 + }
134 +
135 + /**
136 + * 停用弹窗
137 + *
138 + * @description 从活动列表中移除弹窗,触发父弹窗显示底部按钮
139 + * @param {string} popupId - 弹窗 ID
140 + */
141 + const deactivatePopup = (popupId) => {
142 + const index = activePopups.value.indexOf(popupId)
143 + if (index > -1) {
144 + activePopups.value.splice(index, 1)
145 +
146 + // 如果没有其他活动弹窗了,通知所有父弹窗显示底部按钮
147 + if (activePopups.value.length === 0) {
148 + parentPopupCallbacks.forEach((callback) => {
149 + callback(true)
150 + })
151 + }
152 + }
153 + }
154 +
155 + return {
156 + registerPopup,
157 + activatePopup,
158 + deactivatePopup,
159 + hasActiveChildPopup
160 + }
161 +}
162 +
163 +/**
164 + * 注册子弹窗(旧接口,向后兼容)
165 + *
166 + * @description 提供给子弹窗使用的 composable
167 + * @param {Function} notifyParent - 通知父弹窗的函数
168 + * @returns {Object} 子弹窗控制方法
169 + */
170 +export function useChildPopup(notifyParent) {
171 + /**
172 + * 子弹窗打开时通知父弹窗
173 + */
174 + const notifyParentOpen = () => {
175 + if (notifyParent) {
176 + notifyParent(false)
177 + }
178 + }
179 +
180 + /**
181 + * 子弹窗关闭时通知父弹窗
182 + */
183 + const notifyParentClose = () => {
184 + if (notifyParent) {
185 + notifyParent(true)
186 + }
187 + }
188 +
189 + return {
190 + notifyParentOpen,
191 + notifyParentClose
192 + }
193 +}
194 +
195 +/**
196 + * 重置所有弹窗状态
197 + *
198 + * @description 用于测试或异常情况下的状态重置
199 + */
200 +export function resetPopupState() {
201 + activePopups.value = []
202 + popupCounter = 0
203 +}
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
5 + <span v-if="required" class="text-red-500 mr-1">*</span>
6 + <span>{{ label }}</span>
7 + </div>
8 +
9 + <!-- 触发区域 -->
10 + <div
11 + class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
12 + :class="{ 'bg-gray-50': showPicker }"
13 + @tap="openPicker"
14 + >
15 + <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
16 + {{ displayValue || placeholder }}
17 + </span>
18 + <IconFont name="right" size="14" color="#9CA3AF" />
19 + </div>
20 +
21 + <!-- Picker 弹窗 -->
22 + <nut-popup
23 + position="bottom"
24 + v-model:visible="showPicker"
25 + :overlay="true"
26 + >
27 + <nut-picker
28 + :columns="pickerColumns"
29 + @confirm="onConfirm"
30 + @cancel="onCancel"
31 + />
32 + </nut-popup>
33 + </div>
34 +</template>
35 +
36 +<script setup>
37 +/**
38 + * 下拉选择器组件(全局弹窗管理器版本)
39 + *
40 + * @description 使用 NutUI Picker 实现下拉选择功能
41 + * - key 和 value 相同(如"整付(0-75 岁)")
42 + * - 适用于缴费年期等场景
43 + * - 使用 GlobalPopupManager 管理弹窗层级
44 + * @author Claude Code
45 + * @version 2.0.0 - 支持全局弹窗管理器
46 + * @example
47 + * <SelectPickerGlobal
48 + * v-model="paymentPeriod"
49 + * label="缴费年期"
50 + * placeholder="请选择缴费年期"
51 + * :options="['整付(0-75 岁)', '5 年(0-70 岁)']"
52 + * />
53 + */
54 +import { ref, computed, onMounted } from 'vue'
55 +import IconFont from '@/components/IconFont.vue'
56 +import { useGlobalPopup } from './GlobalPopupManager'
57 +
58 +/**
59 + * 使用全局弹窗管理器
60 + */
61 +const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
62 +
63 +/**
64 + * 弹窗 ID(由 GlobalPopupManager 分配)
65 + * @type {Ref<string|null>}
66 + */
67 +const popupId = ref(null)
68 +
69 +/**
70 + * 组件挂载时注册弹窗
71 + */
72 +onMounted(() => {
73 + popupId.value = registerPopup()
74 +})
75 +
76 +/**
77 + * 组件属性
78 + */
79 +const props = defineProps({
80 + /**
81 + * 标签文本
82 + * @type {string}
83 + */
84 + label: {
85 + type: String,
86 + default: ''
87 + },
88 +
89 + /**
90 + * 是否必填
91 + * @type {boolean}
92 + */
93 + required: {
94 + type: Boolean,
95 + default: false
96 + },
97 +
98 + /**
99 + * 占位符文本
100 + * @type {string}
101 + */
102 + placeholder: {
103 + type: String,
104 + default: '请选择'
105 + },
106 +
107 + /**
108 + * 绑定的值
109 + * @type {string}
110 + */
111 + modelValue: {
112 + type: String,
113 + default: ''
114 + },
115 +
116 + /**
117 + * 选项数组(key 和 value 相同)
118 + * @type {Array<string>}
119 + * @example ['整付(0-75 岁)', '5 年(0-70 岁)', '10 年(0-70 岁)']
120 + */
121 + options: {
122 + type: Array,
123 + required: true
124 + }
125 +})
126 +
127 +/**
128 + * 组件事件
129 + */
130 +const emit = defineEmits([
131 + /**
132 + * 更新值事件
133 + * @event update:modelValue
134 + * @param {string} value - 选中的选项
135 + */
136 + 'update:modelValue',
137 + /**
138 + * 弹窗打开事件
139 + * @event open
140 + */
141 + 'open',
142 + /**
143 + * 弹窗关闭事件
144 + * @event close
145 + */
146 + 'close'
147 +])
148 +
149 +/**
150 + * 控制 Picker 显示
151 + */
152 +const showPicker = ref(false)
153 +
154 +/**
155 + * 打开选择器
156 + */
157 +const openPicker = () => {
158 + // 激活弹窗(隐藏父弹窗底部按钮)
159 + if (popupId.value) {
160 + activatePopup(popupId.value)
161 + }
162 +
163 + showPicker.value = true
164 + emit('open')
165 +}
166 +
167 +/**
168 + * 转换为 Picker 格式
169 + * @description 将选项数组转换为 Picker 需要的格式
170 + * @example
171 + * // options = ['整付(0-75 岁)', '5 年(0-70 岁)']
172 + * // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...]
173 + */
174 +const pickerColumns = computed(() => {
175 + return props.options.map(option => ({
176 + text: option,
177 + value: option // key 和 value 相同
178 + }))
179 +})
180 +
181 +/**
182 + * 显示的值
183 + */
184 +const displayValue = computed(() => {
185 + return props.modelValue || ''
186 +})
187 +
188 +/**
189 + * 确认选择
190 + * @param {Object} params - Picker 返回参数
191 + * @param {Array} params.selectedOptions - 选中的选项数组
192 + *
193 + * @example
194 + * // 用户选择 '整付(0-75 岁)'
195 + * onConfirm({ selectedOptions: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }] })
196 + * // -> emit('update:modelValue', '整付(0-75 岁)')
197 + */
198 +const onConfirm = ({ selectedOptions }) => {
199 + const value = selectedOptions[0]?.value
200 + if (value !== undefined) {
201 + emit('update:modelValue', value)
202 + }
203 +
204 + // 停用弹窗(恢复父弹窗底部按钮)
205 + if (popupId.value) {
206 + deactivatePopup(popupId.value)
207 + }
208 +
209 + showPicker.value = false
210 + emit('close')
211 +}
212 +
213 +/**
214 + * 取消选择
215 + */
216 +const onCancel = () => {
217 + // 停用弹窗(恢复父弹窗底部按钮)
218 + if (popupId.value) {
219 + deactivatePopup(popupId.value)
220 + }
221 +
222 + showPicker.value = false
223 + emit('close')
224 +}
225 +</script>
226 +
227 +<style lang="less">
228 +/* 组件样式 */
229 +</style>
1 <template> 1 <template>
2 - <!-- 使用 PlanPopup 容器组件 --> 2 + <!-- 使用 PlanPopupNew 容器组件(支持全局弹窗管理器) -->
3 - <PlanPopup 3 + <PlanPopupNew
4 :visible="props.visible" 4 :visible="props.visible"
5 :title="templateConfig?.name || '计划书'" 5 :title="templateConfig?.name || '计划书'"
6 @close="close" 6 @close="close"
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
20 <p>⚠️ 未找到对应的计划书模版</p> 20 <p>⚠️ 未找到对应的计划书模版</p>
21 <p class="text-sm mt-2">form_sn: {{ product?.form_sn }}</p> 21 <p class="text-sm mt-2">form_sn: {{ product?.form_sn }}</p>
22 </div> 22 </div>
23 - </PlanPopup> 23 + </PlanPopupNew>
24 </template> 24 </template>
25 25
26 <script setup> 26 <script setup>
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
41 * /> 41 * />
42 */ 42 */
43 import { ref, computed, watch, nextTick } from 'vue' 43 import { ref, computed, watch, nextTick } from 'vue'
44 -import PlanPopup from './PlanPopup/index.vue' 44 +import PlanPopupNew from './PlanPopupNew.vue'
45 import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue' 45 import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
46 import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue' 46 import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
47 import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue' 47 import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'
......
1 +<!--
2 + * @Date: 2026-02-08
3 + * @Description: 计划书弹窗容器组件(支持全局弹窗管理器)
4 +-->
5 +<template>
6 + <nut-popup
7 + :visible="visible"
8 + position="bottom"
9 + round
10 + :style="{ height: '90%' }"
11 + :close-on-click-overlay="true"
12 + :safe-area-inset-bottom="true"
13 + @update:visible="handleVisibleChange"
14 + >
15 + <div class="h-full flex flex-col bg-gray-50 overflow-hidden rounded-t-2xl">
16 + <!-- Header -->
17 + <div class="flex justify-between items-center px-5 py-4 bg-white border-b border-gray-100 flex-shrink-0">
18 + <span class="text-lg font-bold text-gray-900">{{ title }}</span>
19 + <div class="p-2 -mr-2" @click="handleClose">
20 + <IconFont name="close" size="16" color="#9CA3AF" />
21 + </div>
22 + </div>
23 +
24 + <!-- Scrollable Content -->
25 + <div class="flex-1 overflow-y-auto p-4">
26 + <div class="bg-white rounded-xl p-5 shadow-sm">
27 + <slot></slot>
28 + </div>
29 + </div>
30 +
31 + <!-- Footer Buttons -->
32 + <div
33 + v-show="showFooter"
34 + class="p-4 bg-white border-t border-gray-100 flex gap-3 flex-shrink-0 pb-safe"
35 + >
36 + <nut-button
37 + plain
38 + type="primary"
39 + class="flex-1 !h-[88rpx] !rounded-[16rpx] !text-[30rpx] !border-blue-600"
40 + @click="handleClose"
41 + >
42 + 取消
43 + </nut-button>
44 + <nut-button
45 + type="primary"
46 + color="#2563EB"
47 + class="flex-1 !h-[88rpx] !rounded-[16rpx] !text-[30rpx]"
48 + @click="handleSubmit"
49 + >
50 + 生成计划书
51 + </nut-button>
52 + </div>
53 +
54 + </div>
55 + </nut-popup>
56 +</template>
57 +
58 +<script setup>
59 +/**
60 + * @description 录入计划书弹窗容器组件(支持全局弹窗管理器)
61 + * @description 自动监听子弹窗状态,隐藏/显示底部按钮
62 + * @param {boolean} visible - 控制弹窗显示隐藏
63 + * @param {string} title - 弹窗标题
64 + * @emits update:visible - 更新 visible 状态
65 + * @emits close - 关闭弹窗
66 + * @emits submit - 提交表单
67 + * @author Claude Code
68 + * @version 2.0.0 - 支持全局弹窗管理器
69 + */
70 +import { ref, watch, onMounted, onUnmounted } from 'vue'
71 +import IconFont from '@/components/IconFont.vue'
72 +import { useParentPopup } from './PlanFields/GlobalPopupManager.js'
73 +
74 +const props = defineProps({
75 + visible: {
76 + type: Boolean,
77 + default: false,
78 + },
79 + title: {
80 + type: String,
81 + default: '计划书',
82 + },
83 +})
84 +
85 +const emit = defineEmits(['update:visible', 'close', 'submit'])
86 +
87 +/**
88 + * 底部按钮显示状态
89 + * @type {Ref<boolean>}
90 + */
91 +const showFooter = ref(true)
92 +
93 +/**
94 + * 使用父弹窗管理器
95 + */
96 +const { registerFooterCallback, hasActiveChildPopup } = useParentPopup()
97 +
98 +/**
99 + * 取消注册回调函数
100 + * @type {Function|null}
101 + */
102 +let unregisterFooterCallback = null
103 +
104 +/**
105 + * 组件挂载时注册回调
106 + */
107 +onMounted(() => {
108 + // 注册回调,当子弹窗打开/关闭时自动隐藏/显示底部按钮
109 + unregisterFooterCallback = registerFooterCallback((shouldShowFooter) => {
110 + showFooter.value = shouldShowFooter
111 + })
112 +
113 + // 初始化时检查是否已有活动弹窗
114 + if (hasActiveChildPopup.value) {
115 + showFooter.value = false
116 + }
117 +})
118 +
119 +/**
120 + * 组件卸载时取消注册
121 + */
122 +onUnmounted(() => {
123 + if (unregisterFooterCallback) {
124 + unregisterFooterCallback()
125 + }
126 +})
127 +
128 +/**
129 + * 监听全局弹窗状态变化
130 + * 当子弹窗打开/关闭时,自动隐藏/显示底部按钮
131 + */
132 +watch(hasActiveChildPopup, (isActive) => {
133 + showFooter.value = !isActive
134 +})
135 +
136 +// 处理 visible 变化事件
137 +const handleVisibleChange = (value) => {
138 + emit('update:visible', value)
139 + if (!value) {
140 + // 重置底部按钮显示状态
141 + showFooter.value = true
142 + emit('close')
143 + }
144 +}
145 +
146 +const handleClose = () => {
147 + emit('update:visible', false)
148 + emit('close')
149 +}
150 +
151 +const handleSubmit = () => {
152 + emit('submit')
153 +}
154 +</script>
155 +
156 +<style lang="less">
157 +:deep(.nut-popup) {
158 + border-top-left-radius: 16px;
159 + border-top-right-radius: 16px;
160 + background-color: #F9FAFB;
161 +}
162 +
163 +/* 适配底部安全区 */
164 +.pb-safe {
165 + padding-bottom: constant(safe-area-inset-bottom);
166 + padding-bottom: env(safe-area-inset-bottom);
167 +}
168 +</style>
...@@ -82,11 +82,11 @@ ...@@ -82,11 +82,11 @@
82 */ 82 */
83 import { reactive, watch } from 'vue' 83 import { reactive, watch } from 'vue'
84 import Taro from '@tarojs/taro' 84 import Taro from '@tarojs/taro'
85 -import PlanFieldAgePicker from '../PlanFields/AgePicker.vue' 85 +import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
86 import PlanFieldAmount from '../PlanFields/AmountInput.vue' 86 import PlanFieldAmount from '../PlanFields/AmountInput.vue'
87 -import PlanFieldDatePicker from '../PlanFields/DatePicker.vue' 87 +import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
88 import PlanFieldRadio from '../PlanFields/RadioGroup.vue' 88 import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
89 -import PlanFieldSelect from '../PlanFields/SelectPicker.vue' 89 +import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
90 90
91 /** 91 /**
92 * 组件属性 92 * 组件属性
......
...@@ -82,11 +82,11 @@ ...@@ -82,11 +82,11 @@
82 */ 82 */
83 import { reactive, watch, toRefs } from 'vue' 83 import { reactive, watch, toRefs } from 'vue'
84 import Taro from '@tarojs/taro' 84 import Taro from '@tarojs/taro'
85 -import PlanFieldAgePicker from '../PlanFields/AgePicker.vue' 85 +import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
86 import PlanFieldAmount from '../PlanFields/AmountInput.vue' 86 import PlanFieldAmount from '../PlanFields/AmountInput.vue'
87 -import PlanFieldDatePicker from '../PlanFields/DatePicker.vue' 87 +import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
88 import PlanFieldRadio from '../PlanFields/RadioGroup.vue' 88 import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
89 -import PlanFieldSelect from '../PlanFields/SelectPicker.vue' 89 +import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
90 90
91 /** 91 /**
92 * 组件属性 92 * 组件属性
......
...@@ -207,11 +207,11 @@ ...@@ -207,11 +207,11 @@
207 */ 207 */
208 import { reactive, watch, computed } from 'vue' 208 import { reactive, watch, computed } from 'vue'
209 import Taro from '@tarojs/taro' 209 import Taro from '@tarojs/taro'
210 -import PlanFieldAgePicker from '../PlanFields/AgePicker.vue' 210 +import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
211 import PlanFieldAmount from '../PlanFields/AmountInput.vue' 211 import PlanFieldAmount from '../PlanFields/AmountInput.vue'
212 -import PlanFieldDatePicker from '../PlanFields/DatePicker.vue' 212 +import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
213 import PlanFieldRadio from '../PlanFields/RadioGroup.vue' 213 import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
214 -import PlanFieldSelect from '../PlanFields/SelectPicker.vue' 214 +import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
215 215
216 /** 216 /**
217 * 组件属性 217 * 组件属性
......