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 @@
---
## [2026-02-09] - 新增 AmountKeyboard 数字键盘输入组件
### 新增
- 创建 AmountKeyboard.vue 组件,替代 AmountInput 输入框
- 实现数字键盘(nut-number-keyboard)点击触发
- 新增金额显示弹窗(页面中间显示输入内容,渐变背景设计)
- 集成 GlobalPopupManager 解决嵌套弹窗层级问题
- 实现字符累加逻辑,支持连续录入(123)
- 添加输入验证(1个小数点,2位小数)
- 添加千分位分隔符显示
- 实现 toFixed(2) 确保保存格式一致性
- 防止点击遮罩关闭弹窗
- 添加 watch(showKeyboard) 同步键盘和弹窗状态
### 功能特点
- 点击输入框弹出数字键盘(右列模式,包含小数点)
- 金额显示弹窗(渐变背景 + 装饰圆圈 + 大号字体)
- 输入验证(限制1个小数点,2位小数)
- 连续输入支持(字符累加,字符串类型安全)
- 状态同步(键盘关闭时自动关闭金额弹窗)
- 多币种支持(CNY、USD、HKD、EUR)
### 技术实现
- 使用 nut-number-keyboard 的 @input 事件(单字符传递)
- 字符累加:`String(inputValue.value) + String(val)`
- 输入验证:split('.') 检查小数点和小数位数量
- 初始化:空字符串(非"0.00")避免验证冲突
- 保存:parseFloat(yuan.toFixed(2)) 确保格式
- 单位转换:内部存储为分(整数),显示为元(带2位小数)
### 问题解决
1. Vue渲染错误:使用正确的 kebab-case 组件名 `nut-number-keyboard`
2. 键盘被底部按钮遮挡:使用 GlobalPopupManager 解决嵌套弹窗层级
3. 单字符输入:实现字符累加逻辑
4. 字符串相加变成数字相加(1+2=3):显式使用 String() 转换
5. 输入验证阻塞:初始化为空字符串而非 "0.00"
6. 保存格式问题:使用 toFixed(2) 确保 "12.00" 格式
7. 自动关闭bug:添加时间判断过滤(500ms内)
8. 状态同步:watch(showKeyboard) 同步关闭金额弹窗
### 影响文件
- src/components/PlanFields/AmountKeyboard.vue(新增)
- src/components/PlanTemplates/LifeInsuranceTemplate.vue(使用新组件)
- src/components/PlanTemplates/CriticalIllnessTemplate.vue(使用新组件)
- src/components/PlanTemplates/SavingsTemplate.vue(使用新组件)
- docs/lessons-learned.md(添加经验教训记录)
**详细信息**
- **影响文件**: 见上文
- **技术栈**: Vue 3, Taro 4, NutUI, GlobalPopupManager
- **测试状态**: ✅ 已通过
- **备注**: 替代原有的 AmountInput 组件,老组件保留用于测试
---
## [2026-02-08] - 实现全局弹窗管理器解决嵌套弹窗遮挡问题
### 新增
- 创建 GlobalPopupManager 全局弹窗管理器
- 实现 useParentPopup 和 useGlobalPopup 接口
- 新增 PlanPopupNew 父弹窗组件(支持全局管理)
- 新增 DatePickerGlobal 日期选择器(支持全局管理)
- 新增 SelectPickerGlobal 下拉选择器(支持全局管理)
- 新增 AgePickerGlobal 年龄选择器(支持全局管理)
### 优化
- 子弹窗打开时自动隐藏父弹窗底部按钮
- 所有子弹窗关闭时自动恢复底部按钮
- 支持多个子弹窗同时打开
- 支持多层弹窗嵌套(弹窗套弹窗)
- 使用 watch 监听全局状态,解决组件挂载时序问题
### 问题原因
**NutUI 嵌套弹窗的 z-index 层级问题**
- 父弹窗和子弹窗都是 `position: fixed` 定位
- 即使子弹窗 `z-index` 更高,也无法遮挡父弹窗的非子元素(如底部按钮)
- 底部按钮在 DOM 结构上与子弹窗同级,会覆盖子弹窗内容
### 解决方案
**核心策略**:全局弹窗管理器协调父弹窗和子弹窗状态
1. **子弹窗注册**:每个子弹窗组件挂载时注册,获得唯一 ID
2. **激活/停用**:子弹窗打开时激活,关闭时停用
3. **状态同步**:管理器通知父弹窗隐藏/显示底部按钮
4. **响应式更新**:父弹窗通过 watch 监听全局状态变化
### 迁移工作
- 更新 PlanFormContainer 使用 PlanPopupNew
- 更新所有计划模板(LifeInsuranceTemplate、CriticalIllnessTemplate、SavingsTemplate)使用 Global 版本字段组件
### 影响文件
- 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/LifeInsuranceTemplate.vue
- src/components/PlanTemplates/CriticalIllnessTemplate.vue
- src/components/PlanTemplates/SavingsTemplate.vue
---
## [2026-02-08] - 修复计划书表单重置和数据同步问题
### 修复
......
......@@ -1945,21 +1945,220 @@ const USE_MOCK_DATA = true // 容易导致生产环境误用
---
## 组件开发案例:AmountKeyboard 数字键盘组件 ⭐ 新增
### 问题描述
需要创建一个保额输入组件,点击后弹出数字键盘进行输入,而不是直接使用输入框。
### 遇到的坑和解决方案
#### 坑 1: Vue 渲染错误 - Invalid vnode type
**错误信息**:
```
[Vue warn]: Invalid vnode type when creating vnode: undefined
```
**原因**: 使用了错误的组件名 `nut-numberkeyboard` (驼峰式)
**解决方案**: 使用正确的 kebab-case 命名 `<nut-number-keyboard>`
**官方示例**:
```vue
<nut-number-keyboard v-model:visible="show" type="rightColumn" />
```
---
#### 坑 2: 键盘被底部按钮遮挡
**现象**: 数字键盘的底部按钮被页面底部按钮遮挡
**错误尝试**: 使用 `z-index` CSS 调整(无效)
**正确方案**: 使用 GlobalPopupManager 系统
```javascript
import { useGlobalPopup } from './GlobalPopupManager.js'
const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
// 注册弹窗
const keyboardPopupId = ref(null)
onMounted(() => {
keyboardPopupId.value = registerPopup()
})
// 监听键盘状态
watch(showKeyboard, (newValue) => {
if (newValue) {
activatePopup(keyboardPopupId.value)
} else {
deactivatePopup(keyboardPopupId.value)
}
})
```
---
#### 坑 3: 数字输入只能录入单个数字
**现象**: 输入 "123" 时,只显示最后一个数字
**原因**: `nut-number-keyboard` 的 `@input` 事件传入的是单个字符,需要累加
**错误代码**:
```javascript
const onInput = (val) => {
inputValue.value = val // ❌ 直接替换
}
```
**正确代码**:
```javascript
const onInput = (val) => {
if (!inputValue.value) {
inputValue.value = String(val)
} else {
inputValue.value = String(inputValue.value) + String(val)
}
}
```
---
#### 坑 4: 输入验证导致无法输入
**现象**: 初始化为 "0.00" 后,所有输入都被忽略
**原因**: 验证逻辑判断 `parts[1].length >= 2`,认为已有2位小数
**解决方案**: 初始化时使用空字符串,而不是 "0.00"
```javascript
const openKeyboard = () => {
if (props.modelValue !== null && props.modelValue !== undefined) {
inputValue.value = (props.modelValue / 100).toFixed(2)
} else {
inputValue.value = '' // ✅ 空字符串,不是 '0.00'
}
}
```
**验证逻辑**:
```javascript
const onInput = (val) => {
// 检查小数点
if (val === '.' && inputValue.value.includes('.')) {
return // 已有小数点,忽略
}
// 检查小数位
if (val >= '0' && val <= '9') {
const parts = inputValue.value.split('.')
if (parts.length === 2 && parts[1]?.length >= 2) {
return // 已有2位小数,忽略
}
}
// 累加输入
if (!inputValue.value) {
inputValue.value = String(val)
} else {
inputValue.value = String(inputValue.value) + String(val)
}
}
```
---
#### 坑 5: 键盘自动关闭问题
**现象**: 键盘刚打开就立即关闭
**原因**: `@close` 事件在打开时被误触发
**解决方案**: 添加时间判断,过滤误触发
```javascript
const keyboardOpenTime = ref(0)
watch(showKeyboard, (newValue) => {
if (newValue) {
keyboardOpenTime.value = Date.now() // 记录打开时间
}
})
const onClose = () => {
const timeSinceOpen = Date.now() - keyboardOpenTime.value
if (timeSinceOpen < 500) {
return // 忽略误触发
}
showKeyboard.value = false
}
```
**最终方案**: 移除 `@close` 监听,在 `watch(showKeyboard)` 中同步关闭金额弹窗
---
#### 坑 6: 字符串累加被当作数字相加
**现象**: 输入 1、2、3 后显示 6(1+2+3)
**原因**: JavaScript `+=` 运算符将字符串当作数字相加
**解决方案**: 确保两边都是字符串类型
```javascript
inputValue.value = String(inputValue.value) + String(val)
```
---
#### 坑 7: 保存后再次编辑显示问题
**问题**: 保存 "12.23",删除后保存变成 "12" 而不是 "12.00"
**原因**: `parseFloat("12")` 丢失小数点后的零
**解决方案**: 使用 `toFixed(2)` 保留两位小数
```javascript
const onConfirm = () => {
let yuan = parseFloat(inputValue.value || '0')
if (!Number.isNaN(yuan)) {
const formattedYuan = parseFloat(yuan.toFixed(2))
const cents = Math.round(formattedYuan * 100)
emit('update:modelValue', cents)
}
}
```
---
### ✅ 最终实现要点
1. **组件结构**: 点击区域 + 金额弹窗 + 数字键盘三层结构
2. **事件处理**: `@input` 累加,`@delete` 删除,`@confirm` 保存
3. **输入验证**: 限制小数点(1个)和小数位(2位)
4. **状态管理**: `watch(showKeyboard)` 同步关闭金额弹窗
5. **美观设计**: 渐变背景 + 圆形装饰 + 大号数字显示
---
## 总结
### 🎯 核心经验
1. **"第 3 次出现原则"**: 代码重复 3 次时必须抽取
2. **NutUI 陷阱**: textarea、IconFont 等组件有坑,优先使用原生组件
3. **静态资源**: SVG 图标必须使用 `import` 导入
4. **样式策略**: TailwindCSS(80%) + Less(20%) 混合使用
5. **性能优化**: `shallowRef` + `markRaw` 处理组件对象响应式
6. **代码质量**: 强制 JSDoc 注释,统一命名规范
7. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误**
8. **架构设计**: 分层清晰,职责单一
9. **跨页面通信**: ⭐ **使用事件总线模式,LoadMoreList 页面避免使用 `useDidShow`**(防止意外刷新)
10. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码**
11. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致
3. **⭐ 数字键盘组件**: `@input` 传入单个字符需要累加,注意类型转换
4. **静态资源**: SVG 图标必须使用 `import` 导入
5. **样式策略**: TailwindCSS(80%) + Less(20%) 混合使用
6. **性能优化**: `shallowRef` + `markRaw` 处理组件对象响应式
7. **代码质量**: 强制 JSDoc 注释,统一命名规范
8. **API 调用规范**: ⚠️ **不要使用 `fn()` 包装 API,直接调用并自己处理错误**
9. **架构设计**: 分层清晰,职责单一
10. **跨页面通信**: ⭐ **使用事件总线模式,LoadMoreList 页面避免使用 `useDidShow`**(防止意外刷新)
11. **Mock 数据切换**: ⭐ **使用环境变量 `process.env.NODE_ENV` 自动判断,禁止硬编码**
12. **⚠️ 写代码前必查**: 先搜索项目中是否有类似实现,保持写法一致
### 📚 推荐阅读
......
This diff is collapsed. Click to expand it.
......@@ -83,7 +83,7 @@
import { reactive, watch } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
......
......@@ -83,7 +83,7 @@
import { reactive, watch, toRefs } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
......
......@@ -208,7 +208,7 @@
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
......