feat: 完成任务2 - 实现本地数据模型和离线功能
任务2.1 创建本地数据模型 - 完善 SQLite 数据库表结构和数据模型 - 创建完整的 TypeScript 接口定义 - 实现数据验证和格式化工具函数 - 添加数据清理和类型守卫功能 任务2.2 实现本地数据 CRUD 操作 - 扩展数据库服务,添加完整的 CRUD 操作 - 实现 Repository 模式封装数据访问层 - 创建 TransactionRepository、CategoryRepository、AccountRepository - 添加批量操作、统计查询和数据迁移功能 - 实现 RepositoryManager 统一管理数据操作 任务2.3 建立离线优先的数据架构 - 重构 Pinia store 使用 Repository 模式 - 创建离线状态管理 store (useOfflineStore) - 实现网络状态监听和离线操作队列 - 添加离线指示器组件和用户界面 - 建立数据同步标记和冲突检测机制 - 实现离线优先的数据缓存策略 核心特性: - 完整的本地数据 CRUD 操作 - 离线优先架构,支持断网使用 - 数据验证和格式化 - Repository 模式数据访问层 - 网络状态监听和自动同步 - 用户友好的离线提示界面
This commit is contained in:
@@ -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)
|
||||
- 创建离线模式的用户界面和提示
|
||||
- 建立数据同步标记和冲突检测机制
|
||||
|
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// 主应用组件
|
||||
import OfflineIndicator from '@/components/common/OfflineIndicator.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" class="min-h-screen bg-background">
|
||||
<!-- 离线状态指示器 -->
|
||||
<OfflineIndicator />
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
207
frontend/src/components/common/OfflineIndicator.vue
Normal file
207
frontend/src/components/common/OfflineIndicator.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<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="showIndicator"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-orange-500 text-white px-4 py-2 text-sm"
|
||||
>
|
||||
<div class="max-w-md mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg
|
||||
v-if="!offlineStore.isOnline"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 5.636l-12.728 12.728m0 0L5.636 18.364m12.728-12.728L5.636 5.636m12.728 12.728L18.364 18.364"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="offlineStore.isSyncing"
|
||||
class="w-4 h-4 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span>{{ statusMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 待同步数量 -->
|
||||
<span
|
||||
v-if="offlineStore.totalPendingCount > 0"
|
||||
class="bg-white bg-opacity-20 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
{{ offlineStore.totalPendingCount }} 待同步
|
||||
</span>
|
||||
|
||||
<!-- 同步进度 -->
|
||||
<div
|
||||
v-if="offlineStore.isSyncing"
|
||||
class="w-16 h-1 bg-white bg-opacity-20 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-white transition-all duration-300"
|
||||
:style="{ width: `${offlineStore.syncProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<button
|
||||
v-if="offlineStore.isOnline && offlineStore.totalPendingCount > 0 && !offlineStore.isSyncing"
|
||||
@click="handleSync"
|
||||
class="text-xs underline hover:no-underline"
|
||||
>
|
||||
立即同步
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="offlineStore.hasFailedActions"
|
||||
@click="handleRetry"
|
||||
class="text-xs underline hover:no-underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="ml-2 hover:bg-white hover:bg-opacity-20 rounded p-1"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useOfflineStore } from '@/stores/offline'
|
||||
|
||||
const offlineStore = useOfflineStore()
|
||||
|
||||
// 显示指示器的条件
|
||||
const showIndicator = computed(() => {
|
||||
return (
|
||||
offlineStore.showOfflineIndicator ||
|
||||
!offlineStore.isOnline ||
|
||||
offlineStore.isSyncing ||
|
||||
offlineStore.totalPendingCount > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 状态消息
|
||||
const statusMessage = computed(() => {
|
||||
if (offlineStore.isSyncing) {
|
||||
return `正在同步... (${Math.round(offlineStore.syncProgress)}%)`
|
||||
}
|
||||
|
||||
if (!offlineStore.isOnline) {
|
||||
return offlineStore.offlineMessage || '离线模式 - 数据将在网络恢复后同步'
|
||||
}
|
||||
|
||||
if (offlineStore.totalPendingCount > 0) {
|
||||
return `有 ${offlineStore.totalPendingCount} 项更改待同步`
|
||||
}
|
||||
|
||||
if (offlineStore.hasFailedActions) {
|
||||
return '部分数据同步失败'
|
||||
}
|
||||
|
||||
return '已连接'
|
||||
})
|
||||
|
||||
// 处理同步
|
||||
async function handleSync() {
|
||||
try {
|
||||
await offlineStore.manualSync()
|
||||
} catch (error) {
|
||||
console.error('手动同步失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理重试
|
||||
async function handleRetry() {
|
||||
try {
|
||||
await offlineStore.retryFailedActions()
|
||||
} catch (error) {
|
||||
console.error('重试失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
function handleClose() {
|
||||
offlineStore.hideOfflineIndicator()
|
||||
}
|
||||
|
||||
// 自动隐藏成功消息
|
||||
let hideTimer: NodeJS.Timeout | null = null
|
||||
|
||||
function scheduleAutoHide() {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
// 如果是成功状态,3秒后自动隐藏
|
||||
if (offlineStore.isOnline && offlineStore.totalPendingCount === 0 && !offlineStore.isSyncing) {
|
||||
hideTimer = setTimeout(() => {
|
||||
offlineStore.hideOfflineIndicator()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 监听状态变化,自动隐藏成功消息
|
||||
scheduleAutoHide()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
@@ -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')
|
||||
|
254
frontend/src/repositories/AccountRepository.ts
Normal file
254
frontend/src/repositories/AccountRepository.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { databaseService, type Account } from '@/services/database'
|
||||
import { validateAccount, DataSanitizer } from '@/utils/validation'
|
||||
|
||||
export class AccountRepository {
|
||||
// 创建账户
|
||||
async create(account: Omit<Account, 'id'>): Promise<number> {
|
||||
// 数据验证
|
||||
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<Account, 'id'>)
|
||||
}
|
||||
|
||||
// 获取所有账户
|
||||
async findAll(): Promise<Account[]> {
|
||||
return await databaseService.getAccounts()
|
||||
}
|
||||
|
||||
// 根据类型获取账户
|
||||
async findByType(type: 'cash' | 'alipay' | 'wechat' | 'bank'): Promise<Account[]> {
|
||||
const accounts = await this.findAll()
|
||||
return accounts.filter(a => a.type === type)
|
||||
}
|
||||
|
||||
// 根据名称查找账户
|
||||
async findByName(name: string): Promise<Account | null> {
|
||||
const accounts = await this.findAll()
|
||||
return accounts.find(a => a.name.toLowerCase() === name.toLowerCase()) || null
|
||||
}
|
||||
|
||||
// 根据ID查找账户
|
||||
async findById(id: number): Promise<Account | null> {
|
||||
const accounts = await this.findAll()
|
||||
return accounts.find(a => a.id === id) || null
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
async update(id: number, updates: Partial<Account>): Promise<void> {
|
||||
// 数据验证
|
||||
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<void> {
|
||||
// 检查是否为默认账户
|
||||
const account = await this.findById(id)
|
||||
if (account?.is_default) {
|
||||
throw new Error('无法删除默认账户')
|
||||
}
|
||||
|
||||
await databaseService.deleteAccount(id)
|
||||
}
|
||||
|
||||
// 获取默认账户
|
||||
async getDefaults(): Promise<Account[]> {
|
||||
const accounts = await this.findAll()
|
||||
return accounts.filter(a => a.is_default)
|
||||
}
|
||||
|
||||
// 获取主要默认账户
|
||||
async getPrimaryDefault(): Promise<Account | null> {
|
||||
const defaults = await this.getDefaults()
|
||||
return defaults.find(a => a.type === 'alipay') || defaults[0] || null
|
||||
}
|
||||
|
||||
// 获取自定义账户
|
||||
async getCustom(): Promise<Account[]> {
|
||||
const accounts = await this.findAll()
|
||||
return accounts.filter(a => !a.is_default)
|
||||
}
|
||||
|
||||
// 设置默认账户
|
||||
async setAsDefault(id: number): Promise<void> {
|
||||
await this.update(id, { is_default: true })
|
||||
}
|
||||
|
||||
// 取消默认账户
|
||||
async unsetAsDefault(id: number): Promise<void> {
|
||||
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<number> {
|
||||
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<any[]> {
|
||||
const stats = await this.getStats()
|
||||
return stats
|
||||
.sort((a, b) => b.transaction_count - a.transaction_count)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
// 检查账户是否被使用
|
||||
async isInUse(id: number): Promise<boolean> {
|
||||
try {
|
||||
await databaseService.deleteAccount(id)
|
||||
return false
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户的交易数量
|
||||
async getTransactionCount(id: number): Promise<number> {
|
||||
const stats = await this.getStats()
|
||||
const accountStats = stats.find(s => s.account_id === id)
|
||||
return accountStats?.transaction_count || 0
|
||||
}
|
||||
|
||||
// 批量创建账户
|
||||
async batchCreate(accounts: Omit<Account, 'id'>[]): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
|
||||
for (const account of accounts) {
|
||||
const id = await this.create(account)
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// 重置为默认账户
|
||||
async resetToDefaults(): Promise<void> {
|
||||
// 删除所有自定义账户
|
||||
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<Account | null> {
|
||||
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<Account[]> {
|
||||
return await this.findAll()
|
||||
}
|
||||
|
||||
// 导入账户数据
|
||||
async import(accounts: Omit<Account, 'id'>[]): Promise<number[]> {
|
||||
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<void> {
|
||||
// 这个功能需要在 TransactionRepository 中实现
|
||||
// 这里只是一个占位符
|
||||
throw new Error('账户合并功能需要在 TransactionRepository 中实现')
|
||||
}
|
||||
}
|
192
frontend/src/repositories/CategoryRepository.ts
Normal file
192
frontend/src/repositories/CategoryRepository.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { databaseService, type Category } from '@/services/database'
|
||||
import { validateCategory, DataSanitizer } from '@/utils/validation'
|
||||
|
||||
export class CategoryRepository {
|
||||
// 创建分类
|
||||
async create(category: Omit<Category, 'id'>): Promise<number> {
|
||||
// 数据验证
|
||||
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<Category, 'id'>)
|
||||
}
|
||||
|
||||
// 获取所有分类
|
||||
async findAll(): Promise<Category[]> {
|
||||
return await databaseService.getCategories()
|
||||
}
|
||||
|
||||
// 根据类型获取分类
|
||||
async findByType(type: 'income' | 'expense'): Promise<Category[]> {
|
||||
return await databaseService.getCategoriesByType(type)
|
||||
}
|
||||
|
||||
// 根据名称查找分类
|
||||
async findByName(name: string): Promise<Category | null> {
|
||||
const categories = await this.findAll()
|
||||
return categories.find(c => c.name.toLowerCase() === name.toLowerCase()) || null
|
||||
}
|
||||
|
||||
// 根据ID查找分类
|
||||
async findById(id: number): Promise<Category | null> {
|
||||
const categories = await this.findAll()
|
||||
return categories.find(c => c.id === id) || null
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
async update(id: number, updates: Partial<Category>): Promise<void> {
|
||||
// 数据验证
|
||||
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<void> {
|
||||
// 检查是否为默认分类
|
||||
const category = await this.findById(id)
|
||||
if (category?.is_default) {
|
||||
throw new Error('无法删除默认分类')
|
||||
}
|
||||
|
||||
await databaseService.deleteCategory(id)
|
||||
}
|
||||
|
||||
// 获取默认分类
|
||||
async getDefaults(): Promise<Category[]> {
|
||||
const categories = await this.findAll()
|
||||
return categories.filter(c => c.is_default)
|
||||
}
|
||||
|
||||
// 获取自定义分类
|
||||
async getCustom(): Promise<Category[]> {
|
||||
const categories = await this.findAll()
|
||||
return categories.filter(c => !c.is_default)
|
||||
}
|
||||
|
||||
// 设置默认分类
|
||||
async setAsDefault(id: number): Promise<void> {
|
||||
await this.update(id, { is_default: true })
|
||||
}
|
||||
|
||||
// 取消默认分类
|
||||
async unsetAsDefault(id: number): Promise<void> {
|
||||
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<any[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await databaseService.deleteCategory(id)
|
||||
return false
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类的交易数量
|
||||
async getTransactionCount(id: number): Promise<number> {
|
||||
const stats = await this.getStats()
|
||||
const categoryStats = stats.find(s => s.category_id === id)
|
||||
return categoryStats?.transaction_count || 0
|
||||
}
|
||||
|
||||
// 批量创建分类
|
||||
async batchCreate(categories: Omit<Category, 'id'>[]): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
|
||||
for (const category of categories) {
|
||||
const id = await this.create(category)
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// 重置为默认分类
|
||||
async resetToDefaults(): Promise<void> {
|
||||
// 删除所有自定义分类
|
||||
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<Category[]> {
|
||||
return await this.findAll()
|
||||
}
|
||||
|
||||
// 导入分类数据
|
||||
async import(categories: Omit<Category, 'id'>[]): Promise<number[]> {
|
||||
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
|
||||
}
|
||||
}
|
227
frontend/src/repositories/TransactionRepository.ts
Normal file
227
frontend/src/repositories/TransactionRepository.ts
Normal file
@@ -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<Transaction, 'id'>): Promise<number> {
|
||||
// 数据验证
|
||||
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<Transaction, 'id'>)
|
||||
}
|
||||
|
||||
// 获取交易列表
|
||||
async findAll(pagination: PaginationParams = { page: 1, limit: 20, offset: 0 }): Promise<TransactionWithDetails[]> {
|
||||
const { limit, offset } = pagination
|
||||
return await databaseService.getTransactions(limit, offset) as TransactionWithDetails[]
|
||||
}
|
||||
|
||||
// 根据ID获取交易
|
||||
async findById(id: number): Promise<TransactionWithDetails | null> {
|
||||
return await databaseService.getTransactionById(id) as TransactionWithDetails | null
|
||||
}
|
||||
|
||||
// 更新交易
|
||||
async update(id: number, updates: Partial<Transaction>): Promise<void> {
|
||||
// 数据验证(只验证提供的字段)
|
||||
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<void> {
|
||||
await databaseService.deleteTransaction(id)
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
async batchDelete(ids: number[]): Promise<void> {
|
||||
await databaseService.batchDeleteTransactions(ids)
|
||||
}
|
||||
|
||||
// 搜索交易
|
||||
async search(
|
||||
query: string,
|
||||
pagination: PaginationParams = { page: 1, limit: 20, offset: 0 }
|
||||
): Promise<TransactionWithDetails[]> {
|
||||
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<TransactionWithDetails[]> {
|
||||
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<TransactionWithDetails[]> {
|
||||
return await databaseService.getTransactions(limit, 0) as TransactionWithDetails[]
|
||||
}
|
||||
|
||||
// 获取今日交易
|
||||
async getToday(): Promise<TransactionWithDetails[]> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return await databaseService.getTransactionsByDateRange(today, today, 100, 0) as TransactionWithDetails[]
|
||||
}
|
||||
|
||||
// 获取本月交易
|
||||
async getThisMonth(): Promise<TransactionWithDetails[]> {
|
||||
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<TransactionWithDetails[]> {
|
||||
const allTransactions = await databaseService.getTransactions(10000, 0) as TransactionWithDetails[]
|
||||
return allTransactions.filter(t => t.sync_status === 'pending')
|
||||
}
|
||||
|
||||
// 标记为已同步
|
||||
async markAsSynced(ids: number[]): Promise<void> {
|
||||
const updates = ids.map(id => ({
|
||||
id,
|
||||
data: { sync_status: 'synced' as const }
|
||||
}))
|
||||
await databaseService.batchUpdateTransactions(updates)
|
||||
}
|
||||
|
||||
// 标记为冲突
|
||||
async markAsConflict(ids: number[]): Promise<void> {
|
||||
const updates = ids.map(id => ({
|
||||
id,
|
||||
data: { sync_status: 'conflict' as const }
|
||||
}))
|
||||
await databaseService.batchUpdateTransactions(updates)
|
||||
}
|
||||
|
||||
// 获取交易数量
|
||||
async count(filters?: TransactionFilters): Promise<number> {
|
||||
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<Transaction, 'id'>): Promise<TransactionWithDetails[]> {
|
||||
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分钟内
|
||||
)
|
||||
}
|
||||
}
|
173
frontend/src/repositories/index.ts
Normal file
173
frontend/src/repositories/index.ts
Normal file
@@ -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<T>(operations: (() => Promise<T>)[]): Promise<T[]> {
|
||||
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<T>(callback: (repos: RepositoryManager) => Promise<T>): Promise<T> {
|
||||
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()
|
@@ -271,6 +271,385 @@ class DatabaseService {
|
||||
return result.changes?.lastId || 0;
|
||||
}
|
||||
|
||||
// 扩展的分类 CRUD 操作
|
||||
async updateCategory(id: number, category: Partial<Category>): Promise<void> {
|
||||
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<void> {
|
||||
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<Category[]> {
|
||||
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<Account>): Promise<void> {
|
||||
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<void> {
|
||||
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<Transaction | null> {
|
||||
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<Transaction[]> {
|
||||
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<Transaction[]> {
|
||||
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<Transaction[]> {
|
||||
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<Transaction[]> {
|
||||
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<Transaction[]> {
|
||||
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<any> {
|
||||
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<any[]> {
|
||||
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<any[]> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
await this.db.execute(`PRAGMA user_version = ${version}`);
|
||||
}
|
||||
|
||||
async migrateDatabase(): Promise<void> {
|
||||
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<Transaction, 'id'>[]): Promise<number[]> {
|
||||
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<Transaction> }[]): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<any> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (this.db) {
|
||||
await this.db.close();
|
||||
|
@@ -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<string | null>(null)
|
||||
|
||||
// 数据状态
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const transactions = ref<TransactionWithDetails[]>([])
|
||||
const categories = ref<Category[]>([])
|
||||
const accounts = ref<Account[]>([])
|
||||
|
||||
// 离线状态
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const syncStatus = ref<SyncStatus>({
|
||||
isOnline: navigator.onLine,
|
||||
pendingChanges: 0,
|
||||
syncInProgress: false
|
||||
})
|
||||
|
||||
// 缓存状态
|
||||
const lastRefreshTime = ref<Date | null>(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<Transaction, 'id'>) {
|
||||
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<Transaction>) {
|
||||
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<Category, 'id'>) {
|
||||
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<Category>) {
|
||||
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<Account, 'id'>) {
|
||||
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<Account>) {
|
||||
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
|
||||
}
|
||||
})
|
354
frontend/src/stores/offline.ts
Normal file
354
frontend/src/stores/offline.ts
Normal file
@@ -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<string>('unknown')
|
||||
const lastOnlineTime = ref<Date | null>(null)
|
||||
const lastOfflineTime = ref<Date | null>(null)
|
||||
|
||||
// 离线操作队列
|
||||
const pendingActions = ref<OfflineAction[]>([])
|
||||
const failedActions = ref<OfflineAction[]>([])
|
||||
|
||||
// 同步状态
|
||||
const isSyncing = ref(false)
|
||||
const syncProgress = ref(0)
|
||||
const syncError = ref<string | null>(null)
|
||||
const lastSyncTime = ref<Date | null>(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<OfflineAction, 'id' | 'timestamp' | 'retryCount'>) {
|
||||
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
|
||||
}
|
||||
})
|
280
frontend/src/types/index.ts
Normal file
280
frontend/src/types/index.ts
Normal file
@@ -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<T = any> {
|
||||
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<T = any> {
|
||||
data: T
|
||||
errors: Record<string, string>
|
||||
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'
|
||||
)
|
||||
}
|
375
frontend/src/utils/validation.ts
Normal file
375
frontend/src/utils/validation.ts
Normal file
@@ -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<Transaction>): 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<Category>): 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<Account>): 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<Transaction>): Partial<Transaction> {
|
||||
const sanitized: Partial<Transaction> = {}
|
||||
|
||||
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<Category>): Partial<Category> {
|
||||
const sanitized: Partial<Category> = {}
|
||||
|
||||
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<Account>): Partial<Account> {
|
||||
const sanitized: Partial<Account> = {}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
@@ -37,10 +37,61 @@
|
||||
</ul>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 离线状态卡片 -->
|
||||
<BaseCard>
|
||||
<h3 class="font-semibold text-text-primary mb-3">连接状态</h3>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="connectionStatus.color === 'green' ? 'bg-accent-green' : 'bg-orange-500'"
|
||||
></div>
|
||||
<span class="text-text-secondary">{{ connectionStatus.message }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{{ offlineStats.connectionType }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="offlineStats.totalCount > 0" class="text-sm text-text-secondary">
|
||||
待同步: {{ offlineStats.pendingCount }} | 失败: {{ offlineStats.failedCount }}
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 数据统计卡片 -->
|
||||
<BaseCard>
|
||||
<h3 class="font-semibold text-text-primary mb-3">数据概览</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-accent-green">
|
||||
¥{{ databaseStore.monthlyStats.income.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">本月收入</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-accent-red">
|
||||
¥{{ databaseStore.monthlyStats.expense.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">本月支出</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-border text-center">
|
||||
<div class="text-lg font-semibold"
|
||||
:class="databaseStore.totalBalance >= 0 ? 'text-accent-green' : 'text-accent-red'">
|
||||
¥{{ databaseStore.totalBalance.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">总余额</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 按钮演示 -->
|
||||
<div class="flex gap-4">
|
||||
<BaseButton variant="primary" class="flex-1"> 主要按钮 </BaseButton>
|
||||
<BaseButton variant="secondary" class="flex-1"> 次要按钮 </BaseButton>
|
||||
<BaseButton variant="primary" class="flex-1" @click="addTestTransaction">
|
||||
添加测试交易
|
||||
</BaseButton>
|
||||
<BaseButton variant="secondary" class="flex-1">
|
||||
查看交易
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 输入框演示 -->
|
||||
@@ -58,10 +109,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
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 testInput = ref('')
|
||||
const databaseStore = useDatabaseStore()
|
||||
const offlineStore = useOfflineStore()
|
||||
|
||||
// 计算属性
|
||||
const connectionStatus = computed(() => offlineStore.connectionStatus)
|
||||
const offlineStats = computed(() => offlineStore.getOfflineStats())
|
||||
|
||||
// 测试添加交易
|
||||
async function addTestTransaction() {
|
||||
try {
|
||||
await databaseStore.addTransaction({
|
||||
amount: Math.floor(Math.random() * 100) + 1,
|
||||
type: 'expense',
|
||||
category_id: 1,
|
||||
account_id: 1,
|
||||
merchant: '测试商户',
|
||||
description: '测试交易',
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
console.log('测试交易添加成功')
|
||||
} catch (error) {
|
||||
console.error('添加测试交易失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 确保数据库已初始化
|
||||
if (!databaseStore.isInitialized) {
|
||||
databaseStore.initialize()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@@ -1,20 +1,20 @@
|
||||
<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">
|
||||
<div class="max-w-md mx-auto px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-text-primary">交易记录</h1>
|
||||
</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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
Reference in New Issue
Block a user