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']
}
}
......
This diff is collapsed. Click to expand it.
<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'
/**
* 组件属性
......