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>
Showing
11 changed files
with
1140 additions
and
14 deletions
| ... | @@ -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 | } | ... | ... |
docs/GlobalPopupManager-弹窗管理器.md
0 → 100644
This diff is collapsed. Click to expand it.
| 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' | ... | ... |
src/components/PlanPopupNew.vue
0 → 100644
| 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 | * 组件属性 | ... | ... |
-
Please register or login to post a comment