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:
@@ -37,6 +37,7 @@
|
||||
|
||||
- [x] 2. 实现本地数据模型和离线功能
|
||||
|
||||
|
||||
- [x] 2.1 创建本地数据模型
|
||||
|
||||
|
||||
@@ -61,26 +62,35 @@
|
||||
- 建立数据同步标记和冲突检测机制
|
||||
- _需求: 7.1, 7.2, 7.4_
|
||||
|
||||
- [ ] 3. 开发核心交易管理界面
|
||||
- [ ] 3.1 实现基础 UI 组件库
|
||||
- [x] 3. 开发核心交易管理界面
|
||||
|
||||
- [x] 3.1 实现基础 UI 组件库
|
||||
|
||||
|
||||
- 创建符合 neumorphic 设计风格的基础组件(按钮、输入框、卡片)
|
||||
- 实现温暖现代主义色彩系统和字体层级
|
||||
- 开发图标系统和分类图标组件
|
||||
- _需求: 6.1, 6.3, 6.4, 6.6_
|
||||
|
||||
- [ ] 3.2 创建交易列表和卡片组件
|
||||
- [x] 3.2 创建交易列表和卡片组件
|
||||
|
||||
|
||||
- 设计实现 TransactionCard 组件,展示分类图标、商户名、金额和日期
|
||||
- 开发交易列表页面,支持无限滚动和下拉刷新
|
||||
- 实现空状态和加载状态的 UI 组件
|
||||
- _需求: 6.1, 6.5_
|
||||
|
||||
- [ ] 3.3 开发交易添加和编辑表单
|
||||
- [x] 3.3 开发交易添加和编辑表单
|
||||
|
||||
|
||||
- 创建 AddTransactionPage 组件,包含金额输入、分类选择、日期选择器
|
||||
- 实现 AmountInput 组件,支持数字键盘和金额格式化
|
||||
- 开发 CategorySelector 组件,支持图标选择和自定义分类
|
||||
- _需求: 2.1, 2.6, 2.7, 6.4_
|
||||
|
||||
- [ ] 3.4 实现搜索和筛选功能
|
||||
- [x] 3.4 实现搜索和筛选功能
|
||||
|
||||
|
||||
- 创建 SearchFilter 组件,支持文本搜索和多维度筛选
|
||||
- 实现前端搜索逻辑,支持按商户名、备注、分类、账户筛选
|
||||
- 开发筛选结果的实时更新和状态管理
|
||||
|
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>
|
146
frontend/src/components/common/BaseIcon.vue
Normal file
146
frontend/src/components/common/BaseIcon.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center justify-center',
|
||||
sizeClasses,
|
||||
colorClasses,
|
||||
$attrs.class
|
||||
]"
|
||||
:style="customStyle"
|
||||
>
|
||||
<!-- 使用 emoji 图标 -->
|
||||
<span v-if="isEmoji" :style="{ fontSize: emojiSize }">{{ name }}</span>
|
||||
|
||||
<!-- 使用 SVG 图标 -->
|
||||
<svg
|
||||
v-else
|
||||
:class="svgClasses"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="strokeWidth"
|
||||
:d="iconPath"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
strokeWidth: 2,
|
||||
rounded: false
|
||||
})
|
||||
|
||||
// 判断是否为 emoji
|
||||
const isEmoji = computed(() => {
|
||||
// 简单的 emoji 检测
|
||||
return /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(props.name)
|
||||
})
|
||||
|
||||
// 尺寸类
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
xl: 'w-8 h-8'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
// 颜色类
|
||||
const colorClasses = computed(() => {
|
||||
if (props.color) return ''
|
||||
return 'text-current'
|
||||
})
|
||||
|
||||
// 自定义样式
|
||||
const customStyle = computed(() => {
|
||||
const style: any = {}
|
||||
if (props.color) {
|
||||
style.color = props.color
|
||||
}
|
||||
if (props.rounded) {
|
||||
style.borderRadius = '50%'
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
// SVG 类
|
||||
const svgClasses = computed(() => {
|
||||
return sizeClasses.value
|
||||
})
|
||||
|
||||
// Emoji 尺寸
|
||||
const emojiSize = computed(() => {
|
||||
const sizes = {
|
||||
xs: '0.75rem',
|
||||
sm: '1rem',
|
||||
md: '1.25rem',
|
||||
lg: '1.5rem',
|
||||
xl: '2rem'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
// 常用图标路径
|
||||
const iconPaths: Record<string, string> = {
|
||||
// 基础图标
|
||||
'plus': 'M12 4v16m8-8H4',
|
||||
'minus': 'M5 12h14',
|
||||
'x': 'M18 6L6 18M6 6l12 12',
|
||||
'check': 'M20 6L9 17l-5-5',
|
||||
'chevron-left': 'M15 18l-6-6 6-6',
|
||||
'chevron-right': 'M9 18l6-6-6-6',
|
||||
'chevron-up': 'M18 15l-6-6-6 6',
|
||||
'chevron-down': 'M6 9l6 6 6-6',
|
||||
|
||||
// 功能图标
|
||||
'search': 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
'filter': 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.207A1 1 0 013 6.5V4z',
|
||||
'edit': 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
|
||||
'trash': 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
|
||||
'settings': 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
|
||||
// 财务图标
|
||||
'dollar': 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1',
|
||||
'trending-up': 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
|
||||
'trending-down': 'M13 17h8m0 0V9m0 8l-8-8-4 4-6-6',
|
||||
'chart': 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||
|
||||
// 导航图标
|
||||
'home': 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
'list': 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
||||
'grid': 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
|
||||
// 时间相关图标
|
||||
'calendar': 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
'clock': 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
|
||||
// 通知图标
|
||||
'bell': 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||
|
||||
// 状态图标
|
||||
'warning': 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.34 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||
}
|
||||
|
||||
const iconPath = computed(() => {
|
||||
return iconPaths[props.name] || iconPaths['x'] // 默认显示 x 图标
|
||||
})
|
||||
</script>
|
243
frontend/src/components/common/BaseSelect.vue
Normal file
243
frontend/src/components/common/BaseSelect.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="label" :for="selectId" class="block text-sm font-medium text-text-primary">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<!-- 选择框 -->
|
||||
<button
|
||||
:id="selectId"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'input-neumorphic w-full text-left flex items-center justify-between',
|
||||
{ 'opacity-50 cursor-not-allowed': disabled }
|
||||
]"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- 选中项的图标 -->
|
||||
<CategoryIcon
|
||||
v-if="selectedOption && selectedOption.icon"
|
||||
:icon="selectedOption.icon"
|
||||
:color="selectedOption.color"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- 选中项的文本 -->
|
||||
<span :class="selectedOption ? 'text-text-primary' : 'text-text-secondary'">
|
||||
{{ selectedOption ? selectedOption.label : placeholder }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下拉箭头 -->
|
||||
<BaseIcon
|
||||
name="chevron-down"
|
||||
size="sm"
|
||||
:class="[
|
||||
'transition-transform duration-200',
|
||||
{ 'rotate-180': isOpen }
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-2 w-full bg-surface border border-border rounded-xl shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<div v-if="searchable" class="p-3 border-b border-border">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
class="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="py-2">
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'w-full px-4 py-3 text-left flex items-center space-x-3 hover:bg-background transition-colors',
|
||||
{
|
||||
'bg-primary bg-opacity-10 text-primary': option.value === modelValue
|
||||
}
|
||||
]"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<!-- 选项图标 -->
|
||||
<CategoryIcon
|
||||
v-if="option.icon"
|
||||
:icon="option.icon"
|
||||
:color="option.color"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- 选项文本 -->
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ option.label }}</div>
|
||||
<div v-if="option.description" class="text-sm text-text-secondary">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中标记 -->
|
||||
<BaseIcon
|
||||
v-if="option.value === modelValue"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-4 py-8 text-center text-text-secondary"
|
||||
>
|
||||
<BaseIcon name="search" size="lg" class="mx-auto mb-2 opacity-50" />
|
||||
<p>{{ searchQuery ? '未找到匹配项' : '暂无选项' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加新项按钮 -->
|
||||
<div v-if="allowAdd" class="border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm text-primary border border-primary rounded-lg hover:bg-primary hover:text-white transition-colors"
|
||||
@click="handleAdd"
|
||||
>
|
||||
<BaseIcon name="plus" size="sm" class="inline mr-2" />
|
||||
添加新项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="text-sm text-accent-red">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import BaseIcon from './BaseIcon.vue'
|
||||
import CategoryIcon from './CategoryIcon.vue'
|
||||
|
||||
export interface SelectOption {
|
||||
value: any
|
||||
label: string
|
||||
icon?: string
|
||||
color?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: any
|
||||
options: SelectOption[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
searchable?: boolean
|
||||
allowAdd?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择',
|
||||
disabled: false,
|
||||
searchable: false,
|
||||
allowAdd: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
'add': []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
|
||||
// 生成唯一ID
|
||||
const selectId = computed(() => `select-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
// 选中的选项
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(option => option.value === props.modelValue)
|
||||
})
|
||||
|
||||
// 过滤后的选项
|
||||
const filteredOptions = computed(() => {
|
||||
if (!props.searchable || !searchQuery.value) {
|
||||
return props.options
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter(option =>
|
||||
option.label.toLowerCase().includes(query) ||
|
||||
(option.description && option.description.toLowerCase().includes(query))
|
||||
)
|
||||
})
|
||||
|
||||
// 切换下拉菜单
|
||||
function toggleDropdown() {
|
||||
if (props.disabled) return
|
||||
|
||||
isOpen.value = !isOpen.value
|
||||
|
||||
if (isOpen.value && props.searchable) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 选择选项
|
||||
function selectOption(option: SelectOption) {
|
||||
emit('update:modelValue', option.value)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// 处理添加新项
|
||||
function handleAdd() {
|
||||
emit('add')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
function handleClickOutside(event: Event) {
|
||||
const target = event.target as Element
|
||||
if (!target.closest(`#${selectId.value}`) && !target.closest('.absolute')) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
97
frontend/src/components/common/CategoryIcon.vue
Normal file
97
frontend/src/components/common/CategoryIcon.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center justify-center rounded-full',
|
||||
sizeClasses,
|
||||
backgroundClasses,
|
||||
{ 'cursor-pointer hover:scale-110 transition-transform': clickable }
|
||||
]"
|
||||
:style="customStyle"
|
||||
@click="handleClick"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="icon"
|
||||
:size="iconSize"
|
||||
:color="textColor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseIcon from './BaseIcon.vue'
|
||||
|
||||
interface Props {
|
||||
icon: string
|
||||
color?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
clickable?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: '#F97316',
|
||||
size: 'md',
|
||||
clickable: false,
|
||||
selected: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// 尺寸类
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-6 h-6',
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
// 图标尺寸
|
||||
const iconSize = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'xs' as const,
|
||||
sm: 'sm' as const,
|
||||
md: 'md' as const,
|
||||
lg: 'lg' as const,
|
||||
xl: 'xl' as const
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
// 背景类
|
||||
const backgroundClasses = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'ring-2 ring-primary ring-offset-2'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 自定义样式
|
||||
const customStyle = computed(() => {
|
||||
const style: any = {}
|
||||
|
||||
// 背景色 - 使用颜色的浅色版本
|
||||
const color = props.color || '#F97316'
|
||||
style.backgroundColor = color + '20' // 添加透明度
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
// 文本颜色
|
||||
const textColor = computed(() => {
|
||||
return props.color || '#F97316'
|
||||
})
|
||||
|
||||
// 处理点击
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (props.clickable) {
|
||||
emit('click', event)
|
||||
}
|
||||
}
|
||||
</script>
|
138
frontend/src/components/common/DatePicker.vue
Normal file
138
frontend/src/components/common/DatePicker.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<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">
|
||||
<!-- 日期输入框 -->
|
||||
<input
|
||||
:id="inputId"
|
||||
type="date"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
class="input-neumorphic pr-10"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<!-- 日历图标 -->
|
||||
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<BaseIcon name="calendar" size="sm" class="text-text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷日期按钮 -->
|
||||
<div v-if="showQuickDates && !disabled" class="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
v-for="quick in quickDates"
|
||||
:key="quick.label"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-3 py-1 text-sm rounded-lg transition-colors',
|
||||
modelValue === quick.value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border hover:bg-primary hover:text-white'
|
||||
]"
|
||||
@click="setQuickDate(quick.value)"
|
||||
>
|
||||
{{ quick.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 相对日期显示 -->
|
||||
<div v-if="showRelativeDate" class="text-sm text-text-secondary">
|
||||
{{ relativeDate }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseIcon from './BaseIcon.vue'
|
||||
import { DataFormatter } from '@/utils/validation'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
min?: string
|
||||
max?: string
|
||||
showQuickDates?: boolean
|
||||
showRelativeDate?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
showQuickDates: true,
|
||||
showRelativeDate: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// 生成唯一ID
|
||||
const inputId = computed(() => `date-picker-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
// 快捷日期选项
|
||||
const quickDates = computed(() => {
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
const thisWeekStart = new Date(today)
|
||||
thisWeekStart.setDate(today.getDate() - today.getDay())
|
||||
|
||||
const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '今天',
|
||||
value: today.toISOString().split('T')[0]
|
||||
},
|
||||
{
|
||||
label: '昨天',
|
||||
value: yesterday.toISOString().split('T')[0]
|
||||
},
|
||||
{
|
||||
label: '本周开始',
|
||||
value: thisWeekStart.toISOString().split('T')[0]
|
||||
},
|
||||
{
|
||||
label: '本月开始',
|
||||
value: thisMonthStart.toISOString().split('T')[0]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 相对日期显示
|
||||
const relativeDate = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
return DataFormatter.formatDate(props.modelValue, 'relative')
|
||||
})
|
||||
|
||||
// 处理输入
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
// 设置快捷日期
|
||||
function setQuickDate(date: string) {
|
||||
emit('update:modelValue', date)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义日期输入框样式 */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
89
frontend/src/components/common/EmptyState.vue
Normal file
89
frontend/src/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div :class="['text-center py-12', containerClasses]">
|
||||
<!-- 图标 -->
|
||||
<div class="mb-4">
|
||||
<BaseIcon
|
||||
v-if="icon"
|
||||
:name="icon"
|
||||
size="xl"
|
||||
class="mx-auto text-text-secondary opacity-50"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-16 mx-auto bg-background rounded-full flex items-center justify-center"
|
||||
>
|
||||
<BaseIcon name="search" size="lg" class="text-text-secondary opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-2">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p class="text-text-secondary mb-6 max-w-sm mx-auto">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="$slots.action || actionText" class="space-y-3">
|
||||
<slot name="action">
|
||||
<BaseButton
|
||||
v-if="actionText"
|
||||
variant="primary"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
<BaseIcon v-if="actionIcon" :name="actionIcon" size="sm" class="mr-2" />
|
||||
{{ actionText }}
|
||||
</BaseButton>
|
||||
</slot>
|
||||
|
||||
<!-- 次要操作 -->
|
||||
<div v-if="secondaryActionText">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-text-secondary hover:text-primary transition-colors"
|
||||
@click="$emit('secondaryAction')"
|
||||
>
|
||||
{{ secondaryActionText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseIcon from './BaseIcon.vue'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
actionText?: string
|
||||
actionIcon?: string
|
||||
secondaryActionText?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md'
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
action: []
|
||||
secondaryAction: []
|
||||
}>()
|
||||
|
||||
// 容器类
|
||||
const containerClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'py-8',
|
||||
md: 'py-12',
|
||||
lg: 'py-16'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
</script>
|
122
frontend/src/components/common/LoadingSpinner.vue
Normal file
122
frontend/src/components/common/LoadingSpinner.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<!-- 旋转加载器 -->
|
||||
<div
|
||||
v-if="type === 'spinner'"
|
||||
:class="[
|
||||
'animate-spin rounded-full border-2 border-current border-t-transparent',
|
||||
sizeClasses,
|
||||
colorClasses
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 脉冲加载器 -->
|
||||
<div
|
||||
v-else-if="type === 'pulse'"
|
||||
:class="[
|
||||
'animate-pulse rounded-full bg-current',
|
||||
sizeClasses,
|
||||
colorClasses
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 点状加载器 -->
|
||||
<div v-else-if="type === 'dots'" :class="['flex space-x-1', colorClasses]">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="[
|
||||
'rounded-full bg-current animate-bounce',
|
||||
dotSizeClasses
|
||||
]"
|
||||
:style="{ animationDelay: `${(i - 1) * 0.1}s` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 骨架屏加载器 -->
|
||||
<div v-else-if="type === 'skeleton'" class="animate-pulse space-y-3">
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文本 -->
|
||||
<div v-if="text" :class="['mt-2 text-sm', colorClasses]">
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
type?: 'spinner' | 'pulse' | 'dots' | 'skeleton'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'primary' | 'secondary' | 'gray' | 'white'
|
||||
text?: string
|
||||
center?: boolean
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'spinner',
|
||||
size: 'md',
|
||||
color: 'primary',
|
||||
center: false,
|
||||
overlay: false
|
||||
})
|
||||
|
||||
// 容器类
|
||||
const containerClasses = computed(() => {
|
||||
const classes = []
|
||||
|
||||
if (props.center) {
|
||||
classes.push('flex flex-col items-center justify-center')
|
||||
}
|
||||
|
||||
if (props.overlay) {
|
||||
classes.push(
|
||||
'fixed inset-0 bg-black bg-opacity-50 z-50',
|
||||
'flex items-center justify-center'
|
||||
)
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
// 尺寸类
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-12 h-12'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
// 点状加载器尺寸
|
||||
const dotSizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-1 h-1',
|
||||
sm: 'w-1.5 h-1.5',
|
||||
md: 'w-2 h-2',
|
||||
lg: 'w-3 h-3',
|
||||
xl: 'w-4 h-4'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
// 颜色类
|
||||
const colorClasses = computed(() => {
|
||||
const colors = {
|
||||
primary: 'text-primary',
|
||||
secondary: 'text-text-secondary',
|
||||
gray: 'text-gray-400',
|
||||
white: 'text-white'
|
||||
}
|
||||
return colors[props.color]
|
||||
})
|
||||
</script>
|
173
frontend/src/components/transaction/TransactionCard.vue
Normal file
173
frontend/src/components/transaction/TransactionCard.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<BaseCard
|
||||
:class="[
|
||||
'transition-all duration-200 hover:shadow-lg cursor-pointer',
|
||||
{ 'ring-2 ring-primary ring-opacity-50': selected }
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 分类图标 -->
|
||||
<CategoryIcon
|
||||
:icon="transaction.category_icon || '💰'"
|
||||
:color="transaction.category_color || '#F97316'"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<!-- 交易信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 商户名和描述 -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="font-semibold text-text-primary truncate">
|
||||
{{ transaction.merchant }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 同步状态指示器 -->
|
||||
<div
|
||||
v-if="transaction.sync_status === 'pending'"
|
||||
class="w-2 h-2 bg-warning rounded-full"
|
||||
title="待同步"
|
||||
/>
|
||||
<div
|
||||
v-else-if="transaction.sync_status === 'conflict'"
|
||||
class="w-2 h-2 bg-error rounded-full"
|
||||
title="同步冲突"
|
||||
/>
|
||||
|
||||
<!-- 来源指示器 -->
|
||||
<BaseIcon
|
||||
v-if="transaction.source === 'notification'"
|
||||
name="bell"
|
||||
size="xs"
|
||||
class="text-text-secondary"
|
||||
title="自动记账"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类和账户信息 -->
|
||||
<div class="flex items-center space-x-2 text-sm text-text-secondary mb-2">
|
||||
<span>{{ transaction.category_name || '未分类' }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ transaction.account_name || '未知账户' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p v-if="transaction.description" class="text-sm text-text-secondary truncate">
|
||||
{{ transaction.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 金额和日期 -->
|
||||
<div class="text-right">
|
||||
<!-- 金额 -->
|
||||
<div
|
||||
:class="[
|
||||
'text-lg font-bold',
|
||||
transaction.type === 'income' ? 'text-accent-green' : 'text-accent-red'
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(transaction.amount, transaction.type) }}
|
||||
</div>
|
||||
|
||||
<!-- 日期 -->
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ formatDate(transaction.date) }}
|
||||
</div>
|
||||
|
||||
<!-- 时间(如果是今天) -->
|
||||
<div v-if="isToday(transaction.date)" class="text-xs text-text-secondary">
|
||||
{{ formatTime(transaction.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮(悬停时显示) -->
|
||||
<div
|
||||
v-if="showActions"
|
||||
class="mt-4 pt-4 border-t border-border flex justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-text-secondary hover:text-primary hover:bg-background rounded-lg transition-colors"
|
||||
@click.stop="$emit('edit')"
|
||||
title="编辑"
|
||||
>
|
||||
<BaseIcon name="edit" size="sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-text-secondary hover:text-error hover:bg-background rounded-lg transition-colors"
|
||||
@click.stop="$emit('delete')"
|
||||
title="删除"
|
||||
>
|
||||
<BaseIcon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseCard from '@/components/common/BaseCard.vue'
|
||||
import BaseIcon from '@/components/common/BaseIcon.vue'
|
||||
import CategoryIcon from '@/components/common/CategoryIcon.vue'
|
||||
import type { TransactionWithDetails } from '@/types'
|
||||
import { DataFormatter } from '@/utils/validation'
|
||||
|
||||
interface Props {
|
||||
transaction: TransactionWithDetails
|
||||
selected?: boolean
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
showActions: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [transaction: TransactionWithDetails]
|
||||
edit: [transaction: TransactionWithDetails]
|
||||
delete: [transaction: TransactionWithDetails]
|
||||
}>()
|
||||
|
||||
// 格式化金额
|
||||
function formatAmount(amount: number, type: 'income' | 'expense'): string {
|
||||
const formatted = DataFormatter.formatCurrency(amount)
|
||||
return type === 'income' ? `+${formatted}` : `-${formatted}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateString: string): string {
|
||||
return DataFormatter.formatDate(dateString, 'relative')
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(dateString?: string): string {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 判断是否为今天
|
||||
function isToday(dateString: string): boolean {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return dateString === today
|
||||
}
|
||||
|
||||
// 处理点击
|
||||
function handleClick() {
|
||||
emit('click', props.transaction)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.group:hover .group-hover\:opacity-100 {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
448
frontend/src/components/transaction/TransactionFilter.vue
Normal file
448
frontend/src/components/transaction/TransactionFilter.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 筛选标题 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-text-primary">筛选条件</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary hover:text-primary-dark transition-colors"
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 交易类型筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">交易类型</h4>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-for="type in transactionTypes"
|
||||
:key="type.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
filters.type === type.value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:border-primary hover:text-primary'
|
||||
]"
|
||||
@click="updateFilter('type', type.value)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">分类</h4>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="category in availableCategories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'p-3 rounded-lg border transition-all',
|
||||
filters.category_ids?.includes(category.id!)
|
||||
? 'border-primary bg-primary bg-opacity-10'
|
||||
: 'border-border hover:border-primary'
|
||||
]"
|
||||
@click="toggleCategory(category.id!)"
|
||||
>
|
||||
<CategoryIcon
|
||||
:icon="category.icon"
|
||||
:color="category.color"
|
||||
size="sm"
|
||||
class="mx-auto mb-2"
|
||||
/>
|
||||
<div class="text-xs text-center text-text-primary">
|
||||
{{ category.name }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">账户</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'p-3 rounded-lg border transition-all flex items-center space-x-3',
|
||||
filters.account_ids?.includes(account.id!)
|
||||
? 'border-primary bg-primary bg-opacity-10'
|
||||
: 'border-border hover:border-primary'
|
||||
]"
|
||||
@click="toggleAccount(account.id!)"
|
||||
>
|
||||
<CategoryIcon
|
||||
:icon="account.icon"
|
||||
:color="account.color"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium text-text-primary">
|
||||
{{ account.name }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
{{ formatAccountType(account.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 金额范围筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">金额范围</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<BaseInput
|
||||
v-model="amountRange.min"
|
||||
type="number"
|
||||
label="最小金额"
|
||||
placeholder="0"
|
||||
@input="updateAmountRange"
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="amountRange.max"
|
||||
type="number"
|
||||
label="最大金额"
|
||||
placeholder="无限制"
|
||||
@input="updateAmountRange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期范围筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">日期范围</h4>
|
||||
|
||||
<!-- 快捷日期选择 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in datePresets"
|
||||
:key="preset.key"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-3 py-1 text-sm rounded-lg transition-colors',
|
||||
selectedDatePreset === preset.key
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:border-primary hover:text-primary'
|
||||
]"
|
||||
@click="selectDatePreset(preset.key)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 自定义日期范围 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<DatePicker
|
||||
v-model="dateRange.start"
|
||||
label="开始日期"
|
||||
:show-quick-dates="false"
|
||||
@update:model-value="updateDateRange"
|
||||
/>
|
||||
<DatePicker
|
||||
v-model="dateRange.end"
|
||||
label="结束日期"
|
||||
:show-quick-dates="false"
|
||||
@update:model-value="updateDateRange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同步状态筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">同步状态</h4>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-for="status in syncStatuses"
|
||||
:key="status.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm transition-colors flex items-center space-x-2',
|
||||
filters.sync_status === status.value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:border-primary hover:text-primary'
|
||||
]"
|
||||
@click="updateFilter('sync_status', status.value)"
|
||||
>
|
||||
<div :class="['w-2 h-2 rounded-full', status.color]" />
|
||||
<span>{{ status.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 来源筛选 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-text-primary">记录来源</h4>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-for="source in sources"
|
||||
:key="source.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm transition-colors flex items-center space-x-2',
|
||||
filters.source === source.value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:border-primary hover:text-primary'
|
||||
]"
|
||||
@click="updateFilter('source', source.value)"
|
||||
>
|
||||
<BaseIcon :name="source.icon" size="xs" />
|
||||
<span>{{ source.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用筛选按钮 -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
@click="applyFilters"
|
||||
>
|
||||
应用筛选 ({{ activeFilterCount }})
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import BaseInput from '@/components/common/BaseInput.vue'
|
||||
import BaseButton from '@/components/common/BaseButton.vue'
|
||||
import BaseIcon from '@/components/common/BaseIcon.vue'
|
||||
import CategoryIcon from '@/components/common/CategoryIcon.vue'
|
||||
import DatePicker from '@/components/common/DatePicker.vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import type { TransactionFilters, Category, Account } from '@/types'
|
||||
import { DataFormatter } from '@/utils/validation'
|
||||
|
||||
interface Props {
|
||||
modelValue: TransactionFilters
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [filters: TransactionFilters]
|
||||
apply: [filters: TransactionFilters]
|
||||
}>()
|
||||
|
||||
const databaseStore = useDatabaseStore()
|
||||
|
||||
// 筛选状态
|
||||
const filters = ref<TransactionFilters>({ ...props.modelValue })
|
||||
const selectedDatePreset = ref<string>('')
|
||||
|
||||
// 金额范围
|
||||
const amountRange = ref({
|
||||
min: props.modelValue.amount_min?.toString() || '',
|
||||
max: props.modelValue.amount_max?.toString() || ''
|
||||
})
|
||||
|
||||
// 日期范围
|
||||
const dateRange = ref({
|
||||
start: props.modelValue.date_from || '',
|
||||
end: props.modelValue.date_to || ''
|
||||
})
|
||||
|
||||
// 交易类型选项
|
||||
const transactionTypes = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'income', label: '收入' },
|
||||
{ value: 'expense', label: '支出' }
|
||||
]
|
||||
|
||||
// 同步状态选项
|
||||
const syncStatuses = [
|
||||
{ value: 'all', label: '全部', color: 'bg-gray-400' },
|
||||
{ value: 'synced', label: '已同步', color: 'bg-accent-green' },
|
||||
{ value: 'pending', label: '待同步', color: 'bg-warning' },
|
||||
{ value: 'conflict', label: '冲突', color: 'bg-error' }
|
||||
]
|
||||
|
||||
// 来源选项
|
||||
const sources = [
|
||||
{ value: 'all', label: '全部', icon: 'list' },
|
||||
{ value: 'manual', label: '手动', icon: 'edit' },
|
||||
{ value: 'notification', label: '自动', icon: 'bell' }
|
||||
]
|
||||
|
||||
// 日期预设
|
||||
const datePresets = [
|
||||
{ key: 'today', label: '今天' },
|
||||
{ key: 'yesterday', label: '昨天' },
|
||||
{ key: 'thisWeek', label: '本周' },
|
||||
{ key: 'thisMonth', label: '本月' },
|
||||
{ key: 'lastMonth', label: '上月' },
|
||||
{ key: 'thisYear', label: '今年' }
|
||||
]
|
||||
|
||||
// 可用分类
|
||||
const availableCategories = computed(() => {
|
||||
return databaseStore.categories.filter(category => {
|
||||
if (filters.value.type === 'all') return true
|
||||
return category.type === filters.value.type || category.type === 'both'
|
||||
})
|
||||
})
|
||||
|
||||
// 可用账户
|
||||
const availableAccounts = computed(() => {
|
||||
return databaseStore.accounts
|
||||
})
|
||||
|
||||
// 活跃筛选条件数量
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
|
||||
if (filters.value.type && filters.value.type !== 'all') count++
|
||||
if (filters.value.category_ids && filters.value.category_ids.length > 0) count++
|
||||
if (filters.value.account_ids && filters.value.account_ids.length > 0) count++
|
||||
if (filters.value.amount_min !== undefined) count++
|
||||
if (filters.value.amount_max !== undefined) count++
|
||||
if (filters.value.date_from) count++
|
||||
if (filters.value.date_to) count++
|
||||
if (filters.value.sync_status && filters.value.sync_status !== 'all') count++
|
||||
if (filters.value.source && filters.value.source !== 'all') count++
|
||||
|
||||
return count
|
||||
})
|
||||
|
||||
// 更新筛选条件
|
||||
function updateFilter(key: keyof TransactionFilters, value: any) {
|
||||
filters.value = { ...filters.value, [key]: value }
|
||||
}
|
||||
|
||||
// 切换分类选择
|
||||
function toggleCategory(categoryId: number) {
|
||||
const currentIds = filters.value.category_ids || []
|
||||
const index = currentIds.indexOf(categoryId)
|
||||
|
||||
if (index > -1) {
|
||||
filters.value.category_ids = currentIds.filter(id => id !== categoryId)
|
||||
} else {
|
||||
filters.value.category_ids = [...currentIds, categoryId]
|
||||
}
|
||||
}
|
||||
|
||||
// 切换账户选择
|
||||
function toggleAccount(accountId: number) {
|
||||
const currentIds = filters.value.account_ids || []
|
||||
const index = currentIds.indexOf(accountId)
|
||||
|
||||
if (index > -1) {
|
||||
filters.value.account_ids = currentIds.filter(id => id !== accountId)
|
||||
} else {
|
||||
filters.value.account_ids = [...currentIds, accountId]
|
||||
}
|
||||
}
|
||||
|
||||
// 更新金额范围
|
||||
function updateAmountRange() {
|
||||
const min = amountRange.value.min ? parseFloat(amountRange.value.min) : undefined
|
||||
const max = amountRange.value.max ? parseFloat(amountRange.value.max) : undefined
|
||||
|
||||
filters.value.amount_min = min
|
||||
filters.value.amount_max = max
|
||||
}
|
||||
|
||||
// 更新日期范围
|
||||
function updateDateRange() {
|
||||
filters.value.date_from = dateRange.value.start || undefined
|
||||
filters.value.date_to = dateRange.value.end || undefined
|
||||
selectedDatePreset.value = '' // 清除预设选择
|
||||
}
|
||||
|
||||
// 选择日期预设
|
||||
function selectDatePreset(preset: string) {
|
||||
selectedDatePreset.value = preset
|
||||
|
||||
const today = new Date()
|
||||
let startDate: Date
|
||||
let endDate: Date = today
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
startDate = today
|
||||
break
|
||||
case 'yesterday':
|
||||
startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 1)
|
||||
endDate = startDate
|
||||
break
|
||||
case 'thisWeek':
|
||||
startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - today.getDay())
|
||||
break
|
||||
case 'thisMonth':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
break
|
||||
case 'lastMonth':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1)
|
||||
endDate = new Date(today.getFullYear(), today.getMonth(), 0)
|
||||
break
|
||||
case 'thisYear':
|
||||
startDate = new Date(today.getFullYear(), 0, 1)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
dateRange.value.start = startDate.toISOString().split('T')[0]
|
||||
dateRange.value.end = endDate.toISOString().split('T')[0]
|
||||
updateDateRange()
|
||||
}
|
||||
|
||||
// 格式化账户类型
|
||||
function formatAccountType(type: string) {
|
||||
return DataFormatter.formatAccountType(type as any)
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
function resetFilters() {
|
||||
filters.value = {
|
||||
type: 'all',
|
||||
category_ids: [],
|
||||
account_ids: [],
|
||||
amount_min: undefined,
|
||||
amount_max: undefined,
|
||||
date_from: undefined,
|
||||
date_to: undefined,
|
||||
sync_status: 'all',
|
||||
source: 'all'
|
||||
}
|
||||
|
||||
amountRange.value = { min: '', max: '' }
|
||||
dateRange.value = { start: '', end: '' }
|
||||
selectedDatePreset.value = ''
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
function applyFilters() {
|
||||
emit('update:modelValue', { ...filters.value })
|
||||
emit('apply', { ...filters.value })
|
||||
}
|
||||
|
||||
// 监听外部筛选条件变化
|
||||
watch(() => props.modelValue, (newFilters) => {
|
||||
filters.value = { ...newFilters }
|
||||
|
||||
// 同步金额范围
|
||||
amountRange.value = {
|
||||
min: newFilters.amount_min?.toString() || '',
|
||||
max: newFilters.amount_max?.toString() || ''
|
||||
}
|
||||
|
||||
// 同步日期范围
|
||||
dateRange.value = {
|
||||
start: newFilters.date_from || '',
|
||||
end: newFilters.date_to || ''
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
410
frontend/src/components/transaction/TransactionForm.vue
Normal file
410
frontend/src/components/transaction/TransactionForm.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 表单标题 -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-text-primary mb-2">
|
||||
{{ isEdit ? '编辑交易' : '添加交易' }}
|
||||
</h2>
|
||||
<p class="text-text-secondary">
|
||||
{{ isEdit ? '修改交易信息' : '记录一笔新的交易' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- 金额输入 -->
|
||||
<AmountInput
|
||||
v-model="form.amount"
|
||||
v-model:type="form.type"
|
||||
label="交易金额"
|
||||
placeholder="0.00"
|
||||
:show-type-toggle="true"
|
||||
:show-quick-amounts="true"
|
||||
:error="errors.amount"
|
||||
@focus="clearError('amount')"
|
||||
/>
|
||||
|
||||
<!-- 分类选择 -->
|
||||
<BaseSelect
|
||||
v-model="form.category_id"
|
||||
:options="categoryOptions"
|
||||
label="交易分类"
|
||||
placeholder="选择分类"
|
||||
searchable
|
||||
allow-add
|
||||
:error="errors.category_id"
|
||||
@add="handleAddCategory"
|
||||
@update:model-value="clearError('category_id')"
|
||||
/>
|
||||
|
||||
<!-- 账户选择 -->
|
||||
<BaseSelect
|
||||
v-model="form.account_id"
|
||||
:options="accountOptions"
|
||||
label="支付账户"
|
||||
placeholder="选择账户"
|
||||
:error="errors.account_id"
|
||||
@update:model-value="clearError('account_id')"
|
||||
/>
|
||||
|
||||
<!-- 商户名输入 -->
|
||||
<BaseInput
|
||||
v-model="form.merchant"
|
||||
label="商户名称"
|
||||
placeholder="输入商户名称"
|
||||
:error="errors.merchant"
|
||||
@input="clearError('merchant')"
|
||||
/>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<DatePicker
|
||||
v-model="form.date"
|
||||
label="交易日期"
|
||||
:show-quick-dates="true"
|
||||
:error="errors.date"
|
||||
@update:model-value="clearError('date')"
|
||||
/>
|
||||
|
||||
<!-- 备注输入 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-text-primary">
|
||||
备注 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
placeholder="添加备注信息..."
|
||||
rows="3"
|
||||
class="input-neumorphic resize-none"
|
||||
:class="{ 'border-error': errors.description }"
|
||||
@input="clearError('description')"
|
||||
/>
|
||||
<div v-if="errors.description" class="text-sm text-error">
|
||||
{{ errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项 -->
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center text-sm text-text-secondary hover:text-primary transition-colors"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
<BaseIcon
|
||||
name="chevron-right"
|
||||
size="sm"
|
||||
:class="['mr-2 transition-transform', { 'rotate-90': showAdvanced }]"
|
||||
/>
|
||||
高级选项
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="transform -translate-y-2 opacity-0 max-h-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100 max-h-96"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100 max-h-96"
|
||||
leave-to-class="transform -translate-y-2 opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="showAdvanced" class="space-y-4 pl-6 border-l-2 border-border">
|
||||
<!-- 来源选择 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-text-primary">
|
||||
记录来源
|
||||
</label>
|
||||
<div class="flex space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.source"
|
||||
type="radio"
|
||||
value="manual"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm">手动添加</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.source"
|
||||
type="radio"
|
||||
value="notification"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm">通知捕获</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始通知内容 -->
|
||||
<div v-if="form.source === 'notification'" class="space-y-2">
|
||||
<label class="block text-sm font-medium text-text-primary">
|
||||
原始通知内容
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.raw_notification"
|
||||
placeholder="原始通知内容..."
|
||||
rows="2"
|
||||
class="input-neumorphic resize-none text-xs"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 表单操作按钮 -->
|
||||
<div class="flex space-x-4 pt-6">
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="flex-1"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="flex-1"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<LoadingSpinner v-if="isSubmitting" type="spinner" size="sm" class="mr-2" />
|
||||
{{ isEdit ? '保存修改' : '添加交易' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 重复交易提示 -->
|
||||
<div
|
||||
v-if="duplicateWarning"
|
||||
class="bg-warning bg-opacity-10 border border-warning rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<BaseIcon name="warning" size="sm" class="text-warning mt-0.5" />
|
||||
<div>
|
||||
<h4 class="font-semibold text-warning mb-1">可能的重复交易</h4>
|
||||
<p class="text-sm text-text-secondary mb-3">
|
||||
发现相似的交易记录,请确认是否继续添加。
|
||||
</p>
|
||||
<div class="flex space-x-2">
|
||||
<BaseButton size="sm" variant="secondary" @click="duplicateWarning = false">
|
||||
继续添加
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" variant="primary" @click="handleCancel">
|
||||
取消
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import BaseInput from '@/components/common/BaseInput.vue'
|
||||
import BaseButton from '@/components/common/BaseButton.vue'
|
||||
import BaseSelect from '@/components/common/BaseSelect.vue'
|
||||
import BaseIcon from '@/components/common/BaseIcon.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import AmountInput from '@/components/common/AmountInput.vue'
|
||||
import DatePicker from '@/components/common/DatePicker.vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { repositoryManager } from '@/repositories'
|
||||
import type { Transaction, TransactionWithDetails, SelectOption } from '@/types'
|
||||
import { validateTransaction, DataSanitizer, DataFormatter } from '@/utils/validation'
|
||||
|
||||
interface Props {
|
||||
transaction?: TransactionWithDetails
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isEdit: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [transaction: Omit<Transaction, 'id'>]
|
||||
cancel: []
|
||||
success: [transaction: Transaction]
|
||||
error: [error: string]
|
||||
}>()
|
||||
|
||||
const databaseStore = useDatabaseStore()
|
||||
|
||||
// 表单状态
|
||||
const form = ref({
|
||||
amount: 0,
|
||||
type: 'expense' as 'income' | 'expense',
|
||||
category_id: 0,
|
||||
account_id: 0,
|
||||
merchant: '',
|
||||
description: '',
|
||||
date: DataFormatter.getTodayDateString(),
|
||||
source: 'manual' as 'manual' | 'notification',
|
||||
raw_notification: ''
|
||||
})
|
||||
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const isSubmitting = ref(false)
|
||||
const showAdvanced = ref(false)
|
||||
const duplicateWarning = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const categoryOptions = computed<SelectOption[]>(() => {
|
||||
return databaseStore.categories
|
||||
.filter(c => c.type === form.value.type || c.type === 'both')
|
||||
.map(c => ({
|
||||
value: c.id!,
|
||||
label: c.name,
|
||||
icon: c.icon,
|
||||
color: c.color
|
||||
}))
|
||||
})
|
||||
|
||||
const accountOptions = computed<SelectOption[]>(() => {
|
||||
return databaseStore.accounts.map(a => ({
|
||||
value: a.id!,
|
||||
label: a.name,
|
||||
icon: a.icon,
|
||||
color: a.color,
|
||||
description: DataFormatter.formatAccountType(a.type)
|
||||
}))
|
||||
})
|
||||
|
||||
// 初始化表单
|
||||
function initializeForm() {
|
||||
if (props.isEdit && props.transaction) {
|
||||
form.value = {
|
||||
amount: props.transaction.amount,
|
||||
type: props.transaction.type,
|
||||
category_id: props.transaction.category_id,
|
||||
account_id: props.transaction.account_id,
|
||||
merchant: props.transaction.merchant,
|
||||
description: props.transaction.description || '',
|
||||
date: props.transaction.date,
|
||||
source: props.transaction.source || 'manual',
|
||||
raw_notification: props.transaction.raw_notification || ''
|
||||
}
|
||||
|
||||
if (props.transaction.source === 'notification') {
|
||||
showAdvanced.value = true
|
||||
}
|
||||
} else {
|
||||
// 设置默认值
|
||||
const defaultCategory = databaseStore.getDefaultCategory(form.value.type)
|
||||
const defaultAccount = databaseStore.getDefaultAccount()
|
||||
|
||||
if (defaultCategory) {
|
||||
form.value.category_id = defaultCategory.id!
|
||||
}
|
||||
if (defaultAccount) {
|
||||
form.value.account_id = defaultAccount.id!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
function validateForm(): boolean {
|
||||
errors.value = {}
|
||||
|
||||
const validationErrors = validateTransaction(form.value)
|
||||
|
||||
validationErrors.forEach(error => {
|
||||
if (error.includes('金额')) {
|
||||
errors.value.amount = error
|
||||
} else if (error.includes('分类')) {
|
||||
errors.value.category_id = error
|
||||
} else if (error.includes('账户')) {
|
||||
errors.value.account_id = error
|
||||
} else if (error.includes('商户')) {
|
||||
errors.value.merchant = error
|
||||
} else if (error.includes('日期')) {
|
||||
errors.value.date = error
|
||||
} else if (error.includes('描述')) {
|
||||
errors.value.description = error
|
||||
}
|
||||
})
|
||||
|
||||
return validationErrors.length === 0
|
||||
}
|
||||
|
||||
// 清除错误
|
||||
function clearError(field: string) {
|
||||
if (errors.value[field]) {
|
||||
delete errors.value[field]
|
||||
}
|
||||
}
|
||||
|
||||
// 检查重复交易
|
||||
async function checkDuplicates(): Promise<boolean> {
|
||||
if (props.isEdit) return false
|
||||
|
||||
try {
|
||||
const duplicates = await repositoryManager.transaction.findDuplicates(form.value)
|
||||
if (duplicates.length > 0) {
|
||||
duplicateWarning.value = true
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查重复交易失败:', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) return
|
||||
|
||||
// 检查重复交易
|
||||
if (await checkDuplicates()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
// 清理数据
|
||||
const cleanedData = DataSanitizer.sanitizeTransaction(form.value)
|
||||
|
||||
if (props.isEdit && props.transaction?.id) {
|
||||
// 更新交易
|
||||
await databaseStore.updateTransaction(props.transaction.id, cleanedData)
|
||||
emit('success', { ...cleanedData, id: props.transaction.id } as Transaction)
|
||||
} else {
|
||||
// 添加交易
|
||||
const id = await databaseStore.addTransaction(cleanedData as Omit<Transaction, 'id'>)
|
||||
emit('success', { ...cleanedData, id } as Transaction)
|
||||
}
|
||||
|
||||
emit('submit', cleanedData as Omit<Transaction, 'id'>)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '操作失败'
|
||||
emit('error', errorMessage)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// 处理添加分类
|
||||
function handleAddCategory() {
|
||||
// 这里可以打开添加分类的对话框
|
||||
console.log('添加新分类')
|
||||
}
|
||||
|
||||
// 监听交易类型变化,更新默认分类
|
||||
watch(() => form.value.type, (newType) => {
|
||||
const defaultCategory = databaseStore.getDefaultCategory(newType)
|
||||
if (defaultCategory && form.value.category_id === 0) {
|
||||
form.value.category_id = defaultCategory.id!
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
initializeForm()
|
||||
})
|
||||
</script>
|
274
frontend/src/components/transaction/TransactionList.vue
Normal file
274
frontend/src/components/transaction/TransactionList.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div v-for="i in 3" :key="i" class="animate-pulse">
|
||||
<BaseCard>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-10 h-10 bg-gray-200 rounded-full"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div class="text-right space-y-2">
|
||||
<div class="h-4 bg-gray-200 rounded w-16"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 交易列表 -->
|
||||
<div v-else-if="groupedTransactions.length > 0" class="space-y-6">
|
||||
<div v-for="group in groupedTransactions" :key="group.date" class="space-y-3">
|
||||
<!-- 日期分组标题 -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-background rounded-lg">
|
||||
<h3 class="font-semibold text-text-primary">
|
||||
{{ formatGroupDate(group.date) }}
|
||||
</h3>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ group.transactions.length }} 笔交易
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 该日期的交易 -->
|
||||
<div class="space-y-3">
|
||||
<TransactionCard
|
||||
v-for="transaction in group.transactions"
|
||||
:key="transaction.id"
|
||||
:transaction="transaction"
|
||||
:selected="selectedIds.includes(transaction.id!)"
|
||||
:show-actions="selectionMode"
|
||||
@click="handleTransactionClick"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 日期汇总 -->
|
||||
<div v-if="showDailySummary" class="px-4 py-2 bg-surface border border-border rounded-lg">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-text-secondary">当日汇总</span>
|
||||
<div class="space-x-4">
|
||||
<span v-if="group.summary.income > 0" class="text-accent-green">
|
||||
收入 ¥{{ group.summary.income.toFixed(2) }}
|
||||
</span>
|
||||
<span v-if="group.summary.expense > 0" class="text-accent-red">
|
||||
支出 ¥{{ group.summary.expense.toFixed(2) }}
|
||||
</span>
|
||||
<span class="font-semibold" :class="group.summary.balance >= 0 ? 'text-accent-green' : 'text-accent-red'">
|
||||
{{ group.summary.balance >= 0 ? '+' : '' }}¥{{ group.summary.balance.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore" class="text-center py-4">
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
:disabled="loadingMore"
|
||||
@click="$emit('loadMore')"
|
||||
>
|
||||
<LoadingSpinner v-if="loadingMore" type="spinner" size="sm" class="mr-2" />
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-else
|
||||
title="暂无交易记录"
|
||||
description="开始记录您的第一笔交易吧"
|
||||
icon="dollar"
|
||||
action-text="添加交易"
|
||||
action-icon="plus"
|
||||
@action="$emit('add')"
|
||||
/>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="transform translate-y-full opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="selectionMode && selectedIds.length > 0"
|
||||
class="fixed bottom-0 left-0 right-0 bg-surface border-t border-border p-4 z-40"
|
||||
>
|
||||
<div class="max-w-md mx-auto flex items-center justify-between">
|
||||
<span class="text-sm text-text-secondary">
|
||||
已选择 {{ selectedIds.length }} 项
|
||||
</span>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<BaseButton variant="secondary" size="sm" @click="clearSelection">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" size="sm" @click="handleBatchDelete">
|
||||
删除
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseCard from '@/components/common/BaseCard.vue'
|
||||
import BaseButton from '@/components/common/BaseButton.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import TransactionCard from './TransactionCard.vue'
|
||||
import type { TransactionWithDetails } from '@/types'
|
||||
import { DataFormatter } from '@/utils/validation'
|
||||
|
||||
interface TransactionGroup {
|
||||
date: string
|
||||
transactions: TransactionWithDetails[]
|
||||
summary: {
|
||||
income: number
|
||||
expense: number
|
||||
balance: number
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
transactions: TransactionWithDetails[]
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
hasMore?: boolean
|
||||
selectionMode?: boolean
|
||||
selectedIds?: number[]
|
||||
showDailySummary?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: false,
|
||||
selectionMode: false,
|
||||
selectedIds: () => [],
|
||||
showDailySummary: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [transaction: TransactionWithDetails]
|
||||
edit: [transaction: TransactionWithDetails]
|
||||
delete: [transaction: TransactionWithDetails]
|
||||
batchDelete: [ids: number[]]
|
||||
add: []
|
||||
loadMore: []
|
||||
selectionChange: [ids: number[]]
|
||||
}>()
|
||||
|
||||
// 按日期分组的交易
|
||||
const groupedTransactions = computed<TransactionGroup[]>(() => {
|
||||
const groups = new Map<string, TransactionWithDetails[]>()
|
||||
|
||||
// 按日期分组
|
||||
props.transactions.forEach(transaction => {
|
||||
const date = transaction.date
|
||||
if (!groups.has(date)) {
|
||||
groups.set(date, [])
|
||||
}
|
||||
groups.get(date)!.push(transaction)
|
||||
})
|
||||
|
||||
// 转换为数组并排序
|
||||
const result: TransactionGroup[] = []
|
||||
|
||||
Array.from(groups.entries())
|
||||
.sort(([a], [b]) => new Date(b).getTime() - new Date(a).getTime()) // 按日期倒序
|
||||
.forEach(([date, transactions]) => {
|
||||
// 计算当日汇总
|
||||
const income = transactions
|
||||
.filter(t => t.type === 'income')
|
||||
.reduce((sum, t) => sum + t.amount, 0)
|
||||
|
||||
const expense = transactions
|
||||
.filter(t => t.type === 'expense')
|
||||
.reduce((sum, t) => sum + t.amount, 0)
|
||||
|
||||
result.push({
|
||||
date,
|
||||
transactions: transactions.sort((a, b) =>
|
||||
new Date(b.created_at || b.date).getTime() - new Date(a.created_at || a.date).getTime()
|
||||
),
|
||||
summary: {
|
||||
income,
|
||||
expense,
|
||||
balance: income - expense
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 格式化分组日期
|
||||
function formatGroupDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0]
|
||||
|
||||
if (dateStr === todayStr) {
|
||||
return '今天'
|
||||
} else if (dateStr === yesterdayStr) {
|
||||
return '昨天'
|
||||
} else {
|
||||
return DataFormatter.formatDate(dateString, 'long')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理交易点击
|
||||
function handleTransactionClick(transaction: TransactionWithDetails) {
|
||||
if (props.selectionMode) {
|
||||
toggleSelection(transaction.id!)
|
||||
} else {
|
||||
emit('click', transaction)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换选择状态
|
||||
function toggleSelection(id: number) {
|
||||
const newSelection = props.selectedIds.includes(id)
|
||||
? props.selectedIds.filter(selectedId => selectedId !== id)
|
||||
: [...props.selectedIds, id]
|
||||
|
||||
emit('selectionChange', newSelection)
|
||||
}
|
||||
|
||||
// 清除选择
|
||||
function clearSelection() {
|
||||
emit('selectionChange', [])
|
||||
}
|
||||
|
||||
// 处理编辑
|
||||
function handleEdit(transaction: TransactionWithDetails) {
|
||||
emit('edit', transaction)
|
||||
}
|
||||
|
||||
// 处理删除
|
||||
function handleDelete(transaction: TransactionWithDetails) {
|
||||
emit('delete', transaction)
|
||||
}
|
||||
|
||||
// 处理批量删除
|
||||
function handleBatchDelete() {
|
||||
emit('batchDelete', props.selectedIds)
|
||||
}
|
||||
</script>
|
308
frontend/src/components/transaction/TransactionSearch.vue
Normal file
308
frontend/src/components/transaction/TransactionSearch.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative">
|
||||
<BaseInput
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索商户名、备注..."
|
||||
class="pr-20"
|
||||
@input="handleSearch"
|
||||
@keydown.enter="handleEnterSearch"
|
||||
/>
|
||||
|
||||
<!-- 搜索图标 -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 space-x-2">
|
||||
<!-- 清除按钮 -->
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="p-1 text-text-secondary hover:text-primary rounded transition-colors"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<BaseIcon name="x" size="xs" />
|
||||
</button>
|
||||
|
||||
<!-- 搜索按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-text-secondary hover:text-primary rounded transition-colors"
|
||||
@click="handleEnterSearch"
|
||||
>
|
||||
<BaseIcon name="search" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索建议 -->
|
||||
<div v-if="showSuggestions && suggestions.length > 0" class="space-y-2">
|
||||
<h4 class="text-sm font-medium text-text-primary">搜索建议</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm bg-surface border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div v-if="showHistory && searchHistory.length > 0" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-text-primary">搜索历史</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-text-secondary hover:text-primary transition-colors"
|
||||
@click="clearHistory"
|
||||
>
|
||||
清除历史
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(history, index) in searchHistory"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm bg-surface border border-border rounded-lg hover:border-primary hover:text-primary transition-colors flex items-center space-x-2"
|
||||
@click="selectSuggestion(history)"
|
||||
>
|
||||
<BaseIcon name="clock" size="xs" class="text-text-secondary" />
|
||||
<span>{{ history }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速筛选标签 -->
|
||||
<div v-if="showQuickFilters" class="space-y-2">
|
||||
<h4 class="text-sm font-medium text-text-primary">快速筛选</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in quickFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm bg-surface border border-border rounded-lg hover:border-primary hover:text-primary transition-colors flex items-center space-x-2"
|
||||
@click="applyQuickFilter(filter)"
|
||||
>
|
||||
<BaseIcon :name="filter.icon" size="xs" />
|
||||
<span>{{ filter.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果统计 -->
|
||||
<div v-if="searchQuery && resultCount !== null" class="text-sm text-text-secondary">
|
||||
找到 {{ resultCount }} 条相关记录
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import BaseInput from '@/components/common/BaseInput.vue'
|
||||
import BaseIcon from '@/components/common/BaseIcon.vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import type { TransactionFilters } from '@/types'
|
||||
|
||||
interface QuickFilter {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
filter: Partial<TransactionFilters>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
showSuggestions?: boolean
|
||||
showHistory?: boolean
|
||||
showQuickFilters?: boolean
|
||||
resultCount?: number | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showSuggestions: true,
|
||||
showHistory: true,
|
||||
showQuickFilters: true,
|
||||
resultCount: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
search: [query: string]
|
||||
quickFilter: [filter: Partial<TransactionFilters>]
|
||||
}>()
|
||||
|
||||
const databaseStore = useDatabaseStore()
|
||||
|
||||
// 搜索状态
|
||||
const searchQuery = ref(props.modelValue)
|
||||
const searchHistory = ref<string[]>([])
|
||||
|
||||
// 搜索建议
|
||||
const suggestions = computed(() => {
|
||||
if (!searchQuery.value || searchQuery.value.length < 2) return []
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
const merchants = new Set<string>()
|
||||
|
||||
// 从交易记录中提取商户名
|
||||
databaseStore.transactions.forEach(transaction => {
|
||||
if (transaction.merchant.toLowerCase().includes(query)) {
|
||||
merchants.add(transaction.merchant)
|
||||
}
|
||||
|
||||
// 从描述中提取关键词
|
||||
if (transaction.description) {
|
||||
const words = transaction.description.toLowerCase().split(/\s+/)
|
||||
words.forEach(word => {
|
||||
if (word.includes(query) && word.length > 2) {
|
||||
merchants.add(word)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(merchants).slice(0, 5)
|
||||
})
|
||||
|
||||
// 快速筛选选项
|
||||
const quickFilters: QuickFilter[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: '今天',
|
||||
icon: 'calendar',
|
||||
filter: {
|
||||
date_from: new Date().toISOString().split('T')[0],
|
||||
date_to: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'thisWeek',
|
||||
label: '本周',
|
||||
icon: 'calendar',
|
||||
filter: {
|
||||
date_from: (() => {
|
||||
const today = new Date()
|
||||
const startOfWeek = new Date(today)
|
||||
startOfWeek.setDate(today.getDate() - today.getDay())
|
||||
return startOfWeek.toISOString().split('T')[0]
|
||||
})(),
|
||||
date_to: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'income',
|
||||
label: '收入',
|
||||
icon: 'trending-up',
|
||||
filter: { type: 'income' }
|
||||
},
|
||||
{
|
||||
key: 'expense',
|
||||
label: '支出',
|
||||
icon: 'trending-down',
|
||||
filter: { type: 'expense' }
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待同步',
|
||||
icon: 'clock',
|
||||
filter: { sync_status: 'pending' }
|
||||
},
|
||||
{
|
||||
key: 'notification',
|
||||
label: '自动记账',
|
||||
icon: 'bell',
|
||||
filter: { source: 'notification' }
|
||||
}
|
||||
]
|
||||
|
||||
// 处理搜索输入
|
||||
function handleSearch() {
|
||||
emit('update:modelValue', searchQuery.value)
|
||||
|
||||
// 防抖搜索
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
if (searchQuery.value.trim()) {
|
||||
emit('search', searchQuery.value.trim())
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
let searchTimeout: NodeJS.Timeout
|
||||
|
||||
// 处理回车搜索
|
||||
function handleEnterSearch() {
|
||||
if (searchQuery.value.trim()) {
|
||||
addToHistory(searchQuery.value.trim())
|
||||
emit('search', searchQuery.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
emit('update:modelValue', '')
|
||||
emit('search', '')
|
||||
}
|
||||
|
||||
// 选择建议
|
||||
function selectSuggestion(suggestion: string) {
|
||||
searchQuery.value = suggestion
|
||||
emit('update:modelValue', suggestion)
|
||||
addToHistory(suggestion)
|
||||
emit('search', suggestion)
|
||||
}
|
||||
|
||||
// 应用快速筛选
|
||||
function applyQuickFilter(filter: QuickFilter) {
|
||||
emit('quickFilter', filter.filter)
|
||||
}
|
||||
|
||||
// 添加到搜索历史
|
||||
function addToHistory(query: string) {
|
||||
const history = searchHistory.value.filter(h => h !== query)
|
||||
history.unshift(query)
|
||||
searchHistory.value = history.slice(0, 10) // 保留最近10条
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// 清除搜索历史
|
||||
function clearHistory() {
|
||||
searchHistory.value = []
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// 保存搜索历史到本地存储
|
||||
function saveHistory() {
|
||||
try {
|
||||
localStorage.setItem('transaction-search-history', JSON.stringify(searchHistory.value))
|
||||
} catch (error) {
|
||||
console.warn('保存搜索历史失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载搜索历史
|
||||
function loadHistory() {
|
||||
try {
|
||||
const saved = localStorage.getItem('transaction-search-history')
|
||||
if (saved) {
|
||||
searchHistory.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载搜索历史失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听外部搜索值变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
searchQuery.value = newValue
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
})
|
||||
</script>
|
@@ -15,6 +15,16 @@ const router = createRouter({
|
||||
name: 'transactions',
|
||||
component: () => import('@/views/TransactionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/transactions/add',
|
||||
name: 'add-transaction',
|
||||
component: () => import('@/views/AddTransactionView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/transactions/:id/edit',
|
||||
name: 'edit-transaction',
|
||||
component: () => import('@/views/AddTransactionView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/analytics',
|
||||
name: 'analytics',
|
||||
|
179
frontend/src/views/AddTransactionView.vue
Normal file
179
frontend/src/views/AddTransactionView.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- 头部 -->
|
||||
<header class="bg-surface shadow-soft border-b border-border sticky top-0 z-30">
|
||||
<div class="max-w-md mx-auto px-4 py-4">
|
||||
<div class="flex items-center">
|
||||
<!-- 返回按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -ml-2 text-text-secondary hover:text-primary hover:bg-background rounded-lg transition-colors"
|
||||
@click="handleBack"
|
||||
>
|
||||
<BaseIcon name="chevron-left" size="md" />
|
||||
</button>
|
||||
|
||||
<h1 class="text-xl font-bold text-text-primary ml-2">
|
||||
{{ isEdit ? '编辑交易' : '添加交易' }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="max-w-md mx-auto px-4 py-6">
|
||||
<TransactionForm
|
||||
:transaction="transaction"
|
||||
:is-edit="isEdit"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
@success="handleSuccess"
|
||||
@error="handleError"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="transform translate-y-full opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showSuccess"
|
||||
class="fixed bottom-4 left-4 right-4 z-50"
|
||||
>
|
||||
<div class="max-w-md mx-auto bg-success text-white px-4 py-3 rounded-lg shadow-lg flex items-center">
|
||||
<BaseIcon name="check" size="sm" class="mr-3" />
|
||||
<span>{{ isEdit ? '交易更新成功' : '交易添加成功' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="transform translate-y-full opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showError"
|
||||
class="fixed bottom-4 left-4 right-4 z-50"
|
||||
>
|
||||
<div class="max-w-md mx-auto bg-error text-white px-4 py-3 rounded-lg shadow-lg flex items-center">
|
||||
<BaseIcon name="x" size="sm" class="mr-3" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto p-1 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
@click="showError = false"
|
||||
>
|
||||
<BaseIcon name="x" size="xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BaseIcon from '@/components/common/BaseIcon.vue'
|
||||
import TransactionForm from '@/components/transaction/TransactionForm.vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import type { Transaction, TransactionWithDetails } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const databaseStore = useDatabaseStore()
|
||||
|
||||
// 状态
|
||||
const transaction = ref<TransactionWithDetails>()
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 计算属性
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const transactionId = computed(() => {
|
||||
const id = route.params.id
|
||||
return typeof id === 'string' ? parseInt(id) : 0
|
||||
})
|
||||
|
||||
// 加载交易数据(编辑模式)
|
||||
async function loadTransaction() {
|
||||
if (!isEdit.value || !transactionId.value) return
|
||||
|
||||
try {
|
||||
const foundTransaction = databaseStore.transactions.find(t => t.id === transactionId.value)
|
||||
if (foundTransaction) {
|
||||
transaction.value = foundTransaction
|
||||
} else {
|
||||
// 如果在 store 中找不到,尝试从数据库加载
|
||||
await databaseStore.refreshIfNeeded()
|
||||
const refreshedTransaction = databaseStore.transactions.find(t => t.id === transactionId.value)
|
||||
if (refreshedTransaction) {
|
||||
transaction.value = refreshedTransaction
|
||||
} else {
|
||||
throw new Error('交易不存在')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载交易失败:', error)
|
||||
handleError(error instanceof Error ? error.message : '加载交易失败')
|
||||
// 延迟返回,让用户看到错误信息
|
||||
setTimeout(() => {
|
||||
handleBack()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理返回
|
||||
function handleBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
function handleSubmit(transactionData: Omit<Transaction, 'id'>) {
|
||||
console.log('提交交易:', transactionData)
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
function handleCancel() {
|
||||
handleBack()
|
||||
}
|
||||
|
||||
// 处理成功
|
||||
function handleSuccess(savedTransaction: Transaction) {
|
||||
console.log('交易保存成功:', savedTransaction)
|
||||
showSuccess.value = true
|
||||
|
||||
// 2秒后自动返回
|
||||
setTimeout(() => {
|
||||
showSuccess.value = false
|
||||
handleBack()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 处理错误
|
||||
function handleError(error: string) {
|
||||
errorMessage.value = error
|
||||
showError.value = true
|
||||
|
||||
// 5秒后自动隐藏错误信息
|
||||
setTimeout(() => {
|
||||
showError.value = false
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadTransaction()
|
||||
})
|
||||
</script>
|
@@ -89,7 +89,7 @@
|
||||
<BaseButton variant="primary" class="flex-1" @click="addTestTransaction">
|
||||
添加测试交易
|
||||
</BaseButton>
|
||||
<BaseButton variant="secondary" class="flex-1">
|
||||
<BaseButton variant="secondary" class="flex-1" @click="goToTransactions">
|
||||
查看交易
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -110,12 +110,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseCard from '@/components/common/BaseCard.vue'
|
||||
import BaseButton from '@/components/common/BaseButton.vue'
|
||||
import BaseInput from '@/components/common/BaseInput.vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useOfflineStore } from '@/stores/offline'
|
||||
|
||||
const router = useRouter()
|
||||
const testInput = ref('')
|
||||
const databaseStore = useDatabaseStore()
|
||||
const offlineStore = useOfflineStore()
|
||||
@@ -142,6 +144,11 @@ async function addTestTransaction() {
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到交易页面
|
||||
function goToTransactions() {
|
||||
router.push('/transactions')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 确保数据库已初始化
|
||||
if (!databaseStore.isInitialized) {
|
||||
|
@@ -1,22 +1,401 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<header class="bg-surface shadow-soft border-b border-border">
|
||||
<div class="max-w-md mx-auto px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-text-primary">交易记录</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- 头部 -->
|
||||
<header class="bg-surface shadow-soft border-b border-border sticky top-0 z-30">
|
||||
<div class="max-w-md mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-text-primary">交易记录</h1>
|
||||
|
||||
<!-- 头部操作按钮 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 搜索按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-text-secondary hover:text-primary hover:bg-background rounded-lg transition-colors"
|
||||
@click="toggleSearch"
|
||||
>
|
||||
<BaseIcon name="search" size="sm" />
|
||||
</button>
|
||||
|
||||
<!-- 筛选按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-text-secondary hover:text-primary hover:bg-background rounded-lg transition-colors"
|
||||
@click="toggleFilter"
|
||||
>
|
||||
<BaseIcon name="filter" size="sm" />
|
||||
</button>
|
||||
|
||||
<!-- 选择模式按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'p-2 rounded-lg transition-colors',
|
||||
selectionMode
|
||||
? 'text-primary bg-primary bg-opacity-10'
|
||||
: 'text-text-secondary hover:text-primary hover:bg-background'
|
||||
]"
|
||||
@click="toggleSelectionMode"
|
||||
>
|
||||
<BaseIcon name="check" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="transform -translate-y-2 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform -translate-y-2 opacity-0"
|
||||
>
|
||||
<div v-if="showSearch" class="mt-4">
|
||||
<TransactionSearch
|
||||
v-model="searchQuery"
|
||||
:result-count="displayTransactions.length"
|
||||
@search="handleSearch"
|
||||
@quick-filter="handleQuickFilter"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-md mx-auto px-4 py-6">
|
||||
<BaseCard>
|
||||
<div class="text-center py-8">
|
||||
<h2 class="text-lg font-semibold text-text-primary mb-2">交易记录页面</h2>
|
||||
<p class="text-text-secondary">此页面将在后续任务中实现</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</main>
|
||||
</div>
|
||||
<!-- 筛选面板 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="transform -translate-x-full opacity-0"
|
||||
enter-to-class="transform translate-x-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="transform translate-x-0 opacity-100"
|
||||
leave-to-class="transform -translate-x-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fixed inset-0 z-40 bg-black bg-opacity-50"
|
||||
@click="showFilter = false"
|
||||
>
|
||||
<div
|
||||
class="absolute right-0 top-0 h-full w-80 bg-surface shadow-xl overflow-y-auto"
|
||||
@click.stop
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-lg font-semibold text-text-primary">筛选条件</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-text-secondary hover:text-primary hover:bg-background rounded-lg transition-colors"
|
||||
@click="showFilter = false"
|
||||
>
|
||||
<BaseIcon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TransactionFilter
|
||||
v-model="filters"
|
||||
@apply="handleApplyFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="max-w-md mx-auto px-4 py-6 pb-20">
|
||||
<!-- 统计卡片 -->
|
||||
<BaseCard v-if="!searchQuery" class="mb-6">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-accent-green">
|
||||
¥{{ stats.totalIncome.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">收入</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-bold text-accent-red">
|
||||
¥{{ stats.totalExpense.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">支出</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-lg font-bold"
|
||||
:class="stats.balance >= 0 ? 'text-accent-green' : 'text-accent-red'"
|
||||
>
|
||||
{{ stats.balance >= 0 ? '+' : '' }}¥{{ stats.balance.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">余额</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 交易列表 -->
|
||||
<TransactionList
|
||||
:transactions="displayTransactions"
|
||||
:loading="loading"
|
||||
:loading-more="loadingMore"
|
||||
:has-more="hasMore"
|
||||
:selection-mode="selectionMode"
|
||||
:selected-ids="selectedIds"
|
||||
@click="handleTransactionClick"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@batch-delete="handleBatchDelete"
|
||||
@add="handleAdd"
|
||||
@load-more="loadMore"
|
||||
@selection-change="handleSelectionChange"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<!-- 悬浮操作按钮 -->
|
||||
<button
|
||||
v-if="!selectionMode"
|
||||
class="fab"
|
||||
@click="handleAdd"
|
||||
>
|
||||
<BaseIcon name="plus" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseCard from '@/components/common/BaseCard.vue'
|
||||
import BaseIcon from '@/components/common/BaseIcon.vue'
|
||||
import TransactionList from '@/components/transaction/TransactionList.vue'
|
||||
import TransactionSearch from '@/components/transaction/TransactionSearch.vue'
|
||||
import TransactionFilter from '@/components/transaction/TransactionFilter.vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import type { TransactionWithDetails, TransactionFilters } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const databaseStore = useDatabaseStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const showSearch = ref(false)
|
||||
const showFilter = ref(false)
|
||||
const selectionMode = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedIds = ref<number[]>([])
|
||||
const filters = ref<TransactionFilters>({
|
||||
type: 'all',
|
||||
category_ids: [],
|
||||
account_ids: [],
|
||||
sync_status: 'all',
|
||||
source: 'all'
|
||||
})
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 计算属性
|
||||
const displayTransactions = computed(() => {
|
||||
let transactions = databaseStore.transactions
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
transactions = transactions.filter(t =>
|
||||
t.merchant.toLowerCase().includes(query) ||
|
||||
(t.description && t.description.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
// 应用筛选条件
|
||||
transactions = applyFilters(transactions, filters.value)
|
||||
|
||||
return transactions
|
||||
})
|
||||
|
||||
// 应用筛选条件
|
||||
function applyFilters(transactions: TransactionWithDetails[], filterOptions: TransactionFilters): TransactionWithDetails[] {
|
||||
return transactions.filter(transaction => {
|
||||
// 交易类型筛选
|
||||
if (filterOptions.type && filterOptions.type !== 'all') {
|
||||
if (transaction.type !== filterOptions.type) return false
|
||||
}
|
||||
|
||||
// 分类筛选
|
||||
if (filterOptions.category_ids && filterOptions.category_ids.length > 0) {
|
||||
if (!filterOptions.category_ids.includes(transaction.category_id)) return false
|
||||
}
|
||||
|
||||
// 账户筛选
|
||||
if (filterOptions.account_ids && filterOptions.account_ids.length > 0) {
|
||||
if (!filterOptions.account_ids.includes(transaction.account_id)) return false
|
||||
}
|
||||
|
||||
// 金额范围筛选
|
||||
if (filterOptions.amount_min !== undefined && transaction.amount < filterOptions.amount_min) {
|
||||
return false
|
||||
}
|
||||
if (filterOptions.amount_max !== undefined && transaction.amount > filterOptions.amount_max) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 日期范围筛选
|
||||
if (filterOptions.date_from && transaction.date < filterOptions.date_from) {
|
||||
return false
|
||||
}
|
||||
if (filterOptions.date_to && transaction.date > filterOptions.date_to) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 同步状态筛选
|
||||
if (filterOptions.sync_status && filterOptions.sync_status !== 'all') {
|
||||
if (transaction.sync_status !== filterOptions.sync_status) return false
|
||||
}
|
||||
|
||||
// 来源筛选
|
||||
if (filterOptions.source && filterOptions.source !== 'all') {
|
||||
if (transaction.source !== filterOptions.source) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return databaseStore.transactions.length >= currentPage.value * pageSize
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const transactions = displayTransactions.value
|
||||
const income = transactions
|
||||
.filter(t => t.type === 'income')
|
||||
.reduce((sum, t) => sum + t.amount, 0)
|
||||
|
||||
const expense = transactions
|
||||
.filter(t => t.type === 'expense')
|
||||
.reduce((sum, t) => sum + t.amount, 0)
|
||||
|
||||
return {
|
||||
totalIncome: income,
|
||||
totalExpense: expense,
|
||||
balance: income - expense
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
async function loadTransactions() {
|
||||
loading.value = true
|
||||
try {
|
||||
await databaseStore.refreshIfNeeded()
|
||||
} catch (error) {
|
||||
console.error('加载交易失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
|
||||
loadingMore.value = true
|
||||
try {
|
||||
currentPage.value++
|
||||
// 这里可以加载更多数据
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)) // 模拟加载
|
||||
} catch (error) {
|
||||
console.error('加载更多失败:', error)
|
||||
currentPage.value--
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
showSearch.value = !showSearch.value
|
||||
if (!showSearch.value) {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilter() {
|
||||
showFilter.value = !showFilter.value
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
selectionMode.value = !selectionMode.value
|
||||
if (!selectionMode.value) {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
searchQuery.value = query
|
||||
// 重置分页
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function handleQuickFilter(quickFilter: Partial<TransactionFilters>) {
|
||||
filters.value = { ...filters.value, ...quickFilter }
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function handleApplyFilter(appliedFilters: TransactionFilters) {
|
||||
filters.value = appliedFilters
|
||||
showFilter.value = false
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function handleTransactionClick(transaction: TransactionWithDetails) {
|
||||
console.log('点击交易:', transaction)
|
||||
// 这里可以导航到交易详情页面
|
||||
}
|
||||
|
||||
function handleEdit(transaction: TransactionWithDetails) {
|
||||
if (transaction.id) {
|
||||
router.push(`/transactions/${transaction.id}/edit`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(transaction: TransactionWithDetails) {
|
||||
if (!transaction.id) return
|
||||
|
||||
try {
|
||||
await databaseStore.deleteTransaction(transaction.id)
|
||||
console.log('删除交易成功')
|
||||
} catch (error) {
|
||||
console.error('删除交易失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete(ids: number[]) {
|
||||
try {
|
||||
for (const id of ids) {
|
||||
await databaseStore.deleteTransaction(id)
|
||||
}
|
||||
selectedIds.value = []
|
||||
selectionMode.value = false
|
||||
console.log('批量删除成功')
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
router.push('/transactions/add')
|
||||
}
|
||||
|
||||
function handleSelectionChange(ids: number[]) {
|
||||
selectedIds.value = ids
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadTransactions()
|
||||
})
|
||||
|
||||
// 监听搜索查询变化
|
||||
watch(searchQuery, () => {
|
||||
// 重置分页
|
||||
currentPage.value = 1
|
||||
})
|
||||
</script>
|
@@ -12,6 +12,16 @@ export default {
|
||||
DEFAULT: '#F97316', // 橙色
|
||||
light: '#FB923C', // 浅橙色
|
||||
dark: '#EA580C', // 深橙色
|
||||
50: '#FFF7ED',
|
||||
100: '#FFEDD5',
|
||||
200: '#FED7AA',
|
||||
300: '#FDBA74',
|
||||
400: '#FB923C',
|
||||
500: '#F97316',
|
||||
600: '#EA580C',
|
||||
700: '#C2410C',
|
||||
800: '#9A3412',
|
||||
900: '#7C2D12',
|
||||
},
|
||||
accent: {
|
||||
blue: '#3B82F6', // 蓝色 - 收入相关
|
||||
@@ -25,6 +35,12 @@ export default {
|
||||
secondary: '#666666', // 次要文本
|
||||
},
|
||||
border: '#E5E5E5', // 边框色
|
||||
|
||||
// 扩展的语义化颜色
|
||||
success: '#22C55E',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444',
|
||||
info: '#3B82F6',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'Poppins', 'system-ui', 'sans-serif'],
|
||||
|
Reference in New Issue
Block a user