feat: 完成任务3 - 开发核心交易管理界面
任务3.1 实现基础 UI 组件库 - 创建 BaseIcon 组件,支持 emoji 和 SVG 图标 - 实现 CategoryIcon 组件,用于分类图标显示 - 开发 AmountInput 组件,支持金额输入和类型切换 - 创建 DatePicker 组件,支持日期选择和快捷日期 - 实现 BaseSelect 组件,支持搜索和自定义选项 - 添加 LoadingSpinner 和 EmptyState 组件 - 扩展 Tailwind 配置,完善设计系统色彩 任务3.2 创建交易列表和卡片组件 - 实现 TransactionCard 组件,展示交易详情 - 创建 TransactionList 组件,支持分组显示和批量操作 - 添加日期分组和汇总功能 - 实现无限滚动和加载更多功能 - 支持选择模式和批量删除 - 更新 TransactionsView 使用新组件 任务3.3 开发交易添加和编辑表单 - 创建 TransactionForm 组件,支持添加和编辑交易 - 集成所有输入组件(金额、分类、账户、日期等) - 实现表单验证和数据清理 - 添加重复交易检测功能 - 创建 AddTransactionView 页面 - 配置路由支持添加和编辑交易 任务3.4 实现搜索和筛选功能 - 创建 TransactionSearch 组件,支持智能搜索 - 实现搜索建议和搜索历史功能 - 开发 TransactionFilter 组件,支持多维度筛选 - 添加快速筛选标签和日期预设 - 集成搜索和筛选到 TransactionsView - 实现实时筛选和结果统计 核心特性: - 完整的交易管理界面(列表、添加、编辑、搜索、筛选) - 现代化的 neumorphic 设计风格 - 响应式布局和移动端优化 - 智能搜索和多维度筛选 - 用户友好的交互体验 - 完整的表单验证和错误处理
This commit is contained in:
226
frontend/src/components/common/AmountInput.vue
Normal file
226
frontend/src/components/common/AmountInput.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<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>
|
Reference in New Issue
Block a user