GlobalPopupManager-弹窗管理器.md
12.1 KB
全局弹窗管理器 (GlobalPopupManager)
版本: 2.0.0 作者: Claude Code 创建日期: 2026-02-08 状态: ✅ 已实现并测试通过
📋 目录
问题背景
原始问题
在计划书功能中,当父弹窗(PlanPopupNew)内嵌套子弹窗(如 DatePickerGlobal、SelectPickerGlobal、AgePickerGlobal)时,出现以下问题:
- 底部按钮遮挡:子弹窗被父弹窗的底部按钮遮挡
- 用户体验差:用户无法看到或操作子弹窗的内容
技术原因
NutUI 的 nut-popup 组件使用 z-index 控制层级,但:
- 父弹窗和子弹窗都是
position: fixed定位 - 即使子弹窗
z-index更高,也无法遮挡父弹窗的非子元素(如底部按钮) - 底部按钮在 DOM 结构上与子弹窗同级,因此会覆盖子弹窗
解决方案
核心思路
当子弹窗打开时,自动隐藏父弹窗的底部按钮
实现方式
通过全局弹窗管理器(GlobalPopupManager)协调父弹窗和子弹窗的状态:
- 子弹窗注册:每个子弹窗组件在挂载时注册,获得唯一 ID
- 激活/停用:子弹窗打开时激活,关闭时停用
- 状态同步:管理器通知父弹窗隐藏/显示底部按钮
-
响应式更新:父弹窗通过
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)
<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)
<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()
用途: 父弹窗组件使用
返回值:
{
registerFooterCallback: (callback: (shouldShowFooter: boolean) => void) => () => void,
hasActiveChildPopup: ComputedRef<boolean>,
notifyCallbacks: (shouldShowFooter: boolean) => void
}
方法:
registerFooterCallback(callback)
注册底部按钮回调函数
参数:
-
callback: (shouldShowFooter: boolean) => void- 回调函数-
shouldShowFooter = true- 显示底部按钮 -
shouldShowFooter = false- 隐藏底部按钮
-
返回值: () => void - 取消注册函数
示例:
const unregister = registerFooterCallback((shouldShowFooter) => {
showFooter.value = shouldShowFooter
})
// 组件卸载时取消注册
onUnmounted(() => {
unregister()
})
hasActiveChildPopup
计算属性:当前是否有活动的子弹窗
类型: ComputedRef<boolean>
示例:
watch(hasActiveChildPopup, (isActive) => {
console.log('是否有活动子弹窗:', isActive)
showFooter.value = !isActive
})
useGlobalPopup()
用途: 子弹窗组件使用
返回值:
{
registerPopup: () => string,
activatePopup: (popupId: string) => void,
deactivatePopup: (popupId: string) => void,
hasActiveChildPopup: ComputedRef<boolean>
}
方法:
registerPopup()
注册弹窗并获取唯一 ID
返回值: string - 弹窗 ID(格式:popup-1, popup-2, ...)
示例:
const popupId = ref(null)
onMounted(() => {
popupId.value = registerPopup()
console.log('弹窗 ID:', popupId.value) // 输出: popup-1
})
activatePopup(popupId)
激活弹窗(隐藏父弹窗底部按钮)
参数:
-
popupId: string- 弹窗 ID
示例:
const openPicker = () => {
activatePopup(popupId.value) // 通知父弹窗隐藏底部按钮
showPicker.value = true
}
deactivatePopup(popupId)
停用弹窗(恢复父弹窗底部按钮)
参数:
-
popupId: string- 弹窗 ID
示例:
const closePicker = () => {
deactivatePopup(popupId.value) // 通知父弹窗显示底部按钮
showPicker.value = false
}
resetPopupState()
用途: 重置所有弹窗状态(用于测试或异常恢复)
示例:
import { resetPopupState } from './GlobalPopupManager.js'
// 重置所有弹窗状态
resetPopupState()
实现细节
全局状态管理
// 活动弹窗列表
const activePopups = ref([])
// 是否有活动的子弹窗
const hasActiveChildPopup = computed(() => activePopups.value.length > 0)
// 父弹窗回调列表
const parentPopupCallbacks = []
激活/停用逻辑
激活弹窗
const activatePopup = (popupId) => {
if (!activePopups.value.includes(popupId)) {
activePopups.value.push(popupId)
// 通知所有父弹窗隐藏底部按钮
parentPopupCallbacks.forEach((callback) => {
callback(false) // false = 隐藏
})
}
}
停用弹窗
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 监听全局状态,解决子弹窗先于父弹窗挂载的问题:
// PlanPopupNew.vue
watch(hasActiveChildPopup, (isActive) => {
showFooter.value = !isActive
})
常见问题
Q1: 为什么不直接使用 provide/inject?
A: provide/inject 模式存在以下问题:
-
紧耦合:子组件必须知道父组件提供的
providekey - 层级限制:只能用于父子关系,跨层级使用困难
- 维护困难:多层嵌套时难以追踪数据流
GlobalPopupManager 优势:
- ✅ 松耦合:子组件无需知道父组件
- ✅ 全局管理:所有弹窗共享状态
- ✅ 易于扩展:支持多个父弹窗和多个子弹窗
Q2: 如果同时打开多个子弹窗会怎样?
A: 系统支持多个子弹窗同时打开:
- 第一个子弹窗打开 →
activePopups = ['popup-1']→ 隐藏底部按钮 - 第二个子弹窗打开 →
activePopups = ['popup-1', 'popup-2']→ 保持隐藏 - 第一个子弹窗关闭 →
activePopups = ['popup-2']→ 保持隐藏 - 第二个子弹窗关闭 →
activePopups = []→ 显示底部按钮
只有当所有子弹窗都关闭时,底部按钮才会重新显示。
Q3: 如何调试弹窗状态?
A: 添加临时日志:
// 在 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