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 {}
declare module 'vue' {
export interface GlobalComponents {
AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default']
AgePickerGlobal: typeof import('./src/components/PlanFields/AgePickerGlobal.vue')['default']
AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default']
CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default']
DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default']
DatePickerGlobal: typeof import('./src/components/PlanFields/DatePickerGlobal.vue')['default']
DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default']
FilterTabs: typeof import('./src/components/FilterTabs.vue')['default']
'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default']
......@@ -36,7 +38,8 @@ declare module 'vue' {
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
PlanPopup: typeof import('./src/components/PlanPopup/index.vue')['default']
PlanPopupNew: typeof import('./src/components/PlanPopupNew.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
ProductCard: typeof import('./src/components/ProductCard.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
......@@ -51,6 +54,7 @@ declare module 'vue' {
SectionCard: typeof import('./src/components/SectionCard.vue')['default']
SectionItem: typeof import('./src/components/SectionItem.vue')['default']
SelectPicker: typeof import('./src/components/PlanFields/SelectPicker.vue')['default']
SelectPickerGlobal: typeof import('./src/components/PlanFields/SelectPickerGlobal.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
}
}
......
# 全局弹窗管理器 (GlobalPopupManager)
> **版本**: 2.0.0
> **作者**: Claude Code
> **创建日期**: 2026-02-08
> **状态**: ✅ 已实现并测试通过
---
## 📋 目录
- [问题背景](#问题背景)
- [解决方案](#解决方案)
- [架构设计](#架构设计)
- [使用指南](#使用指南)
- [API 文档](#api-文档)
- [实现细节](#实现细节)
- [常见问题](#常见问题)
---
## 问题背景
### 原始问题
在计划书功能中,当父弹窗(`PlanPopupNew`)内嵌套子弹窗(如 `DatePickerGlobal``SelectPickerGlobal``AgePickerGlobal`)时,出现以下问题:
1. **底部按钮遮挡**:子弹窗被父弹窗的底部按钮遮挡
2. **用户体验差**:用户无法看到或操作子弹窗的内容
### 技术原因
NutUI 的 `nut-popup` 组件使用 `z-index` 控制层级,但:
- 父弹窗和子弹窗都是 `position: fixed` 定位
- 即使子弹窗 `z-index` 更高,也无法遮挡父弹窗的非子元素(如底部按钮)
- 底部按钮在 DOM 结构上与子弹窗同级,因此会覆盖子弹窗
---
## 解决方案
### 核心思路
**当子弹窗打开时,自动隐藏父弹窗的底部按钮**
### 实现方式
通过全局弹窗管理器(`GlobalPopupManager`)协调父弹窗和子弹窗的状态:
1. **子弹窗注册**:每个子弹窗组件在挂载时注册,获得唯一 ID
2. **激活/停用**:子弹窗打开时激活,关闭时停用
3. **状态同步**:管理器通知父弹窗隐藏/显示底部按钮
4. **响应式更新**:父弹窗通过 `watch` 监听全局状态变化
---
## 架构设计
### 组件关系图
```
PlanFormContainer (表单容器)
└── PlanPopupNew (父弹窗)
├── Footer Buttons (底部按钮)
└── <slot> (表单内容)
├── LifeInsuranceTemplate
│ ├── AgePickerGlobal (子弹窗)
│ ├── DatePickerGlobal (子弹窗)
│ └── SelectPickerGlobal (子弹窗)
├── CriticalIllnessTemplate
└── SavingsTemplate
```
### 数据流图
```
┌─────────────────────────────────────────────────────────────┐
│ GlobalPopupManager │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 全局状态: activePopups: Ref<string[]> │ │
│ │ parentPopupCallbacks: Function[] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ useParentPopup() useGlobalPopup() │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ registerCallback │ │ registerPopup()│ │
│ │ hasActiveChildPopup│ │ activatePopup()│ │
│ │ notifyCallbacks()│ │ deactivatePopup()│ │
│ └─────────────────┘ └─────────────────┘ │
│ ▲ ▲ │
│ │ │ │
│ ▼ ▼ │
│ PlanPopupNew.vue DatePickerGlobal.vue │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ showFooter: ref│ │ popupId: ref │ │
│ │ watch( │ │ onMounted() │ │
│ │ isActive) │ │ openPicker() │ │
│ └─────────────────┘ │ onConfirm() │ │
│ │ onCancel() │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 使用指南
### 快速开始
#### 1. 父弹窗组件(PlanPopupNew)
```vue
<script setup>
import { useParentPopup } from './PlanFields/GlobalPopupManager.js'
const { registerFooterCallback, hasActiveChildPopup } = useParentPopup()
// 注册回调(监听子弹窗状态)
const unregister = registerFooterCallback((shouldShowFooter) => {
showFooter.value = shouldShowFooter
})
// 监听全局状态变化(解决时序问题)
watch(hasActiveChildPopup, (isActive) => {
showFooter.value = !isActive
})
// 组件卸载时取消注册
onUnmounted(() => {
unregister()
})
</script>
<template>
<div class="footer-buttons" v-show="showFooter">
<button>取消</button>
<button>确定</button>
</div>
</template>
```
#### 2. 子弹窗组件(DatePickerGlobal)
```vue
<script setup>
import { useGlobalPopup } from './GlobalPopupManager.js'
const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
const popupId = ref(null)
// 组件挂载时注册弹窗
onMounted(() => {
popupId.value = registerPopup()
})
// 打开子弹窗时激活
const openPicker = () => {
if (popupId.value) {
activatePopup(popupId.value) // 隐藏父弹窗底部按钮
}
showPicker.value = true
}
// 关闭子弹窗时停用
const closePicker = () => {
if (popupId.value) {
deactivatePopup(popupId.value) // 恢复父弹窗底部按钮
}
showPicker.value = false
}
</script>
```
---
## API 文档
### `useParentPopup()`
**用途**: 父弹窗组件使用
**返回值**:
```typescript
{
registerFooterCallback: (callback: (shouldShowFooter: boolean) => void) => () => void,
hasActiveChildPopup: ComputedRef<boolean>,
notifyCallbacks: (shouldShowFooter: boolean) => void
}
```
**方法**:
#### `registerFooterCallback(callback)`
注册底部按钮回调函数
**参数**:
- `callback: (shouldShowFooter: boolean) => void` - 回调函数
- `shouldShowFooter = true` - 显示底部按钮
- `shouldShowFooter = false` - 隐藏底部按钮
**返回值**: `() => void` - 取消注册函数
**示例**:
```javascript
const unregister = registerFooterCallback((shouldShowFooter) => {
showFooter.value = shouldShowFooter
})
// 组件卸载时取消注册
onUnmounted(() => {
unregister()
})
```
#### `hasActiveChildPopup`
计算属性:当前是否有活动的子弹窗
**类型**: `ComputedRef<boolean>`
**示例**:
```javascript
watch(hasActiveChildPopup, (isActive) => {
console.log('是否有活动子弹窗:', isActive)
showFooter.value = !isActive
})
```
---
### `useGlobalPopup()`
**用途**: 子弹窗组件使用
**返回值**:
```typescript
{
registerPopup: () => string,
activatePopup: (popupId: string) => void,
deactivatePopup: (popupId: string) => void,
hasActiveChildPopup: ComputedRef<boolean>
}
```
**方法**:
#### `registerPopup()`
注册弹窗并获取唯一 ID
**返回值**: `string` - 弹窗 ID(格式:`popup-1`, `popup-2`, ...)
**示例**:
```javascript
const popupId = ref(null)
onMounted(() => {
popupId.value = registerPopup()
console.log('弹窗 ID:', popupId.value) // 输出: popup-1
})
```
#### `activatePopup(popupId)`
激活弹窗(隐藏父弹窗底部按钮)
**参数**:
- `popupId: string` - 弹窗 ID
**示例**:
```javascript
const openPicker = () => {
activatePopup(popupId.value) // 通知父弹窗隐藏底部按钮
showPicker.value = true
}
```
#### `deactivatePopup(popupId)`
停用弹窗(恢复父弹窗底部按钮)
**参数**:
- `popupId: string` - 弹窗 ID
**示例**:
```javascript
const closePicker = () => {
deactivatePopup(popupId.value) // 通知父弹窗显示底部按钮
showPicker.value = false
}
```
---
### `resetPopupState()`
**用途**: 重置所有弹窗状态(用于测试或异常恢复)
**示例**:
```javascript
import { resetPopupState } from './GlobalPopupManager.js'
// 重置所有弹窗状态
resetPopupState()
```
---
## 实现细节
### 全局状态管理
```javascript
// 活动弹窗列表
const activePopups = ref([])
// 是否有活动的子弹窗
const hasActiveChildPopup = computed(() => activePopups.value.length > 0)
// 父弹窗回调列表
const parentPopupCallbacks = []
```
### 激活/停用逻辑
#### 激活弹窗
```javascript
const activatePopup = (popupId) => {
if (!activePopups.value.includes(popupId)) {
activePopups.value.push(popupId)
// 通知所有父弹窗隐藏底部按钮
parentPopupCallbacks.forEach((callback) => {
callback(false) // false = 隐藏
})
}
}
```
#### 停用弹窗
```javascript
const deactivatePopup = (popupId) => {
const index = activePopups.value.indexOf(popupId)
if (index > -1) {
activePopups.value.splice(index, 1)
// 如果没有其他活动弹窗了,通知所有父弹窗显示底部按钮
if (activePopups.value.length === 0) {
parentPopupCallbacks.forEach((callback) => {
callback(true) // true = 显示
})
}
}
}
```
### 时序问题解决
使用 `watch` 监听全局状态,解决子弹窗先于父弹窗挂载的问题:
```javascript
// PlanPopupNew.vue
watch(hasActiveChildPopup, (isActive) => {
showFooter.value = !isActive
})
```
---
## 常见问题
### Q1: 为什么不直接使用 `provide/inject`?
**A**: `provide/inject` 模式存在以下问题:
1. **紧耦合**:子组件必须知道父组件提供的 `provide` key
2. **层级限制**:只能用于父子关系,跨层级使用困难
3. **维护困难**:多层嵌套时难以追踪数据流
**GlobalPopupManager** 优势:
- ✅ 松耦合:子组件无需知道父组件
- ✅ 全局管理:所有弹窗共享状态
- ✅ 易于扩展:支持多个父弹窗和多个子弹窗
---
### Q2: 如果同时打开多个子弹窗会怎样?
**A**: 系统支持多个子弹窗同时打开:
- 第一个子弹窗打开 → `activePopups = ['popup-1']` → 隐藏底部按钮
- 第二个子弹窗打开 → `activePopups = ['popup-1', 'popup-2']` → 保持隐藏
- 第一个子弹窗关闭 → `activePopups = ['popup-2']` → 保持隐藏
- 第二个子弹窗关闭 → `activePopups = []` → 显示底部按钮
只有当所有子弹窗都关闭时,底部按钮才会重新显示。
---
### Q3: 如何调试弹窗状态?
**A**: 添加临时日志:
```javascript
// 在 activatePopup 中添加日志
const activatePopup = (popupId) => {
console.log('[DEBUG] 激活弹窗:', popupId)
console.log('[DEBUG] 当前活动弹窗:', activePopups.value)
console.log('[DEBUG] 父弹窗回调数量:', parentPopupCallbacks.length)
// ...
}
```
---
### Q4: 支持多层嵌套吗?
**A**: 支持!例如:
```
PlanPopupNew (父弹窗)
└── SelectPickerGlobal (子弹窗)
└── DatePickerGlobal (孙弹窗)
```
所有层级的弹窗都会正确注册和激活,底部按钮会正确隐藏和显示。
---
## 相关文件
- `src/components/PlanFields/GlobalPopupManager.js` - 核心管理器
- `src/components/PlanPopupNew.vue` - 父弹窗组件
- `src/components/PlanFields/DatePickerGlobal.vue` - 日期选择器(子弹窗)
- `src/components/PlanFields/SelectPickerGlobal.vue` - 下拉选择器(子弹窗)
- `src/components/PlanFields/AgePickerGlobal.vue` - 年龄选择器(子弹窗)
---
## 更新日志
### v2.0.0 (2026-02-08)
- ✅ 实现 GlobalPopupManager 全局弹窗管理器
- ✅ 支持 `useParentPopup()``useGlobalPopup()` 接口
- ✅ 解决嵌套弹窗底部按钮遮挡问题
- ✅ 添加时序问题解决方案(`watch` 监听)
- ✅ 清除调试日志,生产就绪
---
**维护者**: Claude Code
**最后更新**: 2026-02-08
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
<span v-if="required" class="text-red-500 mr-1">*</span>
<span>{{ label }}</span>
</div>
<!-- 触发区域 -->
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
:class="{ 'bg-gray-50': showPicker }"
@tap="handleTap"
>
<span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- Picker 弹窗 -->
<nut-popup
position="bottom"
v-model:visible="showPicker"
:z-index="9999"
:overlay="true"
>
<nut-picker
v-model="pickerValue"
:columns="ageColumns"
@confirm="onConfirm"
@cancel="onCancel"
/>
</nut-popup>
</div>
</template>
<script setup>
/**
* 年龄选择器组件(全局弹窗管理器版本)
*
* @description 使用 NutUI Popup + Picker 实现年龄选择
* - 显示格式:3位数字(如 018 表示 18 岁)
* - 提交格式:数字(如 18)
* - 年龄范围:0-120 岁
* - 使用 GlobalPopupManager 管理弹窗层级
* @author Claude Code
* @version 2.0.0 - 支持全局弹窗管理器
* @example
* <AgePickerGlobal
* v-model="age"
* label="年龄"
* placeholder="请选择年龄"
* />
*/
import { ref, computed, watch, onMounted } from 'vue'
import IconFont from '@/components/IconFont.vue'
import { useGlobalPopup } from './GlobalPopupManager'
/**
* 使用全局弹窗管理器
*/
const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
/**
* 弹窗 ID(由 GlobalPopupManager 分配)
* @type {Ref<string|null>}
*/
const popupId = ref(null)
/**
* 组件挂载时注册弹窗
*/
onMounted(() => {
popupId.value = registerPopup()
})
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 是否必填
* @type {boolean}
*/
required: {
type: Boolean,
default: false
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择年龄'
},
/**
* 绑定的值(数字)
* @type {number}
*/
modelValue: {
type: Number,
default: null
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {number} value - 选中的年龄
*/
'update:modelValue',
/**
* 值变化事件
* @event change
* @param {number} value - 选中的年龄
*/
'change'
])
/**
* 控制 Picker 显示
*/
const showPicker = ref(false)
/**
* Picker 当前值(3位数字格式)
*/
const pickerValue = ref(['018'])
/**
* 年龄选项列(0-120 岁,3位数字格式)
*/
const ageColumns = computed(() => {
return [
Array.from({ length: 121 }, (_, i) => ({
text: `${i} 岁`,
value: String(i).padStart(3, '0')
}))
]
})
/**
* 显示的值(转换为中文格式)
*/
const displayValue = computed(() => {
if (props.modelValue === null || props.modelValue === undefined) {
return ''
}
return `${props.modelValue} 岁`
})
/**
* 点击触发区域
*/
const handleTap = () => {
// 激活弹窗(隐藏父弹窗底部按钮)
if (popupId.value) {
activatePopup(popupId.value)
}
// 如果有值,转换为3位数字格式
if (props.modelValue !== null && props.modelValue !== undefined) {
pickerValue.value = [String(props.modelValue).padStart(3, '0')]
}
showPicker.value = true
}
/**
* 确认选择
* @param {Object} { selectedValue } - Picker 返回的值
*
* @example
* // 用户选择 18 岁
* onConfirm({ selectedValue: ['018'] })
*/
const onConfirm = ({ selectedValue }) => {
// 将3位数字格式转换为普通数字
const age = parseInt(selectedValue[0], 10)
emit('update:modelValue', age)
emit('change', age)
// 停用弹窗(恢复父弹窗底部按钮)
if (popupId.value) {
deactivatePopup(popupId.value)
}
showPicker.value = false
}
/**
* 取消选择
*/
const onCancel = () => {
// 停用弹窗(恢复父弹窗底部按钮)
if (popupId.value) {
deactivatePopup(popupId.value)
}
showPicker.value = false
}
/**
* 监听 modelValue 变化,同步到 pickerValue
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== null && newVal !== undefined) {
pickerValue.value = [String(newVal).padStart(3, '0')]
}
}
)
</script>
<style lang="less">
/* 组件样式 */
</style>
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
<span v-if="required" class="text-red-500 mr-1">*</span>
<span>{{ label }}</span>
</div>
<!-- 触发区域 -->
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
:class="{ 'bg-gray-50': showDatePicker }"
@tap="openDatePicker"
>
<span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- DatePicker 弹窗 -->
<nut-popup position="bottom" v-model:visible="showDatePicker">
<nut-date-picker
v-model="currentDate"
:min-date="minDate"
:max-date="maxDate"
:is-show-chinese="true"
@confirm="onConfirm"
@cancel="onCancel"
>
</nut-date-picker>
</nut-popup>
</div>
</template>
<script setup>
/**
* 日期选择器组件(全局弹窗管理器版本)
*
* @description 使用 NutUI DatePicker + Popup 实现日期选择
* - 支持年龄范围限制(minAge, maxAge)
* - 格式:YYYY-MM-DD
* - 可触发自动计算年龄
* - 使用 GlobalPopupManager 管理弹窗层级
* @author Claude Code
* @version 2.0.0 - 支持全局弹窗管理器
* @example
* <DatePickerGlobal
* v-model="birthday"
* label="出生年月日"
* placeholder="请选择日期"
* :min-age="0"
* :max-age="120"
* @change="onBirthdayChange"
* />
*/
import { ref, computed, watch, onMounted } from 'vue'
import IconFont from '@/components/IconFont.vue'
import { useGlobalPopup } from './GlobalPopupManager'
/**
* 使用全局弹窗管理器
*/
const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
/**
* 弹窗 ID(由 GlobalPopupManager 分配)
* @type {Ref<string|null>}
*/
const popupId = ref(null)
/**
* 组件挂载时注册弹窗
*/
onMounted(() => {
popupId.value = registerPopup()
})
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 是否必填
* @type {boolean}
*/
required: {
type: Boolean,
default: false
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择日期'
},
/**
* 绑定的值(格式:YYYY-MM-DD)
* @type {string}
*/
modelValue: {
type: String,
default: ''
},
/**
* 最小年龄(用于计算最大出生日期)
* @type {number}
* @default 0
*/
minAge: {
type: Number,
default: 0
},
/**
* 最大年龄(用于计算最小出生日期)
* @type {number}
* @default 120
*/
maxAge: {
type: Number,
default: 120
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {string} value - 选中的日期(格式:YYYY-MM-DD)
*/
'update:modelValue',
/**
* 值变化事件(可用于触发自动计算年龄)
* @event change
* @param {string} value - 选中的日期(格式:YYYY-MM-DD)
*/
'change',
/**
* 弹窗打开事件
* @event open
*/
'open',
/**
* 弹窗关闭事件
* @event close
*/
'close'
])
/**
* 控制 DatePicker 显示
*/
const showDatePicker = ref(false)
/**
* 当前选中的日期(Date 对象)
* 用于绑定给 nut-date-picker
*/
const currentDate = ref(new Date())
/**
* 打开日期选择器
* @description 打开时将传入的 modelValue 转换为 Date 对象
*/
const openDatePicker = () => {
// 激活弹窗(隐藏父弹窗底部按钮)
if (popupId.value) {
activatePopup(popupId.value)
}
if (props.modelValue) {
// 兼容 iOS 的日期格式 (YYYY/MM/DD)
const dateStr = props.modelValue.replace(/-/g, '/')
const date = new Date(dateStr)
if (!Number.isNaN(date.getTime())) {
currentDate.value = date
}
} else {
currentDate.value = new Date()
}
showDatePicker.value = true
emit('open')
}
/**
* 计算最小可选日期(基于最大年龄)
* @description maxAge 岁对应的出生日期
* @example
* // maxAge = 75, 当前日期 = 2026-02-06
* // minDate() // 返回: 1951-02-06
*/
const minDate = computed(() => {
const date = new Date()
date.setFullYear(date.getFullYear() - props.maxAge)
return date
})
/**
* 计算最大可选日期(基于最小年龄)
* @description minAge 岁对应的出生日期
* @example
* // minAge = 0, 当前日期 = 2026-02-06
* // maxDate() // 返回: 2026-02-06
*/
const maxDate = computed(() => {
const date = new Date()
date.setFullYear(date.getFullYear() - props.minAge)
return date
})
/**
* 显示的值
*/
const displayValue = computed(() => {
return props.modelValue || ''
})
/**
* 确认选择
* @param {Object} { selectedValue } - DatePicker 返回的日期对象
*
* @example
* // 用户选择 2020-01-01
* onConfirm({ selectedValue: ['2020', '01', '01'] })
*/
const onConfirm = ({ selectedValue }) => {
// NutUI DatePicker confirm 事件返回 { selectedValue: [year, month, day], selectedOptions: [...] }
// 或者直接返回 Date 对象,取决于版本。
// 安全起见,我们查看 currentDate.value,它会被 v-model 更新
const date = currentDate.value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const formattedDate = `${year}-${month}-${day}`
emit('update:modelValue', formattedDate)
emit('change', formattedDate)
// 停用弹窗(恢复父弹窗底部按钮)
if (popupId.value) {
deactivatePopup(popupId.value)
}
showDatePicker.value = false
emit('close')
}
/**
* 取消选择
*/
const onCancel = () => {
// 停用弹窗(恢复父弹窗底部按钮)
if (popupId.value) {
deactivatePopup(popupId.value)
}
showDatePicker.value = false
emit('close')
}
</script>
<style lang="less">
/* 组件样式 */
</style>
/**
* 全局弹窗管理器
*
* @description 管理嵌套弹窗的层级和显示状态,解决弹窗遮挡问题
* @module GlobalPopupManager
* @author Claude Code
* @version 2.0.0
*/
import { ref, computed } from 'vue'
/**
* 全局状态:当前活动的弹窗列表
* @type {Ref<string[]>}
*/
const activePopups = ref([])
/**
* 是否有活动的子弹窗
* @type {ComputedRef<boolean>}
*/
const hasActiveChildPopup = computed(() => activePopups.value.length > 0)
/**
* 弹窗计数器(用于生成唯一 ID)
* @type {number}
*/
let popupCounter = 0
/**
* 父弹窗回调函数列表(全局共享)
* @type {Function[]}
*/
const parentPopupCallbacks = []
/**
* 注册父弹窗(用于 PlanPopupNew)
*
* @description 提供给父弹窗使用的 composable
* @returns {Object} 父弹窗控制方法
*
* @example
* const { registerFooterCallback, hasActiveChildPopup } = useParentPopup()
*
* // 注册回调,当子弹窗打开时自动隐藏父级 footer
* const unregister = registerFooterCallback((shouldShowFooter) => {
* showFooter.value = shouldShowFooter
* })
*/
export function useParentPopup() {
/**
* 注册底部按钮回调
*
* @param {Function} callback - 回调函数,接收 shouldShowFooter 参数
* @returns {Function} 取消注册函数
*/
const registerFooterCallback = (callback) => {
parentPopupCallbacks.push(callback)
// 返回取消注册函数
return () => {
const index = parentPopupCallbacks.indexOf(callback)
if (index > -1) {
parentPopupCallbacks.splice(index, 1)
}
}
}
/**
* 通知所有回调
*
* @param {boolean} shouldShowFooter - 是否显示底部按钮
*/
const notifyCallbacks = (shouldShowFooter) => {
parentPopupCallbacks.forEach(callback => callback(shouldShowFooter))
}
return {
registerFooterCallback,
hasActiveChildPopup,
notifyCallbacks
}
}
/**
* 注册全局弹窗(用于 DatePickerGlobal, SelectPickerGlobal 等)
*
* @description 提供给需要全局管理的弹窗组件使用
* @returns {Object} 弹窗控制方法
*
* @example
* const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
*
* // 组件挂载时注册
* const popupId = ref(null)
* onMounted(() => {
* popupId.value = registerPopup()
* })
*
* // 弹窗打开时激活
* activatePopup(popupId.value)
*
* // 弹窗关闭时停用
* deactivatePopup(popupId.value)
*/
export function useGlobalPopup() {
/**
* 注册弹窗
*
* @description 生成唯一的弹窗 ID
* @returns {string} 弹窗 ID(格式:popup-1, popup-2, ...)
*/
const registerPopup = () => {
popupCounter++
return `popup-${popupCounter}`
}
/**
* 激活弹窗
*
* @description 将弹窗添加到活动列表,触发父弹窗隐藏底部按钮
* @param {string} popupId - 弹窗 ID
*/
const activatePopup = (popupId) => {
if (!activePopups.value.includes(popupId)) {
activePopups.value.push(popupId)
// 通知所有父弹窗隐藏底部按钮
parentPopupCallbacks.forEach((callback) => {
callback(false)
})
}
}
/**
* 停用弹窗
*
* @description 从活动列表中移除弹窗,触发父弹窗显示底部按钮
* @param {string} popupId - 弹窗 ID
*/
const deactivatePopup = (popupId) => {
const index = activePopups.value.indexOf(popupId)
if (index > -1) {
activePopups.value.splice(index, 1)
// 如果没有其他活动弹窗了,通知所有父弹窗显示底部按钮
if (activePopups.value.length === 0) {
parentPopupCallbacks.forEach((callback) => {
callback(true)
})
}
}
}
return {
registerPopup,
activatePopup,
deactivatePopup,
hasActiveChildPopup
}
}
/**
* 注册子弹窗(旧接口,向后兼容)
*
* @description 提供给子弹窗使用的 composable
* @param {Function} notifyParent - 通知父弹窗的函数
* @returns {Object} 子弹窗控制方法
*/
export function useChildPopup(notifyParent) {
/**
* 子弹窗打开时通知父弹窗
*/
const notifyParentOpen = () => {
if (notifyParent) {
notifyParent(false)
}
}
/**
* 子弹窗关闭时通知父弹窗
*/
const notifyParentClose = () => {
if (notifyParent) {
notifyParent(true)
}
}
return {
notifyParentOpen,
notifyParentClose
}
}
/**
* 重置所有弹窗状态
*
* @description 用于测试或异常情况下的状态重置
*/
export function resetPopupState() {
activePopups.value = []
popupCounter = 0
}
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
<span v-if="required" class="text-red-500 mr-1">*</span>
<span>{{ label }}</span>
</div>
<!-- 触发区域 -->
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
:class="{ 'bg-gray-50': showPicker }"
@tap="openPicker"
>
<span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- Picker 弹窗 -->
<nut-popup
position="bottom"
v-model:visible="showPicker"
:overlay="true"
>
<nut-picker
:columns="pickerColumns"
@confirm="onConfirm"
@cancel="onCancel"
/>
</nut-popup>
</div>
</template>
<script setup>
/**
* 下拉选择器组件(全局弹窗管理器版本)
*
* @description 使用 NutUI Picker 实现下拉选择功能
* - key 和 value 相同(如"整付(0-75 岁)")
* - 适用于缴费年期等场景
* - 使用 GlobalPopupManager 管理弹窗层级
* @author Claude Code
* @version 2.0.0 - 支持全局弹窗管理器
* @example
* <SelectPickerGlobal
* v-model="paymentPeriod"
* label="缴费年期"
* placeholder="请选择缴费年期"
* :options="['整付(0-75 岁)', '5 年(0-70 岁)']"
* />
*/
import { ref, computed, onMounted } from 'vue'
import IconFont from '@/components/IconFont.vue'
import { useGlobalPopup } from './GlobalPopupManager'
/**
* 使用全局弹窗管理器
*/
const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
/**
* 弹窗 ID(由 GlobalPopupManager 分配)
* @type {Ref<string|null>}
*/
const popupId = ref(null)
/**
* 组件挂载时注册弹窗
*/
onMounted(() => {
popupId.value = registerPopup()
})
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 是否必填
* @type {boolean}
*/
required: {
type: Boolean,
default: false
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择'
},
/**
* 绑定的值
* @type {string}
*/
modelValue: {
type: String,
default: ''
},
/**
* 选项数组(key 和 value 相同)
* @type {Array<string>}
* @example ['整付(0-75 岁)', '5 年(0-70 岁)', '10 年(0-70 岁)']
*/
options: {
type: Array,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {string} value - 选中的选项
*/
'update:modelValue',
/**
* 弹窗打开事件
* @event open
*/
'open',
/**
* 弹窗关闭事件
* @event close
*/
'close'
])
/**
* 控制 Picker 显示
*/
const showPicker = ref(false)
/**
* 打开选择器
*/
const openPicker = () => {
// 激活弹窗(隐藏父弹窗底部按钮)
if (popupId.value) {
activatePopup(popupId.value)
}
showPicker.value = true
emit('open')
}
/**
* 转换为 Picker 格式
* @description 将选项数组转换为 Picker 需要的格式
* @example
* // options = ['整付(0-75 岁)', '5 年(0-70 岁)']
* // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...]
*/
const pickerColumns = computed(() => {
return props.options.map(option => ({
text: option,
value: option // key 和 value 相同
}))
})
/**
* 显示的值
*/
const displayValue = computed(() => {
return props.modelValue || ''
})
/**
* 确认选择
* @param {Object} params - Picker 返回参数
* @param {Array} params.selectedOptions - 选中的选项数组
*
* @example
* // 用户选择 '整付(0-75 岁)'
* onConfirm({ selectedOptions: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }] })
* // -> emit('update:modelValue', '整付(0-75 岁)')
*/
const onConfirm = ({ selectedOptions }) => {
const value = selectedOptions[0]?.value
if (value !== undefined) {
emit('update:modelValue', value)
}
// 停用弹窗(恢复父弹窗底部按钮)
if (popupId.value) {
deactivatePopup(popupId.value)
}
showPicker.value = false
emit('close')
}
/**
* 取消选择
*/
const onCancel = () => {
// 停用弹窗(恢复父弹窗底部按钮)
if (popupId.value) {
deactivatePopup(popupId.value)
}
showPicker.value = false
emit('close')
}
</script>
<style lang="less">
/* 组件样式 */
</style>
<template>
<!-- 使用 PlanPopup 容器组件 -->
<PlanPopup
<!-- 使用 PlanPopupNew 容器组件(支持全局弹窗管理器) -->
<PlanPopupNew
:visible="props.visible"
:title="templateConfig?.name || '计划书'"
@close="close"
......@@ -20,7 +20,7 @@
<p>⚠️ 未找到对应的计划书模版</p>
<p class="text-sm mt-2">form_sn: {{ product?.form_sn }}</p>
</div>
</PlanPopup>
</PlanPopupNew>
</template>
<script setup>
......@@ -41,7 +41,7 @@
* />
*/
import { ref, computed, watch, nextTick } from 'vue'
import PlanPopup from './PlanPopup/index.vue'
import PlanPopupNew from './PlanPopupNew.vue'
import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'
......
<!--
* @Date: 2026-02-08
* @Description: 计划书弹窗容器组件(支持全局弹窗管理器)
-->
<template>
<nut-popup
:visible="visible"
position="bottom"
round
:style="{ height: '90%' }"
:close-on-click-overlay="true"
:safe-area-inset-bottom="true"
@update:visible="handleVisibleChange"
>
<div class="h-full flex flex-col bg-gray-50 overflow-hidden rounded-t-2xl">
<!-- Header -->
<div class="flex justify-between items-center px-5 py-4 bg-white border-b border-gray-100 flex-shrink-0">
<span class="text-lg font-bold text-gray-900">{{ title }}</span>
<div class="p-2 -mr-2" @click="handleClose">
<IconFont name="close" size="16" color="#9CA3AF" />
</div>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-4">
<div class="bg-white rounded-xl p-5 shadow-sm">
<slot></slot>
</div>
</div>
<!-- Footer Buttons -->
<div
v-show="showFooter"
class="p-4 bg-white border-t border-gray-100 flex gap-3 flex-shrink-0 pb-safe"
>
<nut-button
plain
type="primary"
class="flex-1 !h-[88rpx] !rounded-[16rpx] !text-[30rpx] !border-blue-600"
@click="handleClose"
>
取消
</nut-button>
<nut-button
type="primary"
color="#2563EB"
class="flex-1 !h-[88rpx] !rounded-[16rpx] !text-[30rpx]"
@click="handleSubmit"
>
生成计划书
</nut-button>
</div>
</div>
</nut-popup>
</template>
<script setup>
/**
* @description 录入计划书弹窗容器组件(支持全局弹窗管理器)
* @description 自动监听子弹窗状态,隐藏/显示底部按钮
* @param {boolean} visible - 控制弹窗显示隐藏
* @param {string} title - 弹窗标题
* @emits update:visible - 更新 visible 状态
* @emits close - 关闭弹窗
* @emits submit - 提交表单
* @author Claude Code
* @version 2.0.0 - 支持全局弹窗管理器
*/
import { ref, watch, onMounted, onUnmounted } from 'vue'
import IconFont from '@/components/IconFont.vue'
import { useParentPopup } from './PlanFields/GlobalPopupManager.js'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '计划书',
},
})
const emit = defineEmits(['update:visible', 'close', 'submit'])
/**
* 底部按钮显示状态
* @type {Ref<boolean>}
*/
const showFooter = ref(true)
/**
* 使用父弹窗管理器
*/
const { registerFooterCallback, hasActiveChildPopup } = useParentPopup()
/**
* 取消注册回调函数
* @type {Function|null}
*/
let unregisterFooterCallback = null
/**
* 组件挂载时注册回调
*/
onMounted(() => {
// 注册回调,当子弹窗打开/关闭时自动隐藏/显示底部按钮
unregisterFooterCallback = registerFooterCallback((shouldShowFooter) => {
showFooter.value = shouldShowFooter
})
// 初始化时检查是否已有活动弹窗
if (hasActiveChildPopup.value) {
showFooter.value = false
}
})
/**
* 组件卸载时取消注册
*/
onUnmounted(() => {
if (unregisterFooterCallback) {
unregisterFooterCallback()
}
})
/**
* 监听全局弹窗状态变化
* 当子弹窗打开/关闭时,自动隐藏/显示底部按钮
*/
watch(hasActiveChildPopup, (isActive) => {
showFooter.value = !isActive
})
// 处理 visible 变化事件
const handleVisibleChange = (value) => {
emit('update:visible', value)
if (!value) {
// 重置底部按钮显示状态
showFooter.value = true
emit('close')
}
}
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
const handleSubmit = () => {
emit('submit')
}
</script>
<style lang="less">
:deep(.nut-popup) {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
background-color: #F9FAFB;
}
/* 适配底部安全区 */
.pb-safe {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
</style>
......@@ -82,11 +82,11 @@
*/
import { reactive, watch } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
/**
* 组件属性
......
......@@ -82,11 +82,11 @@
*/
import { reactive, watch, toRefs } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
/**
* 组件属性
......
......@@ -207,11 +207,11 @@
*/
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
/**
* 组件属性
......