GlobalPopupManager-弹窗管理器.md 12.1 KB

全局弹窗管理器 (GlobalPopupManager)

版本: 2.0.0 作者: Claude Code 创建日期: 2026-02-08 状态: ✅ 已实现并测试通过


📋 目录


问题背景

原始问题

在计划书功能中,当父弹窗(PlanPopupNew)内嵌套子弹窗(如 DatePickerGlobalSelectPickerGlobalAgePickerGlobal)时,出现以下问题:

  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)

<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 模式存在以下问题:

  1. 紧耦合:子组件必须知道父组件提供的 provide key
  2. 层级限制:只能用于父子关系,跨层级使用困难
  3. 维护困难:多层嵌套时难以追踪数据流

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