diff --git a/.kiro/specs/personal-billing-app/tasks.md b/.kiro/specs/personal-billing-app/tasks.md index d5ae1d0..9575587 100644 --- a/.kiro/specs/personal-billing-app/tasks.md +++ b/.kiro/specs/personal-billing-app/tasks.md @@ -9,6 +9,7 @@ - [x] 1. 建立项目版本管理和基础架构 + - [x] 1.1 初始化 Git 版本控制 @@ -34,20 +35,27 @@ - 创建本地数据访问层和基础 CRUD 操作 - _需求: 7.1, 7.2_ -- [ ] 2. 实现本地数据模型和离线功能 - - [ ] 2.1 创建本地数据模型 +- [x] 2. 实现本地数据模型和离线功能 + + - [x] 2.1 创建本地数据模型 + + - 设计本地 SQLite 数据库表结构(交易、分类、账户) - 实现本地数据模型的 TypeScript 接口定义 - 创建数据验证和格式化工具函数 - _需求: 2.1, 8.1, 8.3_ - - [ ] 2.2 实现本地数据 CRUD 操作 + - [x] 2.2 实现本地数据 CRUD 操作 + + - 开发本地交易数据的增删改查功能 - 实现分类和账户的本地管理功能 - 创建数据迁移和版本管理机制 - _需求: 2.2, 2.3, 2.4, 8.2, 8.4_ - - [ ] 2.3 建立离线优先的数据架构 + - [x] 2.3 建立离线优先的数据架构 + + - 实现本地数据的状态管理(Pinia store) - 创建离线模式的用户界面和提示 - 建立数据同步标记和冲突检测机制 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c4e1d6b..1e8a203 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,14 @@ diff --git a/frontend/src/components/common/OfflineIndicator.vue b/frontend/src/components/common/OfflineIndicator.vue new file mode 100644 index 0000000..e7d6f56 --- /dev/null +++ b/frontend/src/components/common/OfflineIndicator.vue @@ -0,0 +1,207 @@ + + + \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 464e08d..99d8de5 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -11,9 +11,17 @@ const pinia = createPinia() app.use(pinia) app.use(router) -// 初始化数据库 +// 初始化数据库和离线功能 import { useDatabaseStore } from './stores/database' +import { useOfflineStore } from './stores/offline' + const databaseStore = useDatabaseStore() +const offlineStore = useOfflineStore() + +// 初始化数据库 databaseStore.initialize().catch(console.error) +// 设置网络监听 +offlineStore.setupNetworkListeners() + app.mount('#app') diff --git a/frontend/src/repositories/AccountRepository.ts b/frontend/src/repositories/AccountRepository.ts new file mode 100644 index 0000000..2634864 --- /dev/null +++ b/frontend/src/repositories/AccountRepository.ts @@ -0,0 +1,254 @@ +import { databaseService, type Account } from '@/services/database' +import { validateAccount, DataSanitizer } from '@/utils/validation' + +export class AccountRepository { + // 创建账户 + async create(account: Omit): Promise { + // 数据验证 + const errors = validateAccount(account) + if (errors.length > 0) { + throw new Error(`数据验证失败: ${errors.join(', ')}`) + } + + // 检查名称是否重复 + const existing = await this.findByName(account.name) + if (existing) { + throw new Error('账户名称已存在') + } + + // 数据清理 + const sanitizedAccount = DataSanitizer.sanitizeAccount(account) + + return await databaseService.createAccount(sanitizedAccount as Omit) + } + + // 获取所有账户 + async findAll(): Promise { + return await databaseService.getAccounts() + } + + // 根据类型获取账户 + async findByType(type: 'cash' | 'alipay' | 'wechat' | 'bank'): Promise { + const accounts = await this.findAll() + return accounts.filter(a => a.type === type) + } + + // 根据名称查找账户 + async findByName(name: string): Promise { + const accounts = await this.findAll() + return accounts.find(a => a.name.toLowerCase() === name.toLowerCase()) || null + } + + // 根据ID查找账户 + async findById(id: number): Promise { + const accounts = await this.findAll() + return accounts.find(a => a.id === id) || null + } + + // 更新账户 + async update(id: number, updates: Partial): Promise { + // 数据验证 + const errors = validateAccount(updates) + if (errors.length > 0) { + throw new Error(`数据验证失败: ${errors.join(', ')}`) + } + + // 检查名称是否重复(排除当前账户) + if (updates.name) { + const existing = await this.findByName(updates.name) + if (existing && existing.id !== id) { + throw new Error('账户名称已存在') + } + } + + // 数据清理 + const sanitizedUpdates = DataSanitizer.sanitizeAccount(updates) + + await databaseService.updateAccount(id, sanitizedUpdates) + } + + // 删除账户 + async delete(id: number): Promise { + // 检查是否为默认账户 + const account = await this.findById(id) + if (account?.is_default) { + throw new Error('无法删除默认账户') + } + + await databaseService.deleteAccount(id) + } + + // 获取默认账户 + async getDefaults(): Promise { + const accounts = await this.findAll() + return accounts.filter(a => a.is_default) + } + + // 获取主要默认账户 + async getPrimaryDefault(): Promise { + const defaults = await this.getDefaults() + return defaults.find(a => a.type === 'alipay') || defaults[0] || null + } + + // 获取自定义账户 + async getCustom(): Promise { + const accounts = await this.findAll() + return accounts.filter(a => !a.is_default) + } + + // 设置默认账户 + async setAsDefault(id: number): Promise { + await this.update(id, { is_default: true }) + } + + // 取消默认账户 + async unsetAsDefault(id: number): Promise { + await this.update(id, { is_default: false }) + } + + // 获取账户统计 + async getStats(startDate?: string, endDate?: string) { + return await databaseService.getAccountStats(startDate, endDate) + } + + // 获取账户余额 + async getBalance(id: number, startDate?: string, endDate?: string): Promise { + const stats = await this.getStats(startDate, endDate) + const accountStats = stats.find(s => s.account_id === id) + return accountStats?.balance || 0 + } + + // 获取所有账户余额 + async getAllBalances(startDate?: string, endDate?: string): Promise<{ [accountId: number]: number }> { + const stats = await this.getStats(startDate, endDate) + const balances: { [accountId: number]: number } = {} + + stats.forEach(stat => { + balances[stat.account_id] = stat.balance + }) + + return balances + } + + // 获取使用频率最高的账户 + async getMostUsed(limit = 5): Promise { + const stats = await this.getStats() + return stats + .sort((a, b) => b.transaction_count - a.transaction_count) + .slice(0, limit) + } + + // 检查账户是否被使用 + async isInUse(id: number): Promise { + try { + await databaseService.deleteAccount(id) + return false + } catch (error) { + return true + } + } + + // 获取账户的交易数量 + async getTransactionCount(id: number): Promise { + const stats = await this.getStats() + const accountStats = stats.find(s => s.account_id === id) + return accountStats?.transaction_count || 0 + } + + // 批量创建账户 + async batchCreate(accounts: Omit[]): Promise { + const ids: number[] = [] + + for (const account of accounts) { + const id = await this.create(account) + ids.push(id) + } + + return ids + } + + // 重置为默认账户 + async resetToDefaults(): Promise { + // 删除所有自定义账户 + const customAccounts = await this.getCustom() + for (const account of customAccounts) { + try { + await this.delete(account.id!) + } catch (error) { + console.warn(`无法删除账户 ${account.name}:`, error) + } + } + + // 重新插入默认数据 + await databaseService.insertDefaultData() + } + + // 根据支付应用自动匹配账户 + async findByPaymentApp(appName: string): Promise { + const lowerAppName = appName.toLowerCase() + + if (lowerAppName.includes('支付宝') || lowerAppName.includes('alipay')) { + return await this.findByType('alipay').then(accounts => accounts[0] || null) + } + + if (lowerAppName.includes('微信') || lowerAppName.includes('wechat')) { + return await this.findByType('wechat').then(accounts => accounts[0] || null) + } + + if (lowerAppName.includes('银行') || lowerAppName.includes('bank')) { + return await this.findByType('bank').then(accounts => accounts[0] || null) + } + + // 默认返回现金账户 + return await this.findByType('cash').then(accounts => accounts[0] || null) + } + + // 获取账户类型分布 + async getTypeDistribution(): Promise<{ [type: string]: number }> { + const accounts = await this.findAll() + const distribution: { [type: string]: number } = { + cash: 0, + alipay: 0, + wechat: 0, + bank: 0 + } + + accounts.forEach(account => { + distribution[account.type] = (distribution[account.type] || 0) + 1 + }) + + return distribution + } + + // 导出账户数据 + async export(): Promise { + return await this.findAll() + } + + // 导入账户数据 + async import(accounts: Omit[]): Promise { + const ids: number[] = [] + + for (const account of accounts) { + try { + // 检查是否已存在 + const existing = await this.findByName(account.name) + if (!existing) { + const id = await this.create(account) + ids.push(id) + } + } catch (error) { + console.warn(`导入账户 ${account.name} 失败:`, error) + } + } + + return ids + } + + // 合并账户(将一个账户的所有交易转移到另一个账户) + async merge(fromAccountId: number, toAccountId: number): Promise { + // 这个功能需要在 TransactionRepository 中实现 + // 这里只是一个占位符 + throw new Error('账户合并功能需要在 TransactionRepository 中实现') + } +} \ No newline at end of file diff --git a/frontend/src/repositories/CategoryRepository.ts b/frontend/src/repositories/CategoryRepository.ts new file mode 100644 index 0000000..7de5e10 --- /dev/null +++ b/frontend/src/repositories/CategoryRepository.ts @@ -0,0 +1,192 @@ +import { databaseService, type Category } from '@/services/database' +import { validateCategory, DataSanitizer } from '@/utils/validation' + +export class CategoryRepository { + // 创建分类 + async create(category: Omit): Promise { + // 数据验证 + const errors = validateCategory(category) + if (errors.length > 0) { + throw new Error(`数据验证失败: ${errors.join(', ')}`) + } + + // 检查名称是否重复 + const existing = await this.findByName(category.name) + if (existing) { + throw new Error('分类名称已存在') + } + + // 数据清理 + const sanitizedCategory = DataSanitizer.sanitizeCategory(category) + + return await databaseService.createCategory(sanitizedCategory as Omit) + } + + // 获取所有分类 + async findAll(): Promise { + return await databaseService.getCategories() + } + + // 根据类型获取分类 + async findByType(type: 'income' | 'expense'): Promise { + return await databaseService.getCategoriesByType(type) + } + + // 根据名称查找分类 + async findByName(name: string): Promise { + const categories = await this.findAll() + return categories.find(c => c.name.toLowerCase() === name.toLowerCase()) || null + } + + // 根据ID查找分类 + async findById(id: number): Promise { + const categories = await this.findAll() + return categories.find(c => c.id === id) || null + } + + // 更新分类 + async update(id: number, updates: Partial): Promise { + // 数据验证 + const errors = validateCategory(updates) + if (errors.length > 0) { + throw new Error(`数据验证失败: ${errors.join(', ')}`) + } + + // 检查名称是否重复(排除当前分类) + if (updates.name) { + const existing = await this.findByName(updates.name) + if (existing && existing.id !== id) { + throw new Error('分类名称已存在') + } + } + + // 数据清理 + const sanitizedUpdates = DataSanitizer.sanitizeCategory(updates) + + await databaseService.updateCategory(id, sanitizedUpdates) + } + + // 删除分类 + async delete(id: number): Promise { + // 检查是否为默认分类 + const category = await this.findById(id) + if (category?.is_default) { + throw new Error('无法删除默认分类') + } + + await databaseService.deleteCategory(id) + } + + // 获取默认分类 + async getDefaults(): Promise { + const categories = await this.findAll() + return categories.filter(c => c.is_default) + } + + // 获取自定义分类 + async getCustom(): Promise { + const categories = await this.findAll() + return categories.filter(c => !c.is_default) + } + + // 设置默认分类 + async setAsDefault(id: number): Promise { + await this.update(id, { is_default: true }) + } + + // 取消默认分类 + async unsetAsDefault(id: number): Promise { + await this.update(id, { is_default: false }) + } + + // 获取分类统计 + async getStats(startDate?: string, endDate?: string) { + return await databaseService.getCategoryStats(startDate, endDate) + } + + // 获取使用频率最高的分类 + async getMostUsed(type?: 'income' | 'expense', limit = 5): Promise { + const stats = await this.getStats() + let filteredStats = stats + + if (type) { + const categoriesOfType = await this.findByType(type) + const categoryIds = categoriesOfType.map(c => c.id) + filteredStats = stats.filter(s => categoryIds.includes(s.category_id)) + } + + return filteredStats + .sort((a, b) => b.transaction_count - a.transaction_count) + .slice(0, limit) + } + + // 检查分类是否被使用 + async isInUse(id: number): Promise { + try { + await databaseService.deleteCategory(id) + return false + } catch (error) { + return true + } + } + + // 获取分类的交易数量 + async getTransactionCount(id: number): Promise { + const stats = await this.getStats() + const categoryStats = stats.find(s => s.category_id === id) + return categoryStats?.transaction_count || 0 + } + + // 批量创建分类 + async batchCreate(categories: Omit[]): Promise { + const ids: number[] = [] + + for (const category of categories) { + const id = await this.create(category) + ids.push(id) + } + + return ids + } + + // 重置为默认分类 + async resetToDefaults(): Promise { + // 删除所有自定义分类 + const customCategories = await this.getCustom() + for (const category of customCategories) { + try { + await this.delete(category.id!) + } catch (error) { + console.warn(`无法删除分类 ${category.name}:`, error) + } + } + + // 重新插入默认数据 + await databaseService.insertDefaultData() + } + + // 导出分类数据 + async export(): Promise { + return await this.findAll() + } + + // 导入分类数据 + async import(categories: Omit[]): Promise { + const ids: number[] = [] + + for (const category of categories) { + try { + // 检查是否已存在 + const existing = await this.findByName(category.name) + if (!existing) { + const id = await this.create(category) + ids.push(id) + } + } catch (error) { + console.warn(`导入分类 ${category.name} 失败:`, error) + } + } + + return ids + } +} \ No newline at end of file diff --git a/frontend/src/repositories/TransactionRepository.ts b/frontend/src/repositories/TransactionRepository.ts new file mode 100644 index 0000000..e696d08 --- /dev/null +++ b/frontend/src/repositories/TransactionRepository.ts @@ -0,0 +1,227 @@ +import { databaseService, type Transaction } from '@/services/database' +import type { TransactionFilters, PaginationParams, TransactionWithDetails } from '@/types' +import { validateTransaction, DataSanitizer } from '@/utils/validation' + +export class TransactionRepository { + // 创建交易 + async create(transaction: Omit): Promise { + // 数据验证 + const errors = validateTransaction(transaction) + if (errors.length > 0) { + throw new Error(`数据验证失败: ${errors.join(', ')}`) + } + + // 数据清理 + const sanitizedTransaction = DataSanitizer.sanitizeTransaction(transaction) + + return await databaseService.createTransaction(sanitizedTransaction as Omit) + } + + // 获取交易列表 + async findAll(pagination: PaginationParams = { page: 1, limit: 20, offset: 0 }): Promise { + const { limit, offset } = pagination + return await databaseService.getTransactions(limit, offset) as TransactionWithDetails[] + } + + // 根据ID获取交易 + async findById(id: number): Promise { + return await databaseService.getTransactionById(id) as TransactionWithDetails | null + } + + // 更新交易 + async update(id: number, updates: Partial): Promise { + // 数据验证(只验证提供的字段) + const errors = validateTransaction(updates) + if (errors.length > 0) { + throw new Error(`数据验证失败: ${errors.join(', ')}`) + } + + // 数据清理 + const sanitizedUpdates = DataSanitizer.sanitizeTransaction(updates) + + await databaseService.updateTransaction(id, sanitizedUpdates) + } + + // 删除交易 + async delete(id: number): Promise { + await databaseService.deleteTransaction(id) + } + + // 批量删除 + async batchDelete(ids: number[]): Promise { + await databaseService.batchDeleteTransactions(ids) + } + + // 搜索交易 + async search( + query: string, + pagination: PaginationParams = { page: 1, limit: 20, offset: 0 } + ): Promise { + const { limit, offset } = pagination + return await databaseService.searchTransactions(query, limit, offset) as TransactionWithDetails[] + } + + // 高级筛选 + async findWithFilters( + filters: TransactionFilters, + pagination: PaginationParams = { page: 1, limit: 20, offset: 0 } + ): Promise { + const { limit, offset } = pagination + let transactions: TransactionWithDetails[] = [] + + // 根据不同的筛选条件调用相应的方法 + if (filters.search) { + transactions = await this.search(filters.search, pagination) + } else if (filters.date_from && filters.date_to) { + transactions = await databaseService.getTransactionsByDateRange( + filters.date_from, + filters.date_to, + limit, + offset + ) as TransactionWithDetails[] + } else if (filters.category_ids && filters.category_ids.length === 1) { + transactions = await databaseService.getTransactionsByCategory( + filters.category_ids[0], + limit, + offset + ) as TransactionWithDetails[] + } else if (filters.account_ids && filters.account_ids.length === 1) { + transactions = await databaseService.getTransactionsByAccount( + filters.account_ids[0], + limit, + offset + ) as TransactionWithDetails[] + } else if (filters.type && filters.type !== 'all') { + transactions = await databaseService.getTransactionsByType( + filters.type, + limit, + offset + ) as TransactionWithDetails[] + } else { + transactions = await this.findAll(pagination) + } + + // 在内存中应用其他筛选条件 + return this.applyInMemoryFilters(transactions, filters) + } + + // 在内存中应用筛选条件 + private applyInMemoryFilters( + transactions: TransactionWithDetails[], + filters: TransactionFilters + ): TransactionWithDetails[] { + return transactions.filter(transaction => { + // 金额范围筛选 + if (filters.amount_min !== undefined && transaction.amount < filters.amount_min) { + return false + } + if (filters.amount_max !== undefined && transaction.amount > filters.amount_max) { + return false + } + + // 多分类筛选 + if (filters.category_ids && filters.category_ids.length > 1) { + if (!filters.category_ids.includes(transaction.category_id)) { + return false + } + } + + // 多账户筛选 + if (filters.account_ids && filters.account_ids.length > 1) { + if (!filters.account_ids.includes(transaction.account_id)) { + return false + } + } + + // 同步状态筛选 + if (filters.sync_status && filters.sync_status !== 'all') { + if (transaction.sync_status !== filters.sync_status) { + return false + } + } + + // 来源筛选 + if (filters.source && filters.source !== 'all') { + if (transaction.source !== filters.source) { + return false + } + } + + return true + }) + } + + // 获取统计数据 + async getStats(startDate?: string, endDate?: string) { + return await databaseService.getTransactionStats(startDate, endDate) + } + + // 获取最近的交易 + async getRecent(limit = 10): Promise { + return await databaseService.getTransactions(limit, 0) as TransactionWithDetails[] + } + + // 获取今日交易 + async getToday(): Promise { + const today = new Date().toISOString().split('T')[0] + return await databaseService.getTransactionsByDateRange(today, today, 100, 0) as TransactionWithDetails[] + } + + // 获取本月交易 + async getThisMonth(): Promise { + const now = new Date() + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0] + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split('T')[0] + + return await databaseService.getTransactionsByDateRange(startOfMonth, endOfMonth, 1000, 0) as TransactionWithDetails[] + } + + // 获取待同步的交易 + async getPendingSync(): Promise { + const allTransactions = await databaseService.getTransactions(10000, 0) as TransactionWithDetails[] + return allTransactions.filter(t => t.sync_status === 'pending') + } + + // 标记为已同步 + async markAsSynced(ids: number[]): Promise { + const updates = ids.map(id => ({ + id, + data: { sync_status: 'synced' as const } + })) + await databaseService.batchUpdateTransactions(updates) + } + + // 标记为冲突 + async markAsConflict(ids: number[]): Promise { + const updates = ids.map(id => ({ + id, + data: { sync_status: 'conflict' as const } + })) + await databaseService.batchUpdateTransactions(updates) + } + + // 获取交易数量 + async count(filters?: TransactionFilters): Promise { + if (!filters) { + const stats = await this.getStats() + return stats.transactionCount + } + + // 对于有筛选条件的情况,需要先获取数据再计数 + const transactions = await this.findWithFilters(filters, { page: 1, limit: 10000, offset: 0 }) + return transactions.length + } + + // 检查是否存在重复交易 + async findDuplicates(transaction: Omit): Promise { + const allTransactions = await databaseService.getTransactions(1000, 0) as TransactionWithDetails[] + + return allTransactions.filter(t => + t.amount === transaction.amount && + t.type === transaction.type && + t.merchant === transaction.merchant && + t.date === transaction.date && + Math.abs(new Date(t.created_at || '').getTime() - Date.now()) < 5 * 60 * 1000 // 5分钟内 + ) + } +} \ No newline at end of file diff --git a/frontend/src/repositories/index.ts b/frontend/src/repositories/index.ts new file mode 100644 index 0000000..208615e --- /dev/null +++ b/frontend/src/repositories/index.ts @@ -0,0 +1,173 @@ +// Repository 模式统一导出 +export { TransactionRepository } from './TransactionRepository' +export { CategoryRepository } from './CategoryRepository' +export { AccountRepository } from './AccountRepository' + +// 创建单例实例 +export const transactionRepository = new TransactionRepository() +export const categoryRepository = new CategoryRepository() +export const accountRepository = new AccountRepository() + +// Repository 管理器 +export class RepositoryManager { + private static instance: RepositoryManager + + public readonly transaction: TransactionRepository + public readonly category: CategoryRepository + public readonly account: AccountRepository + + private constructor() { + this.transaction = transactionRepository + this.category = categoryRepository + this.account = accountRepository + } + + public static getInstance(): RepositoryManager { + if (!RepositoryManager.instance) { + RepositoryManager.instance = new RepositoryManager() + } + return RepositoryManager.instance + } + + // 批量操作 + async batchOperation(operations: (() => Promise)[]): Promise { + const results: T[] = [] + + for (const operation of operations) { + try { + const result = await operation() + results.push(result) + } catch (error) { + console.error('批量操作失败:', error) + throw error + } + } + + return results + } + + // 事务操作(模拟) + async transaction(callback: (repos: RepositoryManager) => Promise): Promise { + try { + return await callback(this) + } catch (error) { + // 这里可以添加回滚逻辑 + console.error('事务操作失败:', error) + throw error + } + } + + // 数据完整性检查 + async checkDataIntegrity(): Promise<{ + valid: boolean + errors: string[] + }> { + const errors: string[] = [] + + try { + // 检查是否有孤立的交易(引用不存在的分类或账户) + const transactions = await this.transaction.findAll({ page: 1, limit: 10000, offset: 0 }) + const categories = await this.category.findAll() + const accounts = await this.account.findAll() + + const categoryIds = new Set(categories.map(c => c.id)) + const accountIds = new Set(accounts.map(a => a.id)) + + for (const transaction of transactions) { + if (!categoryIds.has(transaction.category_id)) { + errors.push(`交易 ${transaction.id} 引用了不存在的分类 ${transaction.category_id}`) + } + if (!accountIds.has(transaction.account_id)) { + errors.push(`交易 ${transaction.id} 引用了不存在的账户 ${transaction.account_id}`) + } + } + + // 检查是否有默认分类和账户 + const defaultCategories = await this.category.getDefaults() + const defaultAccounts = await this.account.getDefaults() + + if (defaultCategories.length === 0) { + errors.push('没有找到默认分类') + } + if (defaultAccounts.length === 0) { + errors.push('没有找到默认账户') + } + + } catch (error) { + errors.push(`数据完整性检查失败: ${error}`) + } + + return { + valid: errors.length === 0, + errors + } + } + + // 数据统计概览 + async getOverview() { + try { + const [ + transactionStats, + categoryStats, + accountStats, + categories, + accounts + ] = await Promise.all([ + this.transaction.getStats(), + this.category.getStats(), + this.account.getStats(), + this.category.findAll(), + this.account.findAll() + ]) + + return { + transactions: { + total: transactionStats.transactionCount, + totalIncome: transactionStats.totalIncome, + totalExpense: transactionStats.totalExpense, + balance: transactionStats.balance + }, + categories: { + total: categories.length, + defaults: categories.filter(c => c.is_default).length, + custom: categories.filter(c => !c.is_default).length, + mostUsed: categoryStats.slice(0, 3) + }, + accounts: { + total: accounts.length, + defaults: accounts.filter(a => a.is_default).length, + custom: accounts.filter(a => !a.is_default).length, + balances: accountStats + } + } + } catch (error) { + console.error('获取数据概览失败:', error) + throw error + } + } + + // 清理和维护 + async maintenance() { + try { + // 检查数据完整性 + const integrityCheck = await this.checkDataIntegrity() + if (!integrityCheck.valid) { + console.warn('数据完整性问题:', integrityCheck.errors) + } + + // 清理旧数据(可选) + // await databaseService.cleanupOldData(365) + + return { + integrityCheck, + message: '数据维护完成' + } + } catch (error) { + console.error('数据维护失败:', error) + throw error + } + } +} + +// 导出单例实例 +export const repositoryManager = RepositoryManager.getInstance() \ No newline at end of file diff --git a/frontend/src/services/database.ts b/frontend/src/services/database.ts index 331cc55..92ac270 100644 --- a/frontend/src/services/database.ts +++ b/frontend/src/services/database.ts @@ -271,6 +271,385 @@ class DatabaseService { return result.changes?.lastId || 0; } + // 扩展的分类 CRUD 操作 + async updateCategory(id: number, category: Partial): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const fields = Object.keys(category).filter(key => key !== 'id'); + const values = fields.map(key => { + const value = category[key as keyof Category]; + return key === 'is_default' ? (value ? 1 : 0) : value; + }); + const setClause = fields.map(field => `${field} = ?`).join(', '); + + await this.db.run( + `UPDATE categories SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [...values, id] + ); + } + + async deleteCategory(id: number): Promise { + if (!this.db) throw new Error('Database not initialized'); + + // 检查是否有交易使用此分类 + const transactionCount = await this.db.query( + 'SELECT COUNT(*) as count FROM transactions WHERE category_id = ?', + [id] + ); + + if (transactionCount.values && transactionCount.values[0].count > 0) { + throw new Error('无法删除已被使用的分类'); + } + + await this.db.run('DELETE FROM categories WHERE id = ?', [id]); + } + + async getCategoriesByType(type: 'income' | 'expense'): Promise { + if (!this.db) throw new Error('Database not initialized'); + const result = await this.db.query( + 'SELECT * FROM categories WHERE type = ? OR type = "both" ORDER BY is_default DESC, name ASC', + [type] + ); + return result.values || []; + } + + // 扩展的账户 CRUD 操作 + async updateAccount(id: number, account: Partial): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const fields = Object.keys(account).filter(key => key !== 'id'); + const values = fields.map(key => { + const value = account[key as keyof Account]; + return key === 'is_default' ? (value ? 1 : 0) : value; + }); + const setClause = fields.map(field => `${field} = ?`).join(', '); + + await this.db.run( + `UPDATE accounts SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [...values, id] + ); + } + + async deleteAccount(id: number): Promise { + if (!this.db) throw new Error('Database not initialized'); + + // 检查是否有交易使用此账户 + const transactionCount = await this.db.query( + 'SELECT COUNT(*) as count FROM transactions WHERE account_id = ?', + [id] + ); + + if (transactionCount.values && transactionCount.values[0].count > 0) { + throw new Error('无法删除已被使用的账户'); + } + + await this.db.run('DELETE FROM accounts WHERE id = ?', [id]); + } + + // 扩展的交易查询操作 + async getTransactionById(id: number): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const result = await this.db.query( + `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color, + a.name as account_name, a.icon as account_icon, a.color as account_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.id = ?`, + [id] + ); + + return result.values && result.values.length > 0 ? result.values[0] : null; + } + + async searchTransactions(query: string, limit = 50, offset = 0): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const searchTerm = `%${query.toLowerCase()}%`; + const result = await this.db.query( + `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color, + a.name as account_name, a.icon as account_icon, a.color as account_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE LOWER(t.merchant) LIKE ? OR LOWER(t.description) LIKE ? + ORDER BY t.date DESC, t.created_at DESC + LIMIT ? OFFSET ?`, + [searchTerm, searchTerm, limit, offset] + ); + + return result.values || []; + } + + async getTransactionsByDateRange(startDate: string, endDate: string, limit = 50, offset = 0): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const result = await this.db.query( + `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color, + a.name as account_name, a.icon as account_icon, a.color as account_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.date BETWEEN ? AND ? + ORDER BY t.date DESC, t.created_at DESC + LIMIT ? OFFSET ?`, + [startDate, endDate, limit, offset] + ); + + return result.values || []; + } + + async getTransactionsByCategory(categoryId: number, limit = 50, offset = 0): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const result = await this.db.query( + `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color, + a.name as account_name, a.icon as account_icon, a.color as account_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.category_id = ? + ORDER BY t.date DESC, t.created_at DESC + LIMIT ? OFFSET ?`, + [categoryId, limit, offset] + ); + + return result.values || []; + } + + async getTransactionsByAccount(accountId: number, limit = 50, offset = 0): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const result = await this.db.query( + `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color, + a.name as account_name, a.icon as account_icon, a.color as account_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.account_id = ? + ORDER BY t.date DESC, t.created_at DESC + LIMIT ? OFFSET ?`, + [accountId, limit, offset] + ); + + return result.values || []; + } + + async getTransactionsByType(type: 'income' | 'expense', limit = 50, offset = 0): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const result = await this.db.query( + `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color, + a.name as account_name, a.icon as account_icon, a.color as account_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.type = ? + ORDER BY t.date DESC, t.created_at DESC + LIMIT ? OFFSET ?`, + [type, limit, offset] + ); + + return result.values || []; + } + + // 统计查询 + async getTransactionStats(startDate?: string, endDate?: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + + let whereClause = ''; + const params: any[] = []; + + if (startDate && endDate) { + whereClause = 'WHERE date BETWEEN ? AND ?'; + params.push(startDate, endDate); + } + + const result = await this.db.query( + `SELECT + SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as total_income, + SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as total_expense, + COUNT(*) as transaction_count, + AVG(amount) as average_amount + FROM transactions ${whereClause}`, + params + ); + + const stats = result.values?.[0] || {}; + return { + totalIncome: stats.total_income || 0, + totalExpense: stats.total_expense || 0, + balance: (stats.total_income || 0) - (stats.total_expense || 0), + transactionCount: stats.transaction_count || 0, + averageAmount: stats.average_amount || 0 + }; + } + + async getCategoryStats(startDate?: string, endDate?: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + + let whereClause = ''; + const params: any[] = []; + + if (startDate && endDate) { + whereClause = 'WHERE t.date BETWEEN ? AND ?'; + params.push(startDate, endDate); + } + + const result = await this.db.query( + `SELECT + c.id as category_id, + c.name as category_name, + c.icon as category_icon, + c.color as category_color, + SUM(t.amount) as total_amount, + COUNT(t.id) as transaction_count + FROM categories c + LEFT JOIN transactions t ON c.id = t.category_id ${whereClause} + GROUP BY c.id, c.name, c.icon, c.color + HAVING total_amount > 0 + ORDER BY total_amount DESC`, + params + ); + + return result.values || []; + } + + async getAccountStats(startDate?: string, endDate?: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + + let whereClause = ''; + const params: any[] = []; + + if (startDate && endDate) { + whereClause = 'WHERE t.date BETWEEN ? AND ?'; + params.push(startDate, endDate); + } + + const result = await this.db.query( + `SELECT + a.id as account_id, + a.name as account_name, + a.icon as account_icon, + a.color as account_color, + SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE -t.amount END) as balance, + COUNT(t.id) as transaction_count + FROM accounts a + LEFT JOIN transactions t ON a.id = t.account_id ${whereClause} + GROUP BY a.id, a.name, a.icon, a.color + ORDER BY balance DESC`, + params + ); + + return result.values || []; + } + + // 数据迁移和版本管理 + async getDatabaseVersion(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + try { + const result = await this.db.query('PRAGMA user_version'); + return result.values?.[0]?.user_version || 0; + } catch (error) { + return 0; + } + } + + async setDatabaseVersion(version: number): Promise { + if (!this.db) throw new Error('Database not initialized'); + await this.db.execute(`PRAGMA user_version = ${version}`); + } + + async migrateDatabase(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const currentVersion = await this.getDatabaseVersion(); + const targetVersion = 1; // 当前目标版本 + + if (currentVersion < targetVersion) { + console.log(`Migrating database from version ${currentVersion} to ${targetVersion}`); + + // 这里可以添加具体的迁移逻辑 + // 例如:添加新字段、创建新表等 + + await this.setDatabaseVersion(targetVersion); + console.log('Database migration completed'); + } + } + + // 批量操作 + async batchCreateTransactions(transactions: Omit[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const ids: number[] = []; + + for (const transaction of transactions) { + const id = await this.createTransaction(transaction); + ids.push(id); + } + + return ids; + } + + async batchUpdateTransactions(updates: { id: number; data: Partial }[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + for (const update of updates) { + await this.updateTransaction(update.id, update.data); + } + } + + async batchDeleteTransactions(ids: number[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const placeholders = ids.map(() => '?').join(','); + await this.db.run(`DELETE FROM transactions WHERE id IN (${placeholders})`, ids); + } + + // 数据清理 + async cleanupOldData(daysToKeep = 365): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + const cutoffDateString = cutoffDate.toISOString().split('T')[0]; + + await this.db.run( + 'DELETE FROM transactions WHERE date < ? AND sync_status = "synced"', + [cutoffDateString] + ); + } + + // 数据备份和恢复 + async exportData(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const [transactions, categories, accounts] = await Promise.all([ + this.getTransactions(10000, 0), // 导出所有交易 + this.getCategories(), + this.getAccounts() + ]); + + return { + version: '1.0', + exportDate: new Date().toISOString(), + transactions, + categories, + accounts + }; + } + + async importData(data: any): Promise { + if (!this.db) throw new Error('Database not initialized'); + + // 这里应该包含数据验证和导入逻辑 + // 为了安全起见,暂时只记录日志 + console.log('Import data:', data); + throw new Error('Import functionality not implemented yet'); + } + async close(): Promise { if (this.db) { await this.db.close(); diff --git a/frontend/src/stores/database.ts b/frontend/src/stores/database.ts index b5624e7..6b260b5 100644 --- a/frontend/src/stores/database.ts +++ b/frontend/src/stores/database.ts @@ -1,17 +1,101 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' -import { databaseService, type Transaction, type Category, type Account } from '@/services/database' +import { ref, computed } from 'vue' +import { databaseService } from '@/services/database' +import { repositoryManager } from '@/repositories' +import type { + Transaction, + Category, + Account, + TransactionWithDetails, + TransactionFilters, + SyncStatus +} from '@/types' export const useDatabaseStore = defineStore('database', () => { + // 基础状态 const isInitialized = ref(false) const isLoading = ref(false) const error = ref(null) // 数据状态 - const transactions = ref([]) + const transactions = ref([]) const categories = ref([]) const accounts = ref([]) + // 离线状态 + const isOnline = ref(navigator.onLine) + const syncStatus = ref({ + isOnline: navigator.onLine, + pendingChanges: 0, + syncInProgress: false + }) + + // 缓存状态 + const lastRefreshTime = ref(null) + const cacheExpiry = 5 * 60 * 1000 // 5分钟缓存 + + // 计算属性 + const pendingTransactions = computed(() => + transactions.value.filter(t => t.sync_status === 'pending') + ) + + const conflictTransactions = computed(() => + transactions.value.filter(t => t.sync_status === 'conflict') + ) + + const totalBalance = computed(() => { + const income = transactions.value + .filter(t => t.type === 'income') + .reduce((sum, t) => sum + t.amount, 0) + + const expense = transactions.value + .filter(t => t.type === 'expense') + .reduce((sum, t) => sum + t.amount, 0) + + return income - expense + }) + + const monthlyStats = computed(() => { + const now = new Date() + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0) + + const monthlyTransactions = transactions.value.filter(t => { + const transactionDate = new Date(t.date) + return transactionDate >= startOfMonth && transactionDate <= endOfMonth + }) + + const income = monthlyTransactions + .filter(t => t.type === 'income') + .reduce((sum, t) => sum + t.amount, 0) + + const expense = monthlyTransactions + .filter(t => t.type === 'expense') + .reduce((sum, t) => sum + t.amount, 0) + + return { + income, + expense, + balance: income - expense, + transactionCount: monthlyTransactions.length + } + }) + + // 网络状态监听 + function setupNetworkListeners() { + window.addEventListener('online', () => { + isOnline.value = true + syncStatus.value.isOnline = true + // 自动同步待处理的更改 + syncPendingChanges() + }) + + window.addEventListener('offline', () => { + isOnline.value = false + syncStatus.value.isOnline = false + }) + } + // 初始化数据库 async function initialize() { if (isInitialized.value) return @@ -22,6 +106,8 @@ export const useDatabaseStore = defineStore('database', () => { try { await databaseService.initialize() await loadInitialData() + setupNetworkListeners() + updateSyncStatus() isInitialized.value = true console.log('Database store initialized successfully') } catch (err) { @@ -36,26 +122,58 @@ export const useDatabaseStore = defineStore('database', () => { async function loadInitialData() { try { const [transactionsData, categoriesData, accountsData] = await Promise.all([ - databaseService.getTransactions(20, 0), - databaseService.getCategories(), - databaseService.getAccounts() + repositoryManager.transaction.findAll({ page: 1, limit: 50, offset: 0 }), + repositoryManager.category.findAll(), + repositoryManager.account.findAll() ]) transactions.value = transactionsData categories.value = categoriesData accounts.value = accountsData + lastRefreshTime.value = new Date() } catch (err) { console.error('Failed to load initial data:', err) throw err } } + // 刷新数据(如果缓存过期) + async function refreshIfNeeded() { + if (!lastRefreshTime.value || + Date.now() - lastRefreshTime.value.getTime() > cacheExpiry) { + await loadInitialData() + } + } + + // 更新同步状态 + function updateSyncStatus() { + syncStatus.value = { + isOnline: isOnline.value, + lastSyncTime: syncStatus.value.lastSyncTime, + pendingChanges: pendingTransactions.value.length, + syncInProgress: syncStatus.value.syncInProgress, + syncError: syncStatus.value.syncError + } + } + // 交易操作 async function addTransaction(transaction: Omit) { try { - const id = await databaseService.createTransaction(transaction) - const newTransaction = { ...transaction, id } + const id = await repositoryManager.transaction.create(transaction) + const newTransaction = { + ...transaction, + id, + sync_status: 'pending' as const + } as TransactionWithDetails + transactions.value.unshift(newTransaction) + updateSyncStatus() + + // 如果在线,尝试立即同步 + if (isOnline.value) { + syncPendingChanges() + } + return id } catch (err) { console.error('Failed to add transaction:', err) @@ -65,10 +183,24 @@ export const useDatabaseStore = defineStore('database', () => { async function updateTransaction(id: number, updates: Partial) { try { - await databaseService.updateTransaction(id, updates) + await repositoryManager.transaction.update(id, { + ...updates, + sync_status: 'pending' + }) + const index = transactions.value.findIndex(t => t.id === id) if (index !== -1) { - transactions.value[index] = { ...transactions.value[index], ...updates } + transactions.value[index] = { + ...transactions.value[index], + ...updates, + sync_status: 'pending' + } + } + + updateSyncStatus() + + if (isOnline.value) { + syncPendingChanges() } } catch (err) { console.error('Failed to update transaction:', err) @@ -78,11 +210,12 @@ export const useDatabaseStore = defineStore('database', () => { async function deleteTransaction(id: number) { try { - await databaseService.deleteTransaction(id) + await repositoryManager.transaction.delete(id) const index = transactions.value.findIndex(t => t.id === id) if (index !== -1) { transactions.value.splice(index, 1) } + updateSyncStatus() } catch (err) { console.error('Failed to delete transaction:', err) throw err @@ -92,7 +225,7 @@ export const useDatabaseStore = defineStore('database', () => { // 分类操作 async function addCategory(category: Omit) { try { - const id = await databaseService.createCategory(category) + const id = await repositoryManager.category.create(category) const newCategory = { ...category, id } categories.value.push(newCategory) return id @@ -102,10 +235,36 @@ export const useDatabaseStore = defineStore('database', () => { } } + async function updateCategory(id: number, updates: Partial) { + try { + await repositoryManager.category.update(id, updates) + const index = categories.value.findIndex(c => c.id === id) + if (index !== -1) { + categories.value[index] = { ...categories.value[index], ...updates } + } + } catch (err) { + console.error('Failed to update category:', err) + throw err + } + } + + async function deleteCategory(id: number) { + try { + await repositoryManager.category.delete(id) + const index = categories.value.findIndex(c => c.id === id) + if (index !== -1) { + categories.value.splice(index, 1) + } + } catch (err) { + console.error('Failed to delete category:', err) + throw err + } + } + // 账户操作 async function addAccount(account: Omit) { try { - const id = await databaseService.createAccount(account) + const id = await repositoryManager.account.create(account) const newAccount = { ...account, id } accounts.value.push(newAccount) return id @@ -115,6 +274,53 @@ export const useDatabaseStore = defineStore('database', () => { } } + async function updateAccount(id: number, updates: Partial) { + try { + await repositoryManager.account.update(id, updates) + const index = accounts.value.findIndex(a => a.id === id) + if (index !== -1) { + accounts.value[index] = { ...accounts.value[index], ...updates } + } + } catch (err) { + console.error('Failed to update account:', err) + throw err + } + } + + async function deleteAccount(id: number) { + try { + await repositoryManager.account.delete(id) + const index = accounts.value.findIndex(a => a.id === id) + if (index !== -1) { + accounts.value.splice(index, 1) + } + } catch (err) { + console.error('Failed to delete account:', err) + throw err + } + } + + // 搜索和筛选 + async function searchTransactions(query: string) { + try { + const results = await repositoryManager.transaction.search(query) + return results + } catch (err) { + console.error('Failed to search transactions:', err) + throw err + } + } + + async function filterTransactions(filters: TransactionFilters) { + try { + const results = await repositoryManager.transaction.findWithFilters(filters) + return results + } catch (err) { + console.error('Failed to filter transactions:', err) + throw err + } + } + // 获取默认分类和账户 function getDefaultCategory(type: 'income' | 'expense'): Category | undefined { return categories.value.find(c => c.is_default && (c.type === type || c.type === 'both')) @@ -124,51 +330,121 @@ export const useDatabaseStore = defineStore('database', () => { return accounts.value.find(a => a.is_default) } - // 搜索和筛选 - function searchTransactions(query: string): Transaction[] { - if (!query.trim()) return transactions.value + // 同步功能(占位符,将在云端功能中实现) + async function syncPendingChanges() { + if (!isOnline.value || syncStatus.value.syncInProgress) return - const lowerQuery = query.toLowerCase() - return transactions.value.filter(t => - t.merchant.toLowerCase().includes(lowerQuery) || - (t.description && t.description.toLowerCase().includes(lowerQuery)) - ) + syncStatus.value.syncInProgress = true + + try { + // 这里将在后续任务中实现实际的同步逻辑 + console.log('Sync pending changes (placeholder)') + + // 模拟同步延迟 + await new Promise(resolve => setTimeout(resolve, 1000)) + + syncStatus.value.lastSyncTime = new Date().toISOString() + syncStatus.value.syncError = undefined + } catch (err) { + syncStatus.value.syncError = err instanceof Error ? err.message : 'Sync failed' + console.error('Sync failed:', err) + } finally { + syncStatus.value.syncInProgress = false + updateSyncStatus() + } } - function filterTransactionsByCategory(categoryId: number): Transaction[] { - return transactions.value.filter(t => t.category_id === categoryId) + // 强制同步 + async function forceSync() { + await syncPendingChanges() } - function filterTransactionsByAccount(accountId: number): Transaction[] { - return transactions.value.filter(t => t.account_id === accountId) + // 离线模式提示 + function showOfflineMessage() { + return !isOnline.value } - function filterTransactionsByDateRange(startDate: string, endDate: string): Transaction[] { - return transactions.value.filter(t => t.date >= startDate && t.date <= endDate) + // 数据统计 + async function getStats(startDate?: string, endDate?: string) { + try { + return await repositoryManager.transaction.getStats(startDate, endDate) + } catch (err) { + console.error('Failed to get stats:', err) + throw err + } + } + + // 数据完整性检查 + async function checkDataIntegrity() { + try { + return await repositoryManager.checkDataIntegrity() + } catch (err) { + console.error('Failed to check data integrity:', err) + throw err + } + } + + // 清理错误状态 + function clearError() { + error.value = null + syncStatus.value.syncError = undefined } return { - // 状态 + // 基础状态 isInitialized, isLoading, error, + + // 数据状态 transactions, categories, accounts, - // 方法 + // 离线状态 + isOnline, + syncStatus, + + // 计算属性 + pendingTransactions, + conflictTransactions, + totalBalance, + monthlyStats, + + // 基础方法 initialize, loadInitialData, + refreshIfNeeded, + clearError, + + // 交易操作 addTransaction, updateTransaction, deleteTransaction, + + // 分类操作 addCategory, + updateCategory, + deleteCategory, + + // 账户操作 addAccount, + updateAccount, + deleteAccount, + + // 搜索和筛选 + searchTransactions, + filterTransactions, + + // 工具方法 getDefaultCategory, getDefaultAccount, - searchTransactions, - filterTransactionsByCategory, - filterTransactionsByAccount, - filterTransactionsByDateRange + getStats, + checkDataIntegrity, + + // 同步功能 + syncPendingChanges, + forceSync, + showOfflineMessage } }) \ No newline at end of file diff --git a/frontend/src/stores/offline.ts b/frontend/src/stores/offline.ts new file mode 100644 index 0000000..a8c4386 --- /dev/null +++ b/frontend/src/stores/offline.ts @@ -0,0 +1,354 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface OfflineAction { + id: string + type: 'create' | 'update' | 'delete' + entity: 'transaction' | 'category' | 'account' + data: any + timestamp: string + retryCount: number + maxRetries: number + error?: string +} + +export const useOfflineStore = defineStore('offline', () => { + // 网络状态 + const isOnline = ref(navigator.onLine) + const connectionType = ref('unknown') + const lastOnlineTime = ref(null) + const lastOfflineTime = ref(null) + + // 离线操作队列 + const pendingActions = ref([]) + const failedActions = ref([]) + + // 同步状态 + const isSyncing = ref(false) + const syncProgress = ref(0) + const syncError = ref(null) + const lastSyncTime = ref(null) + + // 离线提示 + const showOfflineIndicator = ref(false) + const offlineMessage = ref('') + + // 计算属性 + const hasConnection = computed(() => isOnline.value) + const hasPendingActions = computed(() => pendingActions.value.length > 0) + const hasFailedActions = computed(() => failedActions.value.length > 0) + const totalPendingCount = computed(() => pendingActions.value.length + failedActions.value.length) + + const connectionStatus = computed(() => { + if (isOnline.value) { + return { + status: 'online', + message: '已连接', + color: 'green' + } + } else { + return { + status: 'offline', + message: '离线模式', + color: 'orange' + } + } + }) + + // 网络状态监听 + function setupNetworkListeners() { + // 监听网络状态变化 + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + // 检测连接类型(如果支持) + if ('connection' in navigator) { + const connection = (navigator as any).connection + connectionType.value = connection.effectiveType || 'unknown' + + connection.addEventListener('change', () => { + connectionType.value = connection.effectiveType || 'unknown' + }) + } + + // 初始状态 + updateNetworkStatus() + } + + // 处理上线事件 + function handleOnline() { + isOnline.value = true + lastOnlineTime.value = new Date() + showOfflineIndicator.value = false + + console.log('网络已连接,开始同步离线数据') + + // 自动同步离线操作 + if (hasPendingActions.value || hasFailedActions.value) { + syncOfflineActions() + } + } + + // 处理离线事件 + function handleOffline() { + isOnline.value = false + lastOfflineTime.value = new Date() + showOfflineIndicator.value = true + offlineMessage.value = '当前处于离线模式,数据将在网络恢复后自动同步' + + console.log('网络已断开,进入离线模式') + } + + // 更新网络状态 + function updateNetworkStatus() { + const wasOnline = isOnline.value + isOnline.value = navigator.onLine + + if (wasOnline !== isOnline.value) { + if (isOnline.value) { + handleOnline() + } else { + handleOffline() + } + } + } + + // 添加离线操作到队列 + function addOfflineAction(action: Omit) { + const offlineAction: OfflineAction = { + ...action, + id: generateActionId(), + timestamp: new Date().toISOString(), + retryCount: 0 + } + + pendingActions.value.push(offlineAction) + console.log('添加离线操作到队列:', offlineAction) + } + + // 生成操作ID + function generateActionId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + } + + // 同步离线操作 + async function syncOfflineActions() { + if (isSyncing.value || !isOnline.value) return + + isSyncing.value = true + syncProgress.value = 0 + syncError.value = null + + try { + const allActions = [...pendingActions.value, ...failedActions.value] + const totalActions = allActions.length + + if (totalActions === 0) { + isSyncing.value = false + return + } + + console.log(`开始同步 ${totalActions} 个离线操作`) + + for (let i = 0; i < allActions.length; i++) { + const action = allActions[i] + + try { + await executeOfflineAction(action) + + // 从队列中移除成功的操作 + removeActionFromQueues(action.id) + + syncProgress.value = ((i + 1) / totalActions) * 100 + } catch (error) { + console.error('同步操作失败:', action, error) + + // 增加重试次数 + action.retryCount++ + action.error = error instanceof Error ? error.message : 'Unknown error' + + // 如果超过最大重试次数,移到失败队列 + if (action.retryCount >= action.maxRetries) { + moveActionToFailed(action) + } + } + } + + lastSyncTime.value = new Date() + console.log('离线操作同步完成') + + } catch (error) { + syncError.value = error instanceof Error ? error.message : 'Sync failed' + console.error('同步过程中发生错误:', error) + } finally { + isSyncing.value = false + syncProgress.value = 100 + } + } + + // 执行离线操作 + async function executeOfflineAction(action: OfflineAction) { + // 这里将在实际的云端同步功能中实现 + // 目前只是模拟执行 + console.log('执行离线操作:', action) + + // 模拟网络请求延迟 + await new Promise(resolve => setTimeout(resolve, 500)) + + // 模拟可能的失败 + if (Math.random() < 0.1) { // 10% 失败率 + throw new Error('模拟网络错误') + } + } + + // 从队列中移除操作 + function removeActionFromQueues(actionId: string) { + pendingActions.value = pendingActions.value.filter(a => a.id !== actionId) + failedActions.value = failedActions.value.filter(a => a.id !== actionId) + } + + // 将操作移到失败队列 + function moveActionToFailed(action: OfflineAction) { + pendingActions.value = pendingActions.value.filter(a => a.id !== action.id) + + if (!failedActions.value.find(a => a.id === action.id)) { + failedActions.value.push(action) + } + } + + // 重试失败的操作 + async function retryFailedActions() { + if (!isOnline.value) { + throw new Error('网络未连接,无法重试') + } + + // 将失败的操作移回待处理队列 + const actionsToRetry = failedActions.value.map(action => ({ + ...action, + retryCount: 0, + error: undefined + })) + + failedActions.value = [] + pendingActions.value.push(...actionsToRetry) + + // 开始同步 + await syncOfflineActions() + } + + // 清除失败的操作 + function clearFailedActions() { + failedActions.value = [] + } + + // 清除所有离线操作 + function clearAllActions() { + pendingActions.value = [] + failedActions.value = [] + } + + // 手动触发同步 + async function manualSync() { + if (!isOnline.value) { + throw new Error('网络未连接,无法同步') + } + + await syncOfflineActions() + } + + // 获取离线统计 + function getOfflineStats() { + return { + isOnline: isOnline.value, + connectionType: connectionType.value, + pendingCount: pendingActions.value.length, + failedCount: failedActions.value.length, + totalCount: totalPendingCount.value, + lastOnlineTime: lastOnlineTime.value, + lastOfflineTime: lastOfflineTime.value, + lastSyncTime: lastSyncTime.value, + isSyncing: isSyncing.value, + syncProgress: syncProgress.value + } + } + + // 设置离线提示 + function setOfflineMessage(message: string, show = true) { + offlineMessage.value = message + showOfflineIndicator.value = show + } + + // 隐藏离线提示 + function hideOfflineIndicator() { + showOfflineIndicator.value = false + } + + // 检查网络连接质量 + async function checkConnectionQuality() { + if (!isOnline.value) return 'offline' + + try { + const start = Date.now() + const response = await fetch('/api/ping', { + method: 'HEAD', + cache: 'no-cache' + }) + const duration = Date.now() - start + + if (response.ok) { + if (duration < 100) return 'excellent' + if (duration < 300) return 'good' + if (duration < 1000) return 'fair' + return 'poor' + } else { + return 'poor' + } + } catch (error) { + return 'offline' + } + } + + // 清理资源 + function cleanup() { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + + return { + // 状态 + isOnline, + connectionType, + lastOnlineTime, + lastOfflineTime, + pendingActions, + failedActions, + isSyncing, + syncProgress, + syncError, + lastSyncTime, + showOfflineIndicator, + offlineMessage, + + // 计算属性 + hasConnection, + hasPendingActions, + hasFailedActions, + totalPendingCount, + connectionStatus, + + // 方法 + setupNetworkListeners, + updateNetworkStatus, + addOfflineAction, + syncOfflineActions, + retryFailedActions, + clearFailedActions, + clearAllActions, + manualSync, + getOfflineStats, + setOfflineMessage, + hideOfflineIndicator, + checkConnectionQuality, + cleanup + } +}) \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..64ba9b8 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,280 @@ +// 重新导出数据库类型 +export type { Transaction, Category, Account } from '@/services/database' + +// 扩展的交易类型,包含关联数据 +export interface TransactionWithDetails extends Transaction { + category_name?: string + category_icon?: string + category_color?: string + account_name?: string + account_icon?: string + account_color?: string +} + +// 交易统计数据 +export interface TransactionStats { + totalIncome: number + totalExpense: number + balance: number + transactionCount: number + averageAmount: number +} + +// 分类统计数据 +export interface CategoryStats { + category_id: number + category_name: string + category_icon: string + category_color: string + total_amount: number + transaction_count: number + percentage: number +} + +// 账户统计数据 +export interface AccountStats { + account_id: number + account_name: string + account_icon: string + account_color: string + total_amount: number + transaction_count: number +} + +// 日期范围统计 +export interface DateRangeStats { + date: string + income: number + expense: number + balance: number + transaction_count: number +} + +// 搜索和筛选参数 +export interface TransactionFilters { + search?: string + type?: 'income' | 'expense' | 'all' + category_ids?: number[] + account_ids?: number[] + date_from?: string + date_to?: string + amount_min?: number + amount_max?: number + sync_status?: 'synced' | 'pending' | 'conflict' | 'all' + source?: 'manual' | 'notification' | 'all' +} + +// 分页参数 +export interface PaginationParams { + page: number + limit: number + offset: number +} + +// API 响应类型 +export interface ApiResponse { + success: boolean + data?: T + error?: { + code: string + message: string + details?: any + } + pagination?: { + page: number + limit: number + total: number + totalPages: number + } +} + +// 数据库操作结果 +export interface DatabaseResult { + success: boolean + data?: any + error?: string + affectedRows?: number + insertId?: number +} + +// 同步状态 +export interface SyncStatus { + isOnline: boolean + lastSyncTime?: string + pendingChanges: number + syncInProgress: boolean + syncError?: string +} + +// 应用设置 +export interface AppSettings { + currency: string + language: string + theme: 'light' | 'dark' | 'auto' + notifications: { + enabled: boolean + autoCapture: boolean + confirmBeforeSave: boolean + } + privacy: { + requireAuth: boolean + autoLock: boolean + lockTimeout: number + } +} + +// 通知解析结果 +export interface NotificationParseResult { + success: boolean + data?: { + amount: number + type: 'income' | 'expense' + source: string + merchant?: string + timestamp: string + confidence: number // 解析置信度 0-1 + } + error?: string + rawContent: string +} + +// 表单状态 +export interface FormState { + data: T + errors: Record + isValid: boolean + isSubmitting: boolean + isDirty: boolean +} + +// UI 状态 +export interface UIState { + loading: boolean + error: string | null + success: string | null + modal: { + isOpen: boolean + type?: string + data?: any + } + drawer: { + isOpen: boolean + type?: string + } +} + +// 图表数据类型 +export interface ChartDataPoint { + label: string + value: number + color?: string + percentage?: number +} + +export interface LineChartData { + labels: string[] + datasets: { + label: string + data: number[] + borderColor: string + backgroundColor: string + fill?: boolean + }[] +} + +export interface PieChartData { + labels: string[] + datasets: { + data: number[] + backgroundColor: string[] + borderColor: string[] + borderWidth?: number + }[] +} + +// 导入导出数据格式 +export interface ExportData { + version: string + exportDate: string + transactions: Transaction[] + categories: Category[] + accounts: Account[] + settings?: AppSettings +} + +// 数据迁移版本 +export interface MigrationVersion { + version: number + description: string + migrationDate: string +} + +// 错误类型 +export interface AppError { + code: string + message: string + details?: any + timestamp: string + stack?: string +} + +// 事件类型 +export interface AppEvent { + type: string + data?: any + timestamp: string +} + +// 性能监控数据 +export interface PerformanceMetrics { + databaseInitTime: number + averageQueryTime: number + memoryUsage: number + storageUsage: number + lastUpdateTime: string +} + +// 常量定义 +export const TRANSACTION_TYPES = ['income', 'expense'] as const +export const ACCOUNT_TYPES = ['cash', 'alipay', 'wechat', 'bank'] as const +export const CATEGORY_TYPES = ['income', 'expense', 'both'] as const +export const SYNC_STATUSES = ['synced', 'pending', 'conflict'] as const +export const TRANSACTION_SOURCES = ['manual', 'notification'] as const + +// 类型守卫函数 +export function isTransaction(obj: any): obj is Transaction { + return ( + typeof obj === 'object' && + obj !== null && + typeof obj.amount === 'number' && + TRANSACTION_TYPES.includes(obj.type) && + typeof obj.category_id === 'number' && + typeof obj.account_id === 'number' && + typeof obj.merchant === 'string' && + typeof obj.date === 'string' + ) +} + +export function isCategory(obj: any): obj is Category { + return ( + typeof obj === 'object' && + obj !== null && + typeof obj.name === 'string' && + typeof obj.icon === 'string' && + typeof obj.color === 'string' && + CATEGORY_TYPES.includes(obj.type) && + typeof obj.is_default === 'boolean' + ) +} + +export function isAccount(obj: any): obj is Account { + return ( + typeof obj === 'object' && + obj !== null && + typeof obj.name === 'string' && + ACCOUNT_TYPES.includes(obj.type) && + typeof obj.icon === 'string' && + typeof obj.color === 'string' && + typeof obj.is_default === 'boolean' + ) +} \ No newline at end of file diff --git a/frontend/src/utils/validation.ts b/frontend/src/utils/validation.ts new file mode 100644 index 0000000..8af5ea9 --- /dev/null +++ b/frontend/src/utils/validation.ts @@ -0,0 +1,375 @@ +import type { Transaction, Category, Account } from '@/services/database' + +// 数据验证错误类 +export class ValidationError extends Error { + constructor(message: string, public field?: string) { + super(message) + this.name = 'ValidationError' + } +} + +// 交易数据验证 +export function validateTransaction(transaction: Partial): string[] { + const errors: string[] = [] + + // 金额验证 + if (transaction.amount === undefined || transaction.amount === null) { + errors.push('金额不能为空') + } else if (typeof transaction.amount !== 'number' || transaction.amount <= 0) { + errors.push('金额必须是大于0的数字') + } else if (transaction.amount > 999999999) { + errors.push('金额不能超过999,999,999') + } + + // 类型验证 + if (!transaction.type) { + errors.push('交易类型不能为空') + } else if (!['income', 'expense'].includes(transaction.type)) { + errors.push('交易类型必须是收入或支出') + } + + // 分类验证 + if (transaction.category_id === undefined || transaction.category_id === null) { + errors.push('分类不能为空') + } else if (typeof transaction.category_id !== 'number' || transaction.category_id <= 0) { + errors.push('分类ID必须是有效的正整数') + } + + // 账户验证 + if (transaction.account_id === undefined || transaction.account_id === null) { + errors.push('账户不能为空') + } else if (typeof transaction.account_id !== 'number' || transaction.account_id <= 0) { + errors.push('账户ID必须是有效的正整数') + } + + // 商户名验证 + if (!transaction.merchant || transaction.merchant.trim().length === 0) { + errors.push('商户名不能为空') + } else if (transaction.merchant.length > 100) { + errors.push('商户名不能超过100个字符') + } + + // 日期验证 + if (!transaction.date) { + errors.push('日期不能为空') + } else if (!isValidDate(transaction.date)) { + errors.push('日期格式不正确') + } + + // 描述验证(可选) + if (transaction.description && transaction.description.length > 500) { + errors.push('描述不能超过500个字符') + } + + // 同步状态验证 + if (transaction.sync_status && !['synced', 'pending', 'conflict'].includes(transaction.sync_status)) { + errors.push('同步状态值不正确') + } + + // 来源验证 + if (transaction.source && !['manual', 'notification'].includes(transaction.source)) { + errors.push('来源类型值不正确') + } + + return errors +} + +// 分类数据验证 +export function validateCategory(category: Partial): string[] { + const errors: string[] = [] + + // 名称验证 + if (!category.name || category.name.trim().length === 0) { + errors.push('分类名称不能为空') + } else if (category.name.length > 50) { + errors.push('分类名称不能超过50个字符') + } + + // 图标验证 + if (!category.icon || category.icon.trim().length === 0) { + errors.push('分类图标不能为空') + } + + // 颜色验证 + if (!category.color || category.color.trim().length === 0) { + errors.push('分类颜色不能为空') + } else if (!isValidHexColor(category.color)) { + errors.push('颜色格式不正确,请使用十六进制格式(如 #FF0000)') + } + + // 类型验证 + if (!category.type) { + errors.push('分类类型不能为空') + } else if (!['income', 'expense', 'both'].includes(category.type)) { + errors.push('分类类型必须是收入、支出或通用') + } + + return errors +} + +// 账户数据验证 +export function validateAccount(account: Partial): string[] { + const errors: string[] = [] + + // 名称验证 + if (!account.name || account.name.trim().length === 0) { + errors.push('账户名称不能为空') + } else if (account.name.length > 50) { + errors.push('账户名称不能超过50个字符') + } + + // 类型验证 + if (!account.type) { + errors.push('账户类型不能为空') + } else if (!['cash', 'alipay', 'wechat', 'bank'].includes(account.type)) { + errors.push('账户类型必须是现金、支付宝、微信或银行卡') + } + + // 图标验证 + if (!account.icon || account.icon.trim().length === 0) { + errors.push('账户图标不能为空') + } + + // 颜色验证 + if (!account.color || account.color.trim().length === 0) { + errors.push('账户颜色不能为空') + } else if (!isValidHexColor(account.color)) { + errors.push('颜色格式不正确,请使用十六进制格式(如 #FF0000)') + } + + return errors +} + +// 辅助函数:验证日期格式 +function isValidDate(dateString: string): boolean { + // 检查 YYYY-MM-DD 格式 + const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + if (!dateRegex.test(dateString)) { + return false + } + + const date = new Date(dateString) + return date instanceof Date && !isNaN(date.getTime()) +} + +// 辅助函数:验证十六进制颜色 +function isValidHexColor(color: string): boolean { + const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/ + return hexRegex.test(color) +} + +// 数据格式化函数 +export class DataFormatter { + // 格式化金额显示 + static formatAmount(amount: number, showSign = true): string { + const formatted = Math.abs(amount).toFixed(2) + if (showSign) { + return amount >= 0 ? `+${formatted}` : `-${formatted}` + } + return formatted + } + + // 格式化金额为货币显示 + static formatCurrency(amount: number, currency = '¥'): string { + return `${currency}${Math.abs(amount).toFixed(2)}` + } + + // 格式化日期显示 + static formatDate(dateString: string, format: 'short' | 'long' | 'relative' = 'short'): string { + const date = new Date(dateString) + const now = new Date() + + switch (format) { + case 'short': + return date.toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit' + }) + + case 'long': + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + + case 'relative': + const diffTime = now.getTime() - date.getTime() + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) return '今天' + if (diffDays === 1) return '昨天' + if (diffDays === 2) return '前天' + if (diffDays < 7) return `${diffDays}天前` + if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前` + if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月前` + return `${Math.floor(diffDays / 365)}年前` + + default: + return dateString + } + } + + // 格式化时间显示 + static formatDateTime(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + } + + // 截断长文本 + static truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.substring(0, maxLength - 3) + '...' + } + + // 格式化交易类型显示 + static formatTransactionType(type: 'income' | 'expense'): string { + return type === 'income' ? '收入' : '支出' + } + + // 格式化账户类型显示 + static formatAccountType(type: 'cash' | 'alipay' | 'wechat' | 'bank'): string { + const typeMap = { + cash: '现金', + alipay: '支付宝', + wechat: '微信', + bank: '银行卡' + } + return typeMap[type] || type + } + + // 生成今天的日期字符串 + static getTodayDateString(): string { + const today = new Date() + return today.toISOString().split('T')[0] + } + + // 生成月份范围 + static getMonthRange(year: number, month: number): { start: string; end: string } { + const start = new Date(year, month - 1, 1) + const end = new Date(year, month, 0) + + return { + start: start.toISOString().split('T')[0], + end: end.toISOString().split('T')[0] + } + } + + // 生成年份范围 + static getYearRange(year: number): { start: string; end: string } { + return { + start: `${year}-01-01`, + end: `${year}-12-31` + } + } +} + +// 数据清理函数 +export class DataSanitizer { + // 清理交易数据 + static sanitizeTransaction(transaction: Partial): Partial { + const sanitized: Partial = {} + + if (transaction.amount !== undefined) { + sanitized.amount = Math.round(transaction.amount * 100) / 100 // 保留两位小数 + } + + if (transaction.type) { + sanitized.type = transaction.type.toLowerCase() as 'income' | 'expense' + } + + if (transaction.category_id !== undefined) { + sanitized.category_id = Math.floor(transaction.category_id) + } + + if (transaction.account_id !== undefined) { + sanitized.account_id = Math.floor(transaction.account_id) + } + + if (transaction.merchant) { + sanitized.merchant = transaction.merchant.trim() + } + + if (transaction.description) { + sanitized.description = transaction.description.trim() + } + + if (transaction.date) { + sanitized.date = transaction.date.split('T')[0] // 确保只保留日期部分 + } + + if (transaction.sync_status) { + sanitized.sync_status = transaction.sync_status + } + + if (transaction.source) { + sanitized.source = transaction.source + } + + if (transaction.raw_notification) { + sanitized.raw_notification = transaction.raw_notification.trim() + } + + return sanitized + } + + // 清理分类数据 + static sanitizeCategory(category: Partial): Partial { + const sanitized: Partial = {} + + if (category.name) { + sanitized.name = category.name.trim() + } + + if (category.icon) { + sanitized.icon = category.icon.trim() + } + + if (category.color) { + sanitized.color = category.color.toUpperCase() + } + + if (category.type) { + sanitized.type = category.type.toLowerCase() as 'income' | 'expense' | 'both' + } + + if (category.is_default !== undefined) { + sanitized.is_default = Boolean(category.is_default) + } + + return sanitized + } + + // 清理账户数据 + static sanitizeAccount(account: Partial): Partial { + const sanitized: Partial = {} + + if (account.name) { + sanitized.name = account.name.trim() + } + + if (account.type) { + sanitized.type = account.type.toLowerCase() as 'cash' | 'alipay' | 'wechat' | 'bank' + } + + if (account.icon) { + sanitized.icon = account.icon.trim() + } + + if (account.color) { + sanitized.color = account.color.toUpperCase() + } + + if (account.is_default !== undefined) { + sanitized.is_default = Boolean(account.is_default) + } + + return sanitized + } +} \ No newline at end of file diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 83a5aa1..6de1b8a 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -37,10 +37,61 @@ + + +

连接状态

+
+
+
+ {{ connectionStatus.message }} +
+ + {{ offlineStats.connectionType }} + +
+ +
+ 待同步: {{ offlineStats.pendingCount }} | 失败: {{ offlineStats.failedCount }} +
+
+ + + +

数据概览

+
+
+
+ ¥{{ databaseStore.monthlyStats.income.toFixed(2) }} +
+
本月收入
+
+
+
+ ¥{{ databaseStore.monthlyStats.expense.toFixed(2) }} +
+
本月支出
+
+
+
+
+ ¥{{ databaseStore.totalBalance.toFixed(2) }} +
+
总余额
+
+
+
- 主要按钮 - 次要按钮 + + 添加测试交易 + + + 查看交易 +
@@ -58,10 +109,43 @@ diff --git a/frontend/src/views/TransactionsView.vue b/frontend/src/views/TransactionsView.vue index cac27ea..4e4c621 100644 --- a/frontend/src/views/TransactionsView.vue +++ b/frontend/src/views/TransactionsView.vue @@ -1,20 +1,20 @@