Article

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. 快速记账

功能描述: 提供简化的记账流程,支持一键记账

实现流程:

  1. 选择记账类型(收入/支出)
  2. 输入金额
  3. 选择分类
  4. 选择资产账户
  5. 添加备注(可选)
  6. 保存记录

代码示例:

// 创建记账记录
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. 智能分类

功能描述: 根据历史记录和用户习惯推荐分类

实现流程:

  1. 分析用户历史记账数据
  2. 计算分类使用频率
  3. 根据金额范围推荐分类
  4. 提供快速选择选项

代码示例:

// 分类推荐接口
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. 批量操作

功能描述: 支持批量创建、编辑、删除记账记录

实现流程:

  1. 选择多条记录
  2. 执行批量操作
  3. 显示操作结果
  4. 处理失败记录

代码示例:

// 批量操作请求
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. 离线记账

功能描述: 支持无网络环境下的记账功能

实现流程:

  1. 检测网络状态
  2. 离线时保存到本地存储
  3. 网络恢复时同步到服务器
  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 记账模块 - 让日常记账更简单、更高效 💰