记账模块
💰 概述
记账模块是 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 记账模块 - 让日常记账更简单、更高效 💰
