226 lines
5.6 KiB
Vue
226 lines
5.6 KiB
Vue
|
<template>
|
|||
|
<div class="space-y-2">
|
|||
|
<label v-if="label" :for="inputId" class="block text-sm font-medium text-text-primary">
|
|||
|
{{ label }}
|
|||
|
</label>
|
|||
|
|
|||
|
<div class="relative">
|
|||
|
<!-- 货币符号 -->
|
|||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|||
|
<span class="text-text-secondary text-lg">{{ currency }}</span>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 金额输入框 -->
|
|||
|
<input
|
|||
|
:id="inputId"
|
|||
|
ref="inputRef"
|
|||
|
type="text"
|
|||
|
inputmode="decimal"
|
|||
|
:placeholder="placeholder"
|
|||
|
:value="displayValue"
|
|||
|
:disabled="disabled"
|
|||
|
:class="[
|
|||
|
'input-neumorphic pl-8 pr-4 text-right text-lg font-semibold',
|
|||
|
{
|
|||
|
'text-accent-red': type === 'expense' && numericValue > 0,
|
|||
|
'text-accent-green': type === 'income' && numericValue > 0,
|
|||
|
'text-text-primary': numericValue === 0
|
|||
|
}
|
|||
|
]"
|
|||
|
@input="handleInput"
|
|||
|
@focus="handleFocus"
|
|||
|
@blur="handleBlur"
|
|||
|
@keydown="handleKeydown"
|
|||
|
/>
|
|||
|
|
|||
|
<!-- 类型切换按钮 -->
|
|||
|
<div v-if="showTypeToggle" class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
|||
|
<button
|
|||
|
type="button"
|
|||
|
:class="[
|
|||
|
'text-xs px-2 py-1 rounded transition-colors',
|
|||
|
type === 'expense'
|
|||
|
? 'bg-accent-red text-white'
|
|||
|
: 'bg-accent-green text-white'
|
|||
|
]"
|
|||
|
@click="toggleType"
|
|||
|
>
|
|||
|
{{ type === 'expense' ? '支出' : '收入' }}
|
|||
|
</button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 快捷金额按钮 -->
|
|||
|
<div v-if="showQuickAmounts && !disabled" class="flex flex-wrap gap-2 mt-3">
|
|||
|
<button
|
|||
|
v-for="amount in quickAmounts"
|
|||
|
:key="amount"
|
|||
|
type="button"
|
|||
|
class="px-3 py-1 text-sm bg-surface border border-border rounded-lg hover:bg-primary hover:text-white transition-colors"
|
|||
|
@click="setQuickAmount(amount)"
|
|||
|
>
|
|||
|
{{ currency }}{{ amount }}
|
|||
|
</button>
|
|||
|
</div>
|
|||
|
|
|||
|
<!-- 错误提示 -->
|
|||
|
<div v-if="error" class="text-sm text-accent-red">
|
|||
|
{{ error }}
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</template>
|
|||
|
|
|||
|
<script setup lang="ts">
|
|||
|
import { ref, computed, nextTick } from 'vue'
|
|||
|
|
|||
|
interface Props {
|
|||
|
modelValue: number
|
|||
|
type?: 'income' | 'expense'
|
|||
|
label?: string
|
|||
|
placeholder?: string
|
|||
|
currency?: string
|
|||
|
disabled?: boolean
|
|||
|
showTypeToggle?: boolean
|
|||
|
showQuickAmounts?: boolean
|
|||
|
quickAmounts?: number[]
|
|||
|
max?: number
|
|||
|
min?: number
|
|||
|
error?: string
|
|||
|
}
|
|||
|
|
|||
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
type: 'expense',
|
|||
|
currency: '¥',
|
|||
|
disabled: false,
|
|||
|
showTypeToggle: false,
|
|||
|
showQuickAmounts: false,
|
|||
|
quickAmounts: () => [10, 20, 50, 100, 200, 500],
|
|||
|
max: 999999999,
|
|||
|
min: 0
|
|||
|
})
|
|||
|
|
|||
|
const emit = defineEmits<{
|
|||
|
'update:modelValue': [value: number]
|
|||
|
'update:type': [type: 'income' | 'expense']
|
|||
|
}>()
|
|||
|
|
|||
|
const inputRef = ref<HTMLInputElement>()
|
|||
|
const isFocused = ref(false)
|
|||
|
|
|||
|
// 生成唯一ID
|
|||
|
const inputId = computed(() => `amount-input-${Math.random().toString(36).substr(2, 9)}`)
|
|||
|
|
|||
|
// 数值
|
|||
|
const numericValue = computed(() => props.modelValue || 0)
|
|||
|
|
|||
|
// 显示值
|
|||
|
const displayValue = computed(() => {
|
|||
|
if (isFocused.value) {
|
|||
|
// 聚焦时显示原始数字
|
|||
|
return numericValue.value > 0 ? numericValue.value.toString() : ''
|
|||
|
} else {
|
|||
|
// 失焦时显示格式化的数字
|
|||
|
return numericValue.value > 0 ? formatAmount(numericValue.value) : ''
|
|||
|
}
|
|||
|
})
|
|||
|
|
|||
|
// 格式化金额显示
|
|||
|
function formatAmount(amount: number): string {
|
|||
|
return amount.toLocaleString('zh-CN', {
|
|||
|
minimumFractionDigits: 0,
|
|||
|
maximumFractionDigits: 2
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
// 解析输入值
|
|||
|
function parseAmount(value: string): number {
|
|||
|
// 移除所有非数字和小数点的字符
|
|||
|
const cleaned = value.replace(/[^\d.]/g, '')
|
|||
|
|
|||
|
// 确保只有一个小数点
|
|||
|
const parts = cleaned.split('.')
|
|||
|
if (parts.length > 2) {
|
|||
|
return parseFloat(parts[0] + '.' + parts.slice(1).join(''))
|
|||
|
}
|
|||
|
|
|||
|
const parsed = parseFloat(cleaned)
|
|||
|
return isNaN(parsed) ? 0 : parsed
|
|||
|
}
|
|||
|
|
|||
|
// 处理输入
|
|||
|
function handleInput(event: Event) {
|
|||
|
const target = event.target as HTMLInputElement
|
|||
|
const value = target.value
|
|||
|
|
|||
|
// 解析并验证输入值
|
|||
|
let amount = parseAmount(value)
|
|||
|
|
|||
|
// 应用最小值和最大值限制
|
|||
|
if (amount < props.min) amount = props.min
|
|||
|
if (amount > props.max) amount = props.max
|
|||
|
|
|||
|
emit('update:modelValue', amount)
|
|||
|
}
|
|||
|
|
|||
|
// 处理聚焦
|
|||
|
function handleFocus() {
|
|||
|
isFocused.value = true
|
|||
|
}
|
|||
|
|
|||
|
// 处理失焦
|
|||
|
function handleBlur() {
|
|||
|
isFocused.value = false
|
|||
|
}
|
|||
|
|
|||
|
// 处理键盘事件
|
|||
|
function handleKeydown(event: KeyboardEvent) {
|
|||
|
// 允许的键:数字、小数点、退格、删除、方向键、Tab
|
|||
|
const allowedKeys = [
|
|||
|
'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
|
|||
|
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'
|
|||
|
]
|
|||
|
|
|||
|
const isNumber = /^[0-9]$/.test(event.key)
|
|||
|
const isDecimal = event.key === '.' && !displayValue.value.includes('.')
|
|||
|
|
|||
|
if (!allowedKeys.includes(event.key) && !isNumber && !isDecimal) {
|
|||
|
event.preventDefault()
|
|||
|
}
|
|||
|
|
|||
|
// Enter 键失焦
|
|||
|
if (event.key === 'Enter') {
|
|||
|
inputRef.value?.blur()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 切换类型
|
|||
|
function toggleType() {
|
|||
|
const newType = props.type === 'expense' ? 'income' : 'expense'
|
|||
|
emit('update:type', newType)
|
|||
|
}
|
|||
|
|
|||
|
// 设置快捷金额
|
|||
|
function setQuickAmount(amount: number) {
|
|||
|
emit('update:modelValue', amount)
|
|||
|
|
|||
|
// 聚焦输入框
|
|||
|
nextTick(() => {
|
|||
|
inputRef.value?.focus()
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
// 聚焦方法(供父组件调用)
|
|||
|
function focus() {
|
|||
|
inputRef.value?.focus()
|
|||
|
}
|
|||
|
|
|||
|
// 选中所有文本
|
|||
|
function selectAll() {
|
|||
|
inputRef.value?.select()
|
|||
|
}
|
|||
|
|
|||
|
defineExpose({
|
|||
|
focus,
|
|||
|
selectAll
|
|||
|
})
|
|||
|
</script>
|