Penny Lens 记账模块详解

2025年1月27日
8 分钟阅读
作者:Penny Lens Team

记账模块

💰 概述

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