hookehuyr

feat(plan): 新增 AmountKeyboard 数字键盘输入组件

创建 AmountKeyboard 组件替代 AmountInput 输入框,实现数字键盘交互:

核心功能:
- 点击弹出数字键盘(右列模式,包含小数点)
- 金额显示弹窗(渐变背景 + 装饰圆圈)
- 输入验证(1个小数点,2位小数)
- 千分位分隔符显示
- 集成 GlobalPopupManager 解决嵌套弹窗层级

技术实现:
- nut-number-keyboard @input 事件(单字符传递)
- 字符累加逻辑:String(inputValue) + String(val)
- 输入验证:split('.') 检查小数点和小数位数量
- toFixed(2) 确保保存格式一致性
- watch(showKeyboard) 同步键盘和弹窗状态

问题解决:
1. Vue渲染错误:使用 kebab-case 组件名
2. 键盘被底部按钮遮挡:使用 GlobalPopupManager
3. 单字符输入:实现字符累加
4. 字符串相加变数字相加:显式 String() 转换
5. 输入验证阻塞:初始化为空字符串
6. 保存格式问题:使用 toFixed(2)
7. 自动关闭bug:时间判断过滤
8. 状态同步:watch 同步关闭金额弹窗

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -5,6 +5,109 @@ ...@@ -5,6 +5,109 @@
5 5
6 --- 6 ---
7 7
8 +## [2026-02-09] - 新增 AmountKeyboard 数字键盘输入组件
9 +
10 +### 新增
11 +- 创建 AmountKeyboard.vue 组件,替代 AmountInput 输入框
12 +- 实现数字键盘(nut-number-keyboard)点击触发
13 +- 新增金额显示弹窗(页面中间显示输入内容,渐变背景设计)
14 +- 集成 GlobalPopupManager 解决嵌套弹窗层级问题
15 +- 实现字符累加逻辑,支持连续录入(123)
16 +- 添加输入验证(1个小数点,2位小数)
17 +- 添加千分位分隔符显示
18 +- 实现 toFixed(2) 确保保存格式一致性
19 +- 防止点击遮罩关闭弹窗
20 +- 添加 watch(showKeyboard) 同步键盘和弹窗状态
21 +
22 +### 功能特点
23 +- 点击输入框弹出数字键盘(右列模式,包含小数点)
24 +- 金额显示弹窗(渐变背景 + 装饰圆圈 + 大号字体)
25 +- 输入验证(限制1个小数点,2位小数)
26 +- 连续输入支持(字符累加,字符串类型安全)
27 +- 状态同步(键盘关闭时自动关闭金额弹窗)
28 +- 多币种支持(CNY、USD、HKD、EUR)
29 +
30 +### 技术实现
31 +- 使用 nut-number-keyboard 的 @input 事件(单字符传递)
32 +- 字符累加:`String(inputValue.value) + String(val)`
33 +- 输入验证:split('.') 检查小数点和小数位数量
34 +- 初始化:空字符串(非"0.00")避免验证冲突
35 +- 保存:parseFloat(yuan.toFixed(2)) 确保格式
36 +- 单位转换:内部存储为分(整数),显示为元(带2位小数)
37 +
38 +### 问题解决
39 +1. Vue渲染错误:使用正确的 kebab-case 组件名 `nut-number-keyboard`
40 +2. 键盘被底部按钮遮挡:使用 GlobalPopupManager 解决嵌套弹窗层级
41 +3. 单字符输入:实现字符累加逻辑
42 +4. 字符串相加变成数字相加(1+2=3):显式使用 String() 转换
43 +5. 输入验证阻塞:初始化为空字符串而非 "0.00"
44 +6. 保存格式问题:使用 toFixed(2) 确保 "12.00" 格式
45 +7. 自动关闭bug:添加时间判断过滤(500ms内)
46 +8. 状态同步:watch(showKeyboard) 同步关闭金额弹窗
47 +
48 +### 影响文件
49 +- src/components/PlanFields/AmountKeyboard.vue(新增)
50 +- src/components/PlanTemplates/LifeInsuranceTemplate.vue(使用新组件)
51 +- src/components/PlanTemplates/CriticalIllnessTemplate.vue(使用新组件)
52 +- src/components/PlanTemplates/SavingsTemplate.vue(使用新组件)
53 +- docs/lessons-learned.md(添加经验教训记录)
54 +
55 +**详细信息**
56 +- **影响文件**: 见上文
57 +- **技术栈**: Vue 3, Taro 4, NutUI, GlobalPopupManager
58 +- **测试状态**: ✅ 已通过
59 +- **备注**: 替代原有的 AmountInput 组件,老组件保留用于测试
60 +
61 +---
62 +
63 +## [2026-02-08] - 实现全局弹窗管理器解决嵌套弹窗遮挡问题
64 +
65 +### 新增
66 +- 创建 GlobalPopupManager 全局弹窗管理器
67 +- 实现 useParentPopup 和 useGlobalPopup 接口
68 +- 新增 PlanPopupNew 父弹窗组件(支持全局管理)
69 +- 新增 DatePickerGlobal 日期选择器(支持全局管理)
70 +- 新增 SelectPickerGlobal 下拉选择器(支持全局管理)
71 +- 新增 AgePickerGlobal 年龄选择器(支持全局管理)
72 +
73 +### 优化
74 +- 子弹窗打开时自动隐藏父弹窗底部按钮
75 +- 所有子弹窗关闭时自动恢复底部按钮
76 +- 支持多个子弹窗同时打开
77 +- 支持多层弹窗嵌套(弹窗套弹窗)
78 +- 使用 watch 监听全局状态,解决组件挂载时序问题
79 +
80 +### 问题原因
81 +**NutUI 嵌套弹窗的 z-index 层级问题**
82 +- 父弹窗和子弹窗都是 `position: fixed` 定位
83 +- 即使子弹窗 `z-index` 更高,也无法遮挡父弹窗的非子元素(如底部按钮)
84 +- 底部按钮在 DOM 结构上与子弹窗同级,会覆盖子弹窗内容
85 +
86 +### 解决方案
87 +**核心策略**:全局弹窗管理器协调父弹窗和子弹窗状态
88 +
89 +1. **子弹窗注册**:每个子弹窗组件挂载时注册,获得唯一 ID
90 +2. **激活/停用**:子弹窗打开时激活,关闭时停用
91 +3. **状态同步**:管理器通知父弹窗隐藏/显示底部按钮
92 +4. **响应式更新**:父弹窗通过 watch 监听全局状态变化
93 +
94 +### 迁移工作
95 +- 更新 PlanFormContainer 使用 PlanPopupNew
96 +- 更新所有计划模板(LifeInsuranceTemplate、CriticalIllnessTemplate、SavingsTemplate)使用 Global 版本字段组件
97 +
98 +### 影响文件
99 +- src/components/PlanFormContainer.vue
100 +- src/components/PlanPopupNew.vue
101 +- src/components/PlanFields/GlobalPopupManager.js
102 +- src/components/PlanFields/DatePickerGlobal.vue
103 +- src/components/PlanFields/SelectPickerGlobal.vue
104 +- src/components/PlanFields/AgePickerGlobal.vue
105 +- src/components/PlanTemplates/LifeInsuranceTemplate.vue
106 +- src/components/PlanTemplates/CriticalIllnessTemplate.vue
107 +- src/components/PlanTemplates/SavingsTemplate.vue
108 +
109 +---
110 +
8 ## [2026-02-08] - 修复计划书表单重置和数据同步问题 111 ## [2026-02-08] - 修复计划书表单重置和数据同步问题
9 112
10 ### 修复 113 ### 修复
......
...@@ -1945,21 +1945,220 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用 ...@@ -1945,21 +1945,220 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用
1945 1945
1946 --- 1946 ---
1947 1947
1948 +## 组件开发案例:AmountKeyboard 数字键盘组件 ⭐ 新增
1949 +
1950 +### 问题描述
1951 +
1952 +需要创建一个保额输入组件,点击后弹出数字键盘进行输入,而不是直接使用输入框。
1953 +
1954 +### 遇到的坑和解决方案
1955 +
1956 +#### 坑 1: Vue 渲染错误 - Invalid vnode type
1957 +
1958 +**错误信息**:
1959 +```
1960 +[Vue warn]: Invalid vnode type when creating vnode: undefined
1961 +```
1962 +
1963 +**原因**: 使用了错误的组件名 `nut-numberkeyboard` (驼峰式)
1964 +
1965 +**解决方案**: 使用正确的 kebab-case 命名 `<nut-number-keyboard>`
1966 +
1967 +**官方示例**:
1968 +```vue
1969 +<nut-number-keyboard v-model:visible="show" type="rightColumn" />
1970 +```
1971 +
1972 +---
1973 +
1974 +#### 坑 2: 键盘被底部按钮遮挡
1975 +
1976 +**现象**: 数字键盘的底部按钮被页面底部按钮遮挡
1977 +
1978 +**错误尝试**: 使用 `z-index` CSS 调整(无效)
1979 +
1980 +**正确方案**: 使用 GlobalPopupManager 系统
1981 +```javascript
1982 +import { useGlobalPopup } from './GlobalPopupManager.js'
1983 +
1984 +const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
1985 +
1986 +// 注册弹窗
1987 +const keyboardPopupId = ref(null)
1988 +onMounted(() => {
1989 + keyboardPopupId.value = registerPopup()
1990 +})
1991 +
1992 +// 监听键盘状态
1993 +watch(showKeyboard, (newValue) => {
1994 + if (newValue) {
1995 + activatePopup(keyboardPopupId.value)
1996 + } else {
1997 + deactivatePopup(keyboardPopupId.value)
1998 + }
1999 +})
2000 +```
2001 +
2002 +---
2003 +
2004 +#### 坑 3: 数字输入只能录入单个数字
2005 +
2006 +**现象**: 输入 "123" 时,只显示最后一个数字
2007 +
2008 +**原因**: `nut-number-keyboard` 的 `@input` 事件传入的是单个字符,需要累加
2009 +
2010 +**错误代码**:
2011 +```javascript
2012 +const onInput = (val) => {
2013 + inputValue.value = val // ❌ 直接替换
2014 +}
2015 +```
2016 +
2017 +**正确代码**:
2018 +```javascript
2019 +const onInput = (val) => {
2020 + if (!inputValue.value) {
2021 + inputValue.value = String(val)
2022 + } else {
2023 + inputValue.value = String(inputValue.value) + String(val)
2024 + }
2025 +}
2026 +```
2027 +
2028 +---
2029 +
2030 +#### 坑 4: 输入验证导致无法输入
2031 +
2032 +**现象**: 初始化为 "0.00" 后,所有输入都被忽略
2033 +
2034 +**原因**: 验证逻辑判断 `parts[1].length >= 2`,认为已有2位小数
2035 +
2036 +**解决方案**: 初始化时使用空字符串,而不是 "0.00"
2037 +```javascript
2038 +const openKeyboard = () => {
2039 + if (props.modelValue !== null && props.modelValue !== undefined) {
2040 + inputValue.value = (props.modelValue / 100).toFixed(2)
2041 + } else {
2042 + inputValue.value = '' // ✅ 空字符串,不是 '0.00'
2043 + }
2044 +}
2045 +```
2046 +
2047 +**验证逻辑**:
2048 +```javascript
2049 +const onInput = (val) => {
2050 + // 检查小数点
2051 + if (val === '.' && inputValue.value.includes('.')) {
2052 + return // 已有小数点,忽略
2053 + }
2054 +
2055 + // 检查小数位
2056 + if (val >= '0' && val <= '9') {
2057 + const parts = inputValue.value.split('.')
2058 + if (parts.length === 2 && parts[1]?.length >= 2) {
2059 + return // 已有2位小数,忽略
2060 + }
2061 + }
2062 +
2063 + // 累加输入
2064 + if (!inputValue.value) {
2065 + inputValue.value = String(val)
2066 + } else {
2067 + inputValue.value = String(inputValue.value) + String(val)
2068 + }
2069 +}
2070 +```
2071 +
2072 +---
2073 +
2074 +#### 坑 5: 键盘自动关闭问题
2075 +
2076 +**现象**: 键盘刚打开就立即关闭
2077 +
2078 +**原因**: `@close` 事件在打开时被误触发
2079 +
2080 +**解决方案**: 添加时间判断,过滤误触发
2081 +```javascript
2082 +const keyboardOpenTime = ref(0)
2083 +
2084 +watch(showKeyboard, (newValue) => {
2085 + if (newValue) {
2086 + keyboardOpenTime.value = Date.now() // 记录打开时间
2087 + }
2088 +})
2089 +
2090 +const onClose = () => {
2091 + const timeSinceOpen = Date.now() - keyboardOpenTime.value
2092 + if (timeSinceOpen < 500) {
2093 + return // 忽略误触发
2094 + }
2095 + showKeyboard.value = false
2096 +}
2097 +```
2098 +
2099 +**最终方案**: 移除 `@close` 监听,在 `watch(showKeyboard)` 中同步关闭金额弹窗
2100 +
2101 +---
2102 +
2103 +#### 坑 6: 字符串累加被当作数字相加
2104 +
2105 +**现象**: 输入 1、2、3 后显示 6(1+2+3)
2106 +
2107 +**原因**: JavaScript `+=` 运算符将字符串当作数字相加
2108 +
2109 +**解决方案**: 确保两边都是字符串类型
2110 +```javascript
2111 +inputValue.value = String(inputValue.value) + String(val)
2112 +```
2113 +
2114 +---
2115 +
2116 +#### 坑 7: 保存后再次编辑显示问题
2117 +
2118 +**问题**: 保存 "12.23",删除后保存变成 "12" 而不是 "12.00"
2119 +
2120 +**原因**: `parseFloat("12")` 丢失小数点后的零
2121 +
2122 +**解决方案**: 使用 `toFixed(2)` 保留两位小数
2123 +```javascript
2124 +const onConfirm = () => {
2125 + let yuan = parseFloat(inputValue.value || '0')
2126 + if (!Number.isNaN(yuan)) {
2127 + const formattedYuan = parseFloat(yuan.toFixed(2))
2128 + const cents = Math.round(formattedYuan * 100)
2129 + emit('update:modelValue', cents)
2130 + }
2131 +}
2132 +```
2133 +
2134 +---
2135 +
2136 +### ✅ 最终实现要点
2137 +
2138 +1. **组件结构**: 点击区域 + 金额弹窗 + 数字键盘三层结构
2139 +2. **事件处理**: `@input` 累加,`@delete` 删除,`@confirm` 保存
2140 +3. **输入验证**: 限制小数点(1个)和小数位(2位)
2141 +4. **状态管理**: `watch(showKeyboard)` 同步关闭金额弹窗
2142 +5. **美观设计**: 渐变背景 + 圆形装饰 + 大号数字显示
2143 +
2144 +---
2145 +
1948 ## 总结 2146 ## 总结
1949 2147
1950 ### 🎯 核心经验 2148 ### 🎯 核心经验
1951 2149
1952 1. **"第 3 次出现原则"**: 代码重复 3 次时必须抽取 2150 1. **"第 3 次出现原则"**: 代码重复 3 次时必须抽取
1953 2. **NutUI 陷阱**: textarea、IconFont 等组件有坑,优先使用原生组件 2151 2. **NutUI 陷阱**: textarea、IconFont 等组件有坑,优先使用原生组件
1954 -3. **静态资源**: SVG 图标必须使用 `import` 导入 2152 +3. **⭐ 数字键盘组件**: `@input` 传入单个字符需要累加,注意类型转换
1955 -4. **样式策略**: TailwindCSS(80%) + Less(20%) 混合使用 2153 +4. **静态资源**: SVG 图标必须使用 `import` 导入
1956 -5. **性能优化**: `shallowRef` + `markRaw` 处理组件对象响应式 2154 +5. **样式策略**: TailwindCSS(80%) + Less(20%) 混合使用
1957 -6. **代码质量**: 强制 JSDoc 注释,统一命名规范 2155 +6. **性能优化**: `shallowRef` + `markRaw` 处理组件对象响应式
1958 -7. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误** 2156 +7. **代码质量**: 强制 JSDoc 注释,统一命名规范
1959 -8. **架构设计**: 分层清晰,职责单一 2157 +8. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误**
1960 -9. **跨页面通信**: ⭐ **使用事件总线模式,LoadMoreList 页面避免使用 `useDidShow`**(防止意外刷新) 2158 +9. **架构设计**: 分层清晰,职责单一
1961 -10. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码** 2159 +10. **跨页面通信**: ⭐ **使用事件总线模式,LoadMoreList 页面避免使用 `useDidShow`**(防止意外刷新)
1962 -11. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致 2160 +11. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码**
2161 +12. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致
1963 2162
1964 ### 📚 推荐阅读 2163 ### 📚 推荐阅读
1965 2164
......
This diff is collapsed. Click to expand it.
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
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/AgePickerGlobal.vue' 85 import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
86 -import PlanFieldAmount from '../PlanFields/AmountInput.vue' 86 +import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
87 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.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/SelectPickerGlobal.vue' 89 import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
......
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
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/AgePickerGlobal.vue' 85 import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
86 -import PlanFieldAmount from '../PlanFields/AmountInput.vue' 86 +import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
87 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.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/SelectPickerGlobal.vue' 89 import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
......
...@@ -208,7 +208,7 @@ ...@@ -208,7 +208,7 @@
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/AgePickerGlobal.vue' 210 import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
211 -import PlanFieldAmount from '../PlanFields/AmountInput.vue' 211 +import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
212 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.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/SelectPickerGlobal.vue' 214 import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
......