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:
2025-08-14 15:46:13 +08:00
parent 49fcc374f6
commit 0920667719
15 changed files with 2878 additions and 56 deletions

View File

@@ -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
- 创建离线模式的用户界面和提示
- 建立数据同步标记和冲突检测机制

View File

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

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

View File

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

View 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 中实现')
}
}

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

View 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分钟内
)
}
}

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

View File

@@ -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();

View File

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

View 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
View 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'
)
}

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

View File

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

View File

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