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