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
......
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 + <span v-if="currencyText" class="text-gray-500">{{ currencyText }}</span>
8 + </div>
9 +
10 + <!-- 多币种模式(方案 2 - 未来扩展) -->
11 + <div v-if="multiCurrencyEnabled" class="mb-2">
12 + <div class="text-sm text-gray-600 mb-2">币种</div>
13 + <div class="flex gap-2">
14 + <button
15 + v-for="curr in supportedCurrencies"
16 + :key="curr.value"
17 + :class="[
18 + 'px-4 py-2 rounded-lg text-sm border transition-colors',
19 + selectedCurrency === curr.value
20 + ? 'bg-blue-600 text-white border-blue-600'
21 + : 'bg-white text-gray-600 border-gray-200'
22 + ]"
23 + @tap="selectCurrency(curr.value)"
24 + >
25 + {{ curr.label }}
26 + </button>
27 + </div>
28 + </div>
29 +
30 + <!-- 保额显示区域(可点击) -->
31 + <div
32 + class="border border-gray-200 rounded-lg flex items-center overflow-hidden cursor-pointer"
33 + @tap="openKeyboard"
34 + >
35 + <div class="flex-1 p-3 text-sm">
36 + <span v-if="displayValue" class="text-gray-900">{{ displayValue }}</span>
37 + <span v-else class="text-gray-400">{{ placeholder }}</span>
38 + </div>
39 + <span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">{{ currencySymbol }}</span>
40 + </div>
41 +
42 + <!-- 金额显示弹窗 -->
43 + <nut-popup
44 + v-model:visible="showAmountModal"
45 + position="center"
46 + :style="{ padding: '0', borderRadius: '24rpx', overflow: 'hidden' }"
47 + :overlay-style="{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }"
48 + :close-on-click-overlay="false"
49 + >
50 + <view class="amount-modal">
51 + <!-- 背景装饰 -->
52 + <view class="amount-modal-bg">
53 + <view class="amount-modal-circle amount-modal-circle-1"></view>
54 + <view class="amount-modal-circle amount-modal-circle-2"></view>
55 + <view class="amount-modal-circle amount-modal-circle-3"></view>
56 + </view>
57 +
58 + <!-- 内容区域 -->
59 + <view class="amount-modal-content">
60 + <!-- 币种符号 -->
61 + <view class="amount-modal-currency">{{ currencySymbol }}</view>
62 +
63 + <!-- 金额数值 -->
64 + <view class="amount-modal-value">
65 + {{ formattedInputValue || '0.00' }}
66 + </view>
67 +
68 + <!-- 提示文字 -->
69 + <view class="amount-modal-hint">请输入金额</view>
70 + </view>
71 + </view>
72 + </nut-popup>
73 +
74 + <!-- 数字键盘 -->
75 + <nut-number-keyboard
76 + v-model:visible="showKeyboard"
77 + type="rightColumn"
78 + overlay
79 + :custom-key="customKey"
80 + confirm-text="完成"
81 + @input="onInput"
82 + @delete="onDelete"
83 + @confirm="onConfirm"
84 + @close="onClose"
85 + />
86 + </div>
87 +</template>
88 +
89 +<script setup>
90 +/**
91 + * 保额键盘输入组件
92 + *
93 + * @description 点击后弹出数字键盘进行输入的保额组件
94 + * - 单位转换:内部存储为分(整数),显示为元(带2位小数)
95 + * - 币种支持:CNY、USD、HKD、EUR
96 + * - 多币种模式:通过 FEATURE_FLAGS.MULTI_CURRENCY_ENABLED 控制
97 + * - 支持弹窗嵌套:通过 GlobalPopupManager 注册为全局弹窗
98 + * @author Claude Code
99 + * @example
100 + * <!-- 固定币种模式 -->
101 + * <AmountKeyboard
102 + * v-model="coverage"
103 + * label="保额"
104 + * currency="USD"
105 + * placeholder="请输入保额"
106 + * />
107 + *
108 + * @example
109 + * <!-- 多币种模式 -->
110 + * <AmountKeyboard
111 + * v-model="coverage"
112 + * label="保额"
113 + * :config="{ supported_currencies: ['CNY', 'USD'], default_currency: 'CNY' }"
114 + * placeholder="请输入保额"
115 + * />
116 + */
117 +import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
118 +import Taro from '@tarojs/taro'
119 +import { FEATURE_FLAGS, CURRENCY_SYMBOLS, CURRENCY_MAP } from '@/config/plan-templates'
120 +import { useGlobalPopup } from './GlobalPopupManager.js'
121 +
122 +/**
123 + * 组件属性
124 + */
125 +const props = defineProps({
126 + /**
127 + * 标签文本
128 + * @type {string}
129 + */
130 + label: {
131 + type: String,
132 + default: ''
133 + },
134 +
135 + /**
136 + * 是否必填
137 + * @type {boolean}
138 + */
139 + required: {
140 + type: Boolean,
141 + default: false
142 + },
143 +
144 + /**
145 + * 占位符文本
146 + * @type {string}
147 + */
148 + placeholder: {
149 + type: String,
150 + default: '请输入保额'
151 + },
152 +
153 + /**
154 + * 绑定的值(单位:分)
155 + * @type {number}
156 + * @example 100000 表示 1000.00 元
157 + */
158 + modelValue: {
159 + type: Number,
160 + default: null
161 + },
162 +
163 + /**
164 + * 币种代码(固定币种模式)
165 + * @type {string}
166 + * @default 'CNY'
167 + */
168 + currency: {
169 + type: String,
170 + default: 'CNY'
171 + },
172 +
173 + /**
174 + * 模版配置(多币种模式)
175 + * @type {Object}
176 + * @property {Array<string>} supported_currencies - 支持的币种代码数组
177 + * @property {string} default_currency - 默认币种代码
178 + * @example { supported_currencies: ['CNY', 'USD'], default_currency: 'CNY' }
179 + */
180 + config: {
181 + type: Object,
182 + default: () => ({})
183 + }
184 +})
185 +
186 +/**
187 + * 组件事件
188 + */
189 +const emit = defineEmits([
190 + /**
191 + * 更新值事件
192 + * @event update:modelValue
193 + * @param {number} value - 保额值(单位:分)
194 + */
195 + 'update:modelValue'
196 +])
197 +
198 +/**
199 + * 判断是否启用多币种
200 + * @type {ComputedRef<boolean>}
201 + */
202 +const multiCurrencyEnabled = computed(() => FEATURE_FLAGS.MULTI_CURRENCY_ENABLED)
203 +
204 +/**
205 + * 当前选中的币种
206 + * @type {Ref<string>}
207 + */
208 +const selectedCurrency = ref(props.config.default_currency || props.currency || 'CNY')
209 +
210 +/**
211 + * 支持的币种列表(多币种模式)
212 + * @type {ComputedRef<Array<{label: string, symbol: string, value: string}>>}
213 + */
214 +const supportedCurrencies = computed(() => {
215 + if (!multiCurrencyEnabled.value) return []
216 +
217 + return (props.config.supported_currencies || ['CNY'])
218 + .map(code => CURRENCY_MAP[code])
219 + .filter(Boolean)
220 +})
221 +
222 +/**
223 + * 当前币种符号
224 + * @type {ComputedRef<string>}
225 + * @example
226 + * // CNY -> '¥'
227 + * // USD -> '$'
228 + */
229 +const currencySymbol = computed(() => {
230 + if (multiCurrencyEnabled.value) {
231 + // 多币种模式:使用用户选择的币种
232 + const curr = supportedCurrencies.value.find(c => c.value === selectedCurrency.value)
233 + return curr?.symbol || '¥'
234 + }
235 +
236 + // 固定币种模式:使用 props.currency
237 + return CURRENCY_SYMBOLS[props.currency] || '¥'
238 +})
239 +
240 +/**
241 + * 币种文本(用于标签显示)
242 + * @type {ComputedRef<string>}
243 + */
244 +const currencyText = computed(() => {
245 + if (multiCurrencyEnabled.value) {
246 + const curr = supportedCurrencies.value.find(c => c.value === selectedCurrency.value)
247 + return curr?.label || ''
248 + }
249 +
250 + const CURRENCY_NAMES = {
251 + CNY: '人民币',
252 + USD: '美元',
253 + HKD: '港币',
254 + EUR: '欧元'
255 + }
256 +
257 + return CURRENCY_NAMES[props.currency] || ''
258 +})
259 +
260 +/**
261 + * 键盘显示状态
262 + * @type {Ref<boolean>}
263 + */
264 +const showKeyboard = ref(false)
265 +
266 +/**
267 + * 键盘打开时间戳(用于判断误触发关闭)
268 + * @type {Ref<number>}
269 + */
270 +const keyboardOpenTime = ref(0)
271 +
272 +/**
273 + * 键盘输入值(临时存储,字符串格式)
274 + * @type {Ref<string>}
275 + */
276 +const inputValue = ref('')
277 +
278 +/**
279 + * 金额弹窗显示状态
280 + * @type {Ref<boolean>}
281 + */
282 +const showAmountModal = ref(false)
283 +
284 +/**
285 + * 显示值(元,带2位小数)
286 + * @type {ComputedRef<string>}
287 + */
288 +const displayValue = computed(() => {
289 + // 优先显示输入过程中的值
290 + if (inputValue.value) {
291 + return inputValue.value
292 + }
293 + // 如果没有输入值,显示表单的原始值
294 + if (props.modelValue !== null && props.modelValue !== undefined) {
295 + return (props.modelValue / 100).toFixed(2)
296 + }
297 + return ''
298 +})
299 +
300 +/**
301 + * 格式化输入值(带千分位分隔符)
302 + * @type {ComputedRef<string>}
303 + */
304 +const formattedInputValue = computed(() => {
305 + if (!inputValue.value) return '0.00'
306 +
307 + // 解析数值
308 + const num = parseFloat(inputValue.value)
309 + if (Number.isNaN(num)) return '0.00'
310 +
311 + // 格式化为千分位
312 + const parts = num.toFixed(2).split('.')
313 + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
314 + return parts.join('.')
315 +})
316 +
317 +/**
318 + * 自定义键盘按键
319 + * @type {Ref<Array<string>>}
320 + */
321 +const customKey = ref(['.'])
322 +
323 +/**
324 + * 使用全局弹窗管理器
325 + */
326 +const { registerPopup, activatePopup, deactivatePopup } = useGlobalPopup()
327 +
328 +/**
329 + * 数字键盘弹窗 ID
330 + * @type {Ref<string|null>}
331 + */
332 +const keyboardPopupId = ref(null)
333 +
334 +/**
335 + * 组件挂载时注册弹窗
336 + */
337 +onMounted(() => {
338 + keyboardPopupId.value = registerPopup()
339 +})
340 +
341 +/**
342 + * 组件卸载时取消注册
343 + */
344 +onUnmounted(() => {
345 + if (keyboardPopupId.value) {
346 + deactivatePopup(keyboardPopupId.value)
347 + }
348 +})
349 +
350 +/**
351 + * 监听键盘显示状态
352 + */
353 +watch(showKeyboard, (newValue) => {
354 + if (!keyboardPopupId.value) {
355 + return
356 + }
357 +
358 + if (newValue) {
359 + // 键盘打开:激活全局弹窗,通知父弹窗隐藏 footer
360 + activatePopup(keyboardPopupId.value)
361 + // 记录打开时间
362 + keyboardOpenTime.value = Date.now()
363 + } else {
364 + // 键盘关闭:停用全局弹窗,通知父弹窗恢复 footer
365 + deactivatePopup(keyboardPopupId.value)
366 + // 同步关闭金额弹窗
367 + showAmountModal.value = false
368 + }
369 +})
370 +
371 +/**
372 + * 打开键盘
373 + */
374 +const openKeyboard = () => {
375 + // 初始化键盘值为当前值(元),如果没有值则为空
376 + if (props.modelValue !== null && props.modelValue !== undefined) {
377 + inputValue.value = (props.modelValue / 100).toFixed(2)
378 + } else {
379 + inputValue.value = ''
380 + }
381 +
382 + showKeyboard.value = true
383 + showAmountModal.value = true
384 +}
385 +
386 +/**
387 + * 键盘输入处理
388 + * @param {string} val - 输入值(单个字符)
389 + */
390 +const onInput = (val) => {
391 + // 如果输入的是小数点,检查是否已经有小数点
392 + if (val === '.' && inputValue.value.includes('.')) {
393 + return
394 + }
395 +
396 + // 如果输入的是数字,检查小数点后的位数
397 + if (val >= '0' && val <= '9') {
398 + const parts = inputValue.value.split('.')
399 + // 如果已经有小数点,并且小数点后有2位,则忽略输入
400 + if (parts.length === 2 && parts[1]?.length >= 2) {
401 + return
402 + }
403 + }
404 +
405 + // nut-number-keyboard 的 @input 事件传入的是单个字符,需要累加
406 + if (!inputValue.value) {
407 + // 初始状态,确保是字符串
408 + inputValue.value = String(val)
409 + } else {
410 + // 累加输入,确保两边都是字符串进行连接
411 + inputValue.value = String(inputValue.value) + String(val)
412 + }
413 +}
414 +
415 +/**
416 + * 删除键处理
417 + */
418 +const onDelete = () => {
419 + if (!inputValue.value) return
420 +
421 + // 删除最后一个字符
422 + inputValue.value = inputValue.value.slice(0, -1)
423 +}
424 +
425 +/**
426 + * 完成按钮点击处理
427 + */
428 +const onConfirm = () => {
429 + // 将元转换为分,保留两位小数
430 + let yuan = parseFloat(inputValue.value || '0')
431 + if (!Number.isNaN(yuan)) {
432 + // 先保留两位小数,再转换为分
433 + const formattedYuan = parseFloat(yuan.toFixed(2))
434 + const cents = Math.round(formattedYuan * 100)
435 +
436 + emit('update:modelValue', cents)
437 + }
438 +
439 + // 关闭键盘和金额弹窗
440 + showKeyboard.value = false
441 + showAmountModal.value = false
442 +}
443 +
444 +/**
445 + * 关闭键盘认处
446 + */
447 +const onClose = () => {
448 + // 只关闭金额弹窗,让 showKeyboard 自然变化触发 watch 处理 GlobalPopupManager
449 + showAmountModal.value = false
450 +}
451 +
452 +/**
453 + * 选择币种(多币种模式)
454 + * @param {string} value - 币种代码
455 + */
456 +const selectCurrency = (value) => {
457 + selectedCurrency.value = value
458 +}
459 +</script>
460 +
461 +<style lang="less">
462 +/**
463 + * 组件样式
464 + */
465 +.cursor-pointer {
466 + cursor: pointer;
467 +}
468 +
469 +.amount-modal {
470 + position: relative;
471 + display: flex;
472 + flex-direction: column;
473 + align-items: center;
474 + justify-content: center;
475 + min-width: 500rpx;
476 + min-height: 360rpx;
477 + padding: 60rpx 40rpx;
478 + overflow: hidden;
479 +
480 + &-bg {
481 + position: absolute;
482 + top: 0;
483 + left: 0;
484 + right: 0;
485 + bottom: 0;
486 + z-index: 0;
487 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
488 + opacity: 0.1;
489 + }
490 +
491 + &-circle {
492 + position: absolute;
493 + border-radius: 50%;
494 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
495 + opacity: 0.15;
496 + }
497 +
498 + &-circle-1 {
499 + width: 400rpx;
500 + height: 400rpx;
501 + top: -200rpx;
502 + right: -100rpx;
503 + }
504 +
505 + &-circle-2 {
506 + width: 300rpx;
507 + height: 300rpx;
508 + bottom: -100rpx;
509 + left: -80rpx;
510 + }
511 +
512 + &-circle-3 {
513 + width: 200rpx;
514 + height: 200rpx;
515 + top: 50%;
516 + right: 20%;
517 + }
518 +
519 + &-content {
520 + position: relative;
521 + z-index: 1;
522 + display: flex;
523 + flex-direction: column;
524 + align-items: center;
525 + }
526 +
527 + &-currency {
528 + font-size: 48rpx;
529 + font-weight: bold;
530 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
531 + --webkit-background-clip: text;
532 + --webkit-text-fill-color: transparent;
533 + background-clip: text;
534 + margin-bottom: 20rpx;
535 + }
536 +
537 + &-value {
538 + font-size: 88rpx;
539 + font-weight: bold;
540 + color: #333;
541 + line-height: 1.2;
542 + margin-bottom: 16rpx;
543 + }
544 +
545 + &-hint {
546 + font-size: 28rpx;
547 + color: #999;
548 + }
549 +}
550 +</style>
...@@ -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'
......