Penny Lens 记账模块详解
2026年6月24日 · 8 分钟阅读 · ixNieStudio
详细介绍Penny Lens的记账模块,包括快速记账、智能分类、批量操作、离线记账、键盘组件等核心功能。
记账模块
💰 概述
记账模块是 Penny Lens 应用的核心功能模块,负责用户日常收支记录、分类管理、批量操作等功能。提供快速记账、智能分类、离线记账等特性,帮助用户轻松管理个人财务。
🏗️ 模块架构
核心组件
src/├── pages/accounting/ # 记账页面│ ├── index.vue # 记账主页│ └── components/ # 记账页面组件├── components/Keyboard/ # 键盘组件│ ├── Keyboard.vue # 主键盘│ ├── BattleKeyboard.vue # 战斗键盘│ └── TransferKeyboard.vue # 转账键盘├── store/accounting.ts # 记账状态管理├── store/accountingCategory.ts # 记账分类状态├── services/api/accounting.ts # 记账API服务├── services/api/category.ts # 分类API服务├── utils/savingCalculator.ts # 储蓄计算器├── types/accounting.ts # 记账类型定义└── types/accountingCategoryModel.ts # 分类模型📝 记账类型
支持的记账类型
// 记账类型枚举const AccountingType = { INCOME: 'income', // 收入 EXPENSE: 'expense', // 支出 TRANSFER: 'transfer' // 转账} as const
type AccountingType = typeof AccountingType[keyof typeof AccountingType]记账记录数据结构
// 记账记录接口interface AccountingRecord { id: string; // 记录ID type: AccountingType; // 记账类型 amountCents: number; // 金额(分) categoryCode: string; // 分类代码 assetId: string; // 资产ID targetAssetId?: string; // 目标资产ID(转账时使用) remark?: string; // 备注 date: number; // 记账日期 shouldCountAmortization?: boolean; // 是否计入摊销 createdAt: number; // 创建时间 updatedAt: number; // 更新时间}
// 记账表单数据interface AccountingFormData { type: AccountingType; amount: string; // 金额字符串 categoryCode: string; assetId: string; targetAssetId?: string; remark: string; date: Date; shouldCountAmortization: boolean;}🔧 核心功能
1. 快速记账
功能描述: 提供简化的记账流程,支持一键记账
实现流程:
- 选择记账类型(收入/支出)
- 输入金额
- 选择分类
- 选择资产账户
- 添加备注(可选)
- 保存记录
代码示例:
// 创建记账记录async function createAccountingRecord(data: AccountingFormData): Promise<AccountingRecord> { // 验证表单数据 const validation = validateAccountingForm(data) if (!validation.isValid) { throw new Error(validation.errors.join(', ')) }
// 转换数据格式 const recordData: Omit<AccountingRecord, 'id' | 'createdAt' | 'updatedAt'> = { type: data.type, amountCents: parseFloat(data.amount) * 100, categoryCode: data.categoryCode, assetId: data.assetId, targetAssetId: data.targetAssetId, remark: data.remark, date: data.date.getTime(), shouldCountAmortization: data.shouldCountAmortization }
// 发送请求 const response = await accountingApi.createRecord(recordData) if (response.code === 200) { return response.data } throw new Error(response.message)}
// 表单验证function validateAccountingForm(data: AccountingFormData): ValidationResult { const errors: string[] = []
if (!data.amount || parseFloat(data.amount) <= 0) { errors.push('金额必须大于0') }
if (!data.categoryCode) { errors.push('请选择分类') }
if (!data.assetId) { errors.push('请选择资产账户') }
if (data.type === AccountingType.TRANSFER && !data.targetAssetId) { errors.push('转账必须选择目标账户') }
if (data.type === AccountingType.TRANSFER && data.assetId === data.targetAssetId) { errors.push('转出和转入账户不能相同') }
return { isValid: errors.length === 0, errors }}2. 智能分类
功能描述: 根据历史记录和用户习惯推荐分类
实现流程:
- 分析用户历史记账数据
- 计算分类使用频率
- 根据金额范围推荐分类
- 提供快速选择选项
代码示例:
// 分类推荐接口interface CategoryRecommendation { categoryCode: string; categoryName: string; confidence: number; // 推荐置信度 0-1 reason: string; // 推荐原因}
// 获取分类推荐async function getCategoryRecommendations( type: AccountingType, amount: number): Promise<CategoryRecommendation[]> { const response = await categoryApi.getRecommendations({ type, amount, userId: getCurrentUserId() })
return response.data.recommendations}
// 计算分类使用频率function calculateCategoryFrequency( records: AccountingRecord[], type: AccountingType): Map<string, number> { const frequency = new Map<string, number>()
records .filter(record => record.type === type) .forEach(record => { const count = frequency.get(record.categoryCode) || 0 frequency.set(record.categoryCode, count + 1) })
return frequency}3. 批量操作
功能描述: 支持批量创建、编辑、删除记账记录
实现流程:
- 选择多条记录
- 执行批量操作
- 显示操作结果
- 处理失败记录
代码示例:
// 批量操作请求interface BatchOperationRequest { action: 'create' | 'update' | 'delete'; records: AccountingRecord[];}
// 批量创建记录async function batchCreateRecords(records: Omit<AccountingRecord, 'id'>[]): Promise<BatchOperationResult> { const results: BatchOperationResult = { success: [], failed: [] }
for (const record of records) { try { const createdRecord = await createAccountingRecord(record) results.success.push(createdRecord) } catch (error) { results.failed.push({ record, error: error.message }) } }
return results}
// 批量删除记录async function batchDeleteRecords(recordIds: string[]): Promise<BatchOperationResult> { const results: BatchOperationResult = { success: [], failed: [] }
for (const id of recordIds) { try { await accountingApi.deleteRecord(id) results.success.push({ id }) } catch (error) { results.failed.push({ record: { id }, error: error.message }) } }
return results}4. 离线记账
功能描述: 支持无网络环境下的记账功能
实现流程:
- 检测网络状态
- 离线时保存到本地存储
- 网络恢复时同步到服务器
- 处理同步冲突
代码示例:
// 离线记账管理export const offlineAccountingManager = { // 检查网络状态 isOnline(): boolean { return uni.getNetworkType().then(res => res.networkType !== 'none') },
// 保存离线记录 async saveOfflineRecord(record: AccountingFormData): Promise<void> { const offlineRecords = await this.getOfflineRecords() offlineRecords.push({ ...record, id: 'offline_' + Date.now(), isOffline: true }) await uni.setStorageSync('offline_accounting_records', offlineRecords) },
// 获取离线记录 async getOfflineRecords(): Promise<AccountingFormData[]> { return uni.getStorageSync('offline_accounting_records') || [] },
// 同步离线记录 async syncOfflineRecords(): Promise<void> { const offlineRecords = await this.getOfflineRecords() const syncResults = await batchCreateRecords(offlineRecords)
// 清除已同步的记录 const remainingRecords = offlineRecords.filter(record => !syncResults.success.some(success => success.id === record.id) )
await uni.setStorageSync('offline_accounting_records', remainingRecords) }}🎹 键盘组件
主键盘组件
功能描述: 提供数字输入和记账操作的主键盘
代码示例:
<template> <view class="keyboard-container"> <!-- 操作栏 --> <view class="action-bar"> <view class="action-button" :class="{ active: shouldCountAmortization }" @click="toggleAmortization"> <Iconify icon="i-mdi-check-bold" size="14" /> <text>计入成本</text> </view> <view class="action-button asset-button" @click="openAssetSelector"> <Iconify icon="i-mdi-wallet" size="14" /> <text>{{ getAssetDisplayName(selectedAssetId) }}</text> </view> </view>
<!-- 金额显示 --> <view class="display"> <view class="record-type" @click="toggleRecordType"> <view :class="{ active: !isIncome }">支出</view> <view class="type-split">/</view> <view :class="{ active: isIncome }">收入</view> </view> <view class="result">{{ amountValue || '0' }}</view> </view>
<!-- 数字键盘 --> <view class="keyboard"> <view class="row" v-for="(row, rowIndex) in keyboardRows" :key="rowIndex"> <view v-for="key in row" :key="key" class="key" :class="getKeyClass(key)" @click="handleKeyPress(key)" > {{ key }} </view> </view> </view>
<!-- 分类选择 --> <view class="category-row"> <view class="category-key" @click="openCategorySelector"> <Iconify :icon="getCategoryIcon(selectedCategoryCode)" size="16" /> <text>{{ getCategoryName(selectedCategoryCode) }}</text> </view> <view class="remark-key"> <input v-model="remark" placeholder="添加备注..." /> </view> </view>
<!-- 日期选择 --> <view class="date-row"> <view class="date-key" @click="openDateSelector"> <Iconify icon="i-mdi-calendar" size="16" /> <text>{{ formatDate(selectedDate) }}</text> </view> <view class="confirm-key" @click="confirmRecord"> <text>确定</text> </view> </view> </view></template>
<script setup lang="ts">interface Props { initialType?: AccountingType; initialAmount?: string; initialCategory?: string; initialAsset?: string;}
interface Emits { confirm: [data: AccountingFormData]; cancel: [];}
const props = withDefaults(defineProps<Props>(), { initialType: AccountingType.EXPENSE})
const emit = defineEmits<Emits>()
// 响应式数据const isIncome = ref(props.initialType === AccountingType.INCOME)const amountValue = ref(props.initialAmount || '')const selectedCategoryCode = ref(props.initialCategory || '')const selectedAssetId = ref(props.initialAsset || '')const remark = ref('')const selectedDate = ref(new Date())const shouldCountAmortization = ref(false)
// 键盘布局const keyboardRows = [ ['7', '8', '9'], ['4', '5', '6'], ['1', '2', '3'], ['0', '.', '⌫']]
// 处理按键function handleKeyPress(key: string) { if (key === '⌫') { amountValue.value = amountValue.value.slice(0, -1) } else if (key === '.') { if (!amountValue.value.includes('.')) { amountValue.value += '.' } } else { amountValue.value += key }}
// 切换记账类型function toggleRecordType() { isIncome.value = !isIncome.value}
// 确认记录function confirmRecord() { const formData: AccountingFormData = { type: isIncome.value ? AccountingType.INCOME : AccountingType.EXPENSE, amount: amountValue.value, categoryCode: selectedCategoryCode.value, assetId: selectedAssetId.value, remark: remark.value, date: selectedDate.value, shouldCountAmortization: shouldCountAmortization.value }
emit('confirm', formData)}</script>转账键盘组件
功能描述: 专门用于转账操作的键盘组件
代码示例:
<template> <view class="transfer-keyboard"> <!-- 转账信息显示 --> <view class="transfer-info"> <view class="from-asset"> <text class="label">从</text> <view class="asset-info" @click="selectFromAsset"> <Iconify :icon="getAssetIcon(fromAssetId)" size="20" /> <text>{{ getAssetName(fromAssetId) }}</text> </view> </view>
<view class="transfer-arrow"> <Iconify icon="i-mdi-arrow-right" size="24" /> </view>
<view class="to-asset"> <text class="label">到</text> <view class="asset-info" @click="selectToAsset"> <Iconify :icon="getAssetIcon(toAssetId)" size="20" /> <text>{{ getAssetName(toAssetId) }}</text> </view> </view> </view>
<!-- 金额输入 --> <view class="amount-display"> <text class="amount">{{ amountValue || '0' }}</text> <text class="currency">元</text> </view>
<!-- 数字键盘 --> <view class="keyboard"> <!-- 键盘行 --> </view>
<!-- 操作按钮 --> <view class="actions"> <button class="btn-secondary" @click="handleCancel">取消</button> <button class="btn-primary" @click="confirmTransfer">确认转账</button> </view> </view></template>
<script setup lang="ts">interface Emits { confirm: [data: TransferFormData]; cancel: [];}
const emit = defineEmits<Emits>()
const fromAssetId = ref('')const toAssetId = ref('')const amountValue = ref('')const remark = ref('')
// 确认转账function confirmTransfer() { const formData: TransferFormData = { fromAssetId: fromAssetId.value, toAssetId: toAssetId.value, amount: amountValue.value, remark: remark.value, date: new Date() }
emit('confirm', formData)}</script>📊 分类管理
分类数据结构
// 分类接口interface AccountingCategory { code: string; // 分类代码 name: string; // 分类名称 type: AccountingType; // 分类类型 parentCode?: string; // 父分类代码 icon: string; // 分类图标 color: string; // 分类颜色 isDefault: boolean; // 是否默认分类 isActive: boolean; // 是否激活 sortOrder: number; // 排序}
// 分类树结构interface CategoryTree { category: AccountingCategory; children: CategoryTree[];}分类管理功能
代码示例:
// 分类管理服务export const categoryService = { // 获取分类列表 async getCategoryList(type?: AccountingType): Promise<AccountingCategory[]> { const response = await categoryApi.getCategoryList({ type }) return response.data.list },
// 创建分类 async createCategory(categoryData: Omit<AccountingCategory, 'code'>): Promise<AccountingCategory> { const response = await categoryApi.createCategory(categoryData) return response.data },
// 更新分类 async updateCategory(code: string, data: Partial<AccountingCategory>): Promise<AccountingCategory> { const response = await categoryApi.updateCategory(code, data) return response.data },
// 删除分类 async deleteCategory(code: string): Promise<void> { const response = await categoryApi.deleteCategory(code) if (response.code !== 200) { throw new Error(response.message) } },
// 构建分类树 buildCategoryTree(categories: AccountingCategory[]): CategoryTree[] { const categoryMap = new Map<string, CategoryTree>() const roots: CategoryTree[] = []
// 创建所有节点 categories.forEach(category => { categoryMap.set(category.code, { category, children: [] }) })
// 构建树结构 categories.forEach(category => { const node = categoryMap.get(category.code)!
if (category.parentCode) { const parent = categoryMap.get(category.parentCode) if (parent) { parent.children.push(node) } } else { roots.push(node) } })
return roots }}🔄 状态管理
Pinia Store
功能描述: 使用Pinia管理记账状态
代码示例:
// 记账状态管理export const useAccountingStore = defineStore('accounting', () => { // 状态 const records = ref<AccountingRecord[]>([]) const categories = ref<AccountingCategory[]>([]) const isLoading = ref(false) const currentRecord = ref<AccountingRecord | null>(null)
// 计算属性 const incomeRecords = computed(() => records.value.filter(record => record.type === AccountingType.INCOME) )
const expenseRecords = computed(() => records.value.filter(record => record.type === AccountingType.EXPENSE) )
const transferRecords = computed(() => records.value.filter(record => record.type === AccountingType.TRANSFER) )
const totalIncome = computed(() => incomeRecords.value.reduce((sum, record) => sum + record.amountCents, 0) )
const totalExpense = computed(() => expenseRecords.value.reduce((sum, record) => sum + record.amountCents, 0) )
// 操作 const fetchRecords = async (params?: AccountingListRequest): Promise<void> => { isLoading.value = true try { const response = await accountingApi.getRecordList(params) records.value = response.data.list } catch (error) { console.error('获取记账记录失败:', error) throw error } finally { isLoading.value = false } }
const createRecord = async (data: AccountingFormData): Promise<AccountingRecord> => { try { const record = await accountingService.createRecord(data) records.value.unshift(record) return record } catch (error) { console.error('创建记账记录失败:', error) throw error } }
const updateRecord = async (id: string, data: Partial<AccountingRecord>): Promise<AccountingRecord> => { try { const updatedRecord = await accountingService.updateRecord(id, data) const index = records.value.findIndex(record => record.id === id) if (index !== -1) { records.value[index] = updatedRecord } return updatedRecord } catch (error) { console.error('更新记账记录失败:', error) throw error } }
const deleteRecord = async (id: string): Promise<void> => { try { await accountingService.deleteRecord(id) records.value = records.value.filter(record => record.id !== id) } catch (error) { console.error('删除记账记录失败:', error) throw error } }
const fetchCategories = async (type?: AccountingType): Promise<void> => { try { const categoryList = await categoryService.getCategoryList(type) categories.value = categoryList } catch (error) { console.error('获取分类列表失败:', error) throw error } }
return { records, categories, isLoading, currentRecord, incomeRecords, expenseRecords, transferRecords, totalIncome, totalExpense, fetchRecords, createRecord, updateRecord, deleteRecord, fetchCategories }})📚 最佳实践
1. 用户体验
- 快速响应输入
- 智能分类推荐
- 便捷的批量操作
- 离线记账支持
2. 数据一致性
- 实时更新资产余额
- 同步记账记录
- 处理并发操作
- 数据验证和错误处理
3. 性能优化
- 缓存分类数据
- 懒加载历史记录
- 优化键盘渲染
- 减少不必要的API调用
4. 可维护性
- 模块化组件设计
- 统一的错误处理
- 完整的类型定义
- 充分的测试覆盖
Penny Lens 记账模块 - 让日常记账更简单、更高效 💰