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:
2025-08-14 16:03:02 +08:00
parent 0920667719
commit 4ea91a0f59
18 changed files with 3296 additions and 21 deletions

View File

@@ -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 组件,支持文本搜索和多维度筛选
- 实现前端搜索逻辑,支持按商户名、备注、分类、账户筛选
- 开发筛选结果的实时更新和状态管理

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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',

View 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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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'],