第12篇:成本核算系统

教程目标

通过本篇教程,你将学会:

  • 理解成本核算数据模型设计
  • 实现成本记录的增删改查
  • 创建成本核算列表页面
  • 创建添加成本记录页面
  • 实现成本统计与分析功能
  • 管理成本分类与筛选

完成本教程后,你将拥有完整的农业成本核算管理功能。


一、成本核算数据模型

在实现成本核算功能之前,我们需要了解成本数据模型的结构。成本核算服务已在 CostAccountingService.ets 中实现。

1.1 查看成本数据模型

文件位置:entry/src/main/ets/services/CostAccountingService.ets(第8-52行)

操作说明:

  1. 打开 entry/src/main/ets/services/CostAccountingService.ets
  2. 查看成本相关的接口定义
/**
 * 成本记录接口
 */
export interface CostAccountingRecord {
  id: string;              // 记录唯一标识
  fieldId: string;         // 关联地块ID
  fieldName: string;       // 地块名称
  category: string;        // 成本分类(种子/肥料/农药/人工)
  item: string;            // 具体项目名称
  quantity: number;        // 数量
  unit: string;            // 单位
  unitPrice: number;       // 单价
  totalCost: number;       // 总成本
  date: number;            // 发生日期
  supplier?: string;       // 供应商(可选)
  notes?: string;          // 备注(可选)
  createdAt: number;       // 创建时间
}

/**
 * 成本统计信息接口
 */
export interface CostStatistics {
  totalCost: number;                    // 总成本
  recordCount: number;                  // 记录数量
  categoryBreakdown: CategoryCost[];    // 分类成本明细
  monthlyTrend: MonthlyCost[];          // 月度成本趋势
}

/**
 * 分类成本接口
 */
export interface CategoryCost {
  category: string;        // 成本类别
  cost: number;            // 类别总成本
  percentage: number;      // 占比百分比
}

/**
 * 月度成本接口
 */
export interface MonthlyCost {
  month: string;           // 月份(格式:YYYY-MM)
  cost: number;            // 该月总成本
}

模型设计要点:

设计要点 说明
地块关联 通过fieldIdfieldName关联地块,便于按地块统计成本
自动计算 totalCost = quantity × unitPrice,提高录入效率
分类管理 支持种子、肥料、农药、人工、机械、水电等多种成本类型
统计分析 提供分类占比、月度趋势等多维度统计数据
可选字段 使用?标记非必填字段(如supplier、notes)

1.2 成本分类说明

本系统支持以下成本分类:

成本分类体系:
├── 种子: 🌱 (玉米种子、小麦种子、水稻种子等)
├── 肥料: 🌿 (复合肥、尿素、磷肥、钾肥等)
├── 农药: 🛡️ (杀虫剂、杀菌剂、除草剂等)
├── 人工: 👨‍🌾 (播种用工、施肥用工、收获用工等)
├── 机械: 🚜 (耕地费、播种费、收割费等)
├── 水电: 💧 (灌溉用水、生产用电等)
└── 其他: 📋 (农膜、大棚材料、工具设备等)

每个分类都有对应的项目选项和单位选项,方便快速录入。


二、成本核算服务

CostAccountingService提供了完整的成本管理功能,包括基础CRUD、筛选查询和统计分析。

2.1 CostAccountingService核心方法

文件位置:entry/src/main/ets/services/CostAccountingService.ets

基础CRUD方法:

/**
 * 获取所有成本记录
 */
async getAllRecords(): Promise<CostAccountingRecord[]>

/**
 * 添加成本记录
 */
async addRecord(record: CostAccountingRecord): Promise<boolean>

/**
 * 更新成本记录
 */
async updateRecord(record: CostAccountingRecord): Promise<boolean>

/**
 * 删除成本记录
 */
async deleteRecord(id: string): Promise<boolean>

筛选查询方法:

/**
 * 根据分类获取记录
 * @param category 成本分类('全部'返回所有记录)
 */
async getRecordsByCategory(category: string): Promise<CostAccountingRecord[]>

/**
 * 根据地块获取记录
 * @param fieldId 地块ID
 */
async getRecordsByField(fieldId: string): Promise<CostAccountingRecord[]>

统计分析方法:

/**
 * 获取统计信息
 * 包含总成本、记录数、分类占比、月度趋势
 */
async getStatistics(): Promise<CostStatistics>

/**
 * 获取本月成本
 */
async getMonthlyTotalCost(): Promise<number>

/**
 * 获取地块成本统计
 * @param fieldId 地块ID
 */
async getFieldCostStatistics(fieldId: string): Promise<number>

2.2 统计功能实现原理

分类成本统计:

// 使用Map结构累加各分类的成本
const categoryMap = new Map<string, number>();
for (const record of records) {
  const current = categoryMap.get(record.category) || 0;
  categoryMap.set(record.category, current + record.totalCost);
}

// 计算百分比并排序
categoryBreakdown.sort((a, b) => b.cost - a.cost);  // 按成本降序

月度趋势统计:

// 初始化最近6个月的数据结构
const monthlyMap = new Map<string, number>();
for (let i = 5; i >= 0; i--) {
  const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
  const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
  monthlyMap.set(monthKey, 0);
}

// 累加各月的成本
for (const record of records) {
  const date = new Date(record.date);
  const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
  if (monthlyMap.has(monthKey)) {
    const current = monthlyMap.get(monthKey) || 0;
    monthlyMap.set(monthKey, current + record.totalCost);
  }
}

三、创建成本核算列表页面

现在开始实现成本核算的列表页面,展示成本记录和统计数据。

3.1 创建页面文件

文件路径:entry/src/main/ets/pages/Services/CostAccountingPage.ets

页面功能:

  • 展示总成本统计卡片
  • 支持按分类筛选记录
  • 显示成本分类占比分析
  • 长按删除成本记录
  • 点击进入编辑页面

3.2 完整页面代码

/**
 * 成本核算页面
 * 展示和管理农业生产成本
 */

import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { CostAccountingService, CostAccountingRecord, CostStatistics, CategoryCost } from '../../services/CostAccountingService';

@Entry
@ComponentV2
export struct CostAccountingPage {
  // ===== 状态变量 =====
  @Local costRecords: CostAccountingRecord[] = [];      // 成本记录列表
  @Local statistics: CostStatistics | null = null;      // 统计数据
  @Local selectedCategory: string = '全部';             // 选中的分类
  @Local isLoading: boolean = true;                     // 加载状态
  @Local showAnalysis: boolean = false;                 // 是否显示分析图表

  // ===== 服务实例 =====
  private costService: CostAccountingService = CostAccountingService.getInstance();

  // ===== 成本分类选项 =====
  private costCategories = ['全部', '种子', '肥料', '农药', '人工', '机械', '水电', '其他'];

  /**
   * 生命周期:页面即将出现
   */
  aboutToAppear(): void {
    this.loadData();
  }

  /**
   * 生命周期:页面显示时刷新数据
   */
  onPageShow(): void {
    this.loadData();
  }

  /**
   * 加载成本数据
   */
  async loadData(): Promise<void> {
    try {
      this.costRecords = await this.costService.getAllRecords();
      this.statistics = await this.costService.getStatistics();
      this.isLoading = false;
    } catch (error) {
      console.error('Failed to load cost data:', error);
      this.isLoading = false;
    }
  }

  /**
   * 页面主体结构
   */
  build() {
    Column() {
      this.buildHeader()

      if (this.isLoading) {
        this.buildLoading()
      } else {
        Column() {
          this.buildSummary()
          if (this.showAnalysis && this.statistics) {
            this.buildAnalysis()
          }
          this.buildCategoryFilter()
          this.buildCostList()
        }
        .layoutWeight(1)
      }

      this.buildAddButton()
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.background'))
  }

  /**
   * 构建顶部导航栏
   */
  @Builder
  buildHeader() {
    Row() {
      // 返回按钮
      Button('< 返回')
        .backgroundColor(Color.Transparent)
        .fontColor($r('app.color.text_primary'))
        .onClick(() => {
          router.back();
        })

      // 页面标题
      Text('成本核算')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      // 分析按钮
      Button(this.showAnalysis ? '隐藏' : '分析')
        .backgroundColor(Color.Transparent)
        .fontColor($r('app.color.primary_professional'))
        .onClick(() => {
          this.showAnalysis = !this.showAnalysis;
        })
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor($r('app.color.card_background'))
    .shadow({ radius: 4, color: $r('app.color.shadow_light'), offsetY: 2 })
  }

  /**
   * 构建加载状态
   */
  @Builder
  buildLoading() {
    Column() {
      Text('加载中...')
        .fontSize(16)
        .fontColor($r('app.color.text_secondary'))
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
  }

  /**
   * 构建成本汇总卡片
   */
  @Builder
  buildSummary() {
    Column({ space: 12 }) {
      Text('总成本')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))

      // 总成本金额
      Text(`¥${this.statistics ? this.statistics.totalCost.toFixed(2) : '0.00'}`)
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FF6B6B')  // 红色表示支出

      // 记录数量和类别数量
      Row({ space: 16 }) {
        Text(`${this.statistics ? this.statistics.recordCount : 0} 条记录`)
          .fontSize(14)
          .fontColor($r('app.color.text_secondary'))

        if (this.statistics && this.statistics.categoryBreakdown.length > 0) {
          Text(`${this.statistics.categoryBreakdown.length} 个类别`)
            .fontSize(14)
            .fontColor($r('app.color.text_secondary'))
        }
      }
    }
    .width('100%')
    .padding(24)
    .backgroundColor($r('app.color.card_background'))
    .margin(16)
    .borderRadius(12)
  }

  /**
   * 构建成本分析图表
   */
  @Builder
  buildAnalysis() {
    Column({ space: 16 }) {
      // 标题
      Text('成本分类占比')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.text_primary'))
        .width('100%')

      // 分类成本列表
      if (this.statistics && this.statistics.categoryBreakdown.length > 0) {
        Column({ space: 8 }) {
          ForEach(this.statistics.categoryBreakdown, (item: CategoryCost) => {
            Row({ space: 12 }) {
              // 分类图标
              Text(this.getCategoryIcon(item.category))
                .fontSize(16)

              // 分类名称
              Text(item.category)
                .fontSize(14)
                .fontColor($r('app.color.text_primary'))
                .width(60)

              // 进度条
              Stack() {
                // 背景条
                Row()
                  .width('100%')
                  .height(8)
                  .backgroundColor('#E0E0E0')
                  .borderRadius(4)

                // 进度条
                Row()
                  .width(`${item.percentage}%`)
                  .height(8)
                  .backgroundColor(this.getCategoryColor(item.category))
                  .borderRadius(4)
              }
              .layoutWeight(1)

              // 金额
              Text(`¥${item.cost.toFixed(0)}`)
                .fontSize(13)
                .fontColor($r('app.color.text_secondary'))
                .width(70)
                .textAlign(TextAlign.End)
            }
            .width('100%')
          })
        }
      } else {
        Text('暂无数据')
          .fontSize(14)
          .fontColor($r('app.color.text_tertiary'))
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor($r('app.color.card_background'))
    .margin({ left: 16, right: 16, bottom: 16 })
    .borderRadius(12)
  }

  /**
   * 构建分类筛选器
   */
  @Builder
  buildCategoryFilter() {
    Scroll() {
      Row({ space: 8 }) {
        ForEach(this.costCategories, (category: string) => {
          Text(category)
            .fontSize(14)
            .fontColor(this.selectedCategory === category ?
              Color.White : $r('app.color.text_primary'))
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .backgroundColor(this.selectedCategory === category ?
              $r('app.color.primary_professional') : $r('app.color.card_background'))
            .borderRadius(16)
            .onClick(() => {
              this.selectedCategory = category;
            })
        })
      }
      .padding({ left: 16, right: 16 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ bottom: 16 })
  }

  /**
   * 构建成本记录列表
   */
  @Builder
  buildCostList() {
    if (this.getFilteredCostRecords().length === 0) {
      // 空状态
      Column({ space: 16 }) {
        Text('💰')
          .fontSize(48)

        Text('还没有成本记录')
          .fontSize(16)
          .fontColor($r('app.color.text_secondary'))

        Text('点击下方按钮添加第一条记录')
          .fontSize(14)
          .fontColor($r('app.color.text_tertiary'))
      }
      .width('100%')
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
    } else {
      // 记录列表
      Scroll() {
        Column({ space: 12 }) {
          ForEach(this.getFilteredCostRecords(), (record: CostAccountingRecord) => {
            this.buildCostCard(record)
          })
        }
        .padding({ left: 16, right: 16, bottom: 16 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Auto)
    }
  }

  /**
   * 构建单个成本记录卡片
   */
  @Builder
  buildCostCard(record: CostAccountingRecord) {
    Column({ space: 12 }) {
      // 第一行:分类图标、项目名称、总成本
      Row() {
        Text(this.getCategoryIcon(record.category))
          .fontSize(20)

        Column({ space: 4 }) {
          Text(record.item)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor($r('app.color.text_primary'))

          Text(record.category)
            .fontSize(12)
            .fontColor($r('app.color.text_secondary'))
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
        .layoutWeight(1)

        Text(`¥${record.totalCost.toFixed(2)}`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF6B6B')
      }
      .width('100%')

      // 第二行:日期、数量单价、地块
      Row() {
        Text(this.formatDate(record.date))
          .fontSize(12)
          .fontColor($r('app.color.text_tertiary'))

        if (record.quantity && record.unit) {
          Text(`${record.quantity} ${record.unit} × ¥${record.unitPrice}`)
            .fontSize(12)
            .fontColor($r('app.color.text_secondary'))
            .margin({ left: 12 })
        }

        Blank()

        Text(record.fieldName)
          .fontSize(12)
          .fontColor($r('app.color.primary_professional'))
      }
      .width('100%')

      // 供应商信息(可选)
      if (record.supplier) {
        Row({ space: 4 }) {
          Text('🏪')
            .fontSize(12)
          Text(record.supplier)
            .fontSize(12)
            .fontColor($r('app.color.text_tertiary'))
        }
        .width('100%')
      }

      // 备注信息(可选)
      if (record.notes) {
        Row({ space: 4 }) {
          Text('📝')
            .fontSize(12)
          Text(record.notes)
            .fontSize(12)
            .fontColor($r('app.color.text_tertiary'))
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .width('100%')
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor($r('app.color.card_background'))
    .borderRadius(12)
    .shadow({ radius: 4, color: $r('app.color.shadow_light'), offsetY: 2 })
    .onClick(() => {
      // 点击进入编辑页面
      router.pushUrl({
        url: 'pages/Services/EditCostRecordPage',
        params: {
          record: record
        }
      });
    })
    .gesture(
      // 长按显示删除确认
      LongPressGesture()
        .onAction(() => {
          this.showDeleteConfirm(record);
        })
    )
  }

  /**
   * 构建添加按钮
   */
  @Builder
  buildAddButton() {
    Button('+ 添加成本记录')
      .width('100%')
      .height(48)
      .margin(16)
      .backgroundColor($r('app.color.primary_professional'))
      .fontColor(Color.White)
      .borderRadius(24)
      .onClick(() => {
        router.pushUrl({
          url: 'pages/Services/AddCostRecordPage'
        });
      })
  }

  /**
   * 获取筛选后的成本记录
   */
  private getFilteredCostRecords(): CostAccountingRecord[] {
    if (this.selectedCategory === '全部') {
      return this.costRecords;
    }
    return this.costRecords.filter(r => r.category === this.selectedCategory);
  }

  /**
   * 获取分类图标
   */
  private getCategoryIcon(category: string): string {
    const iconMap: Record<string, string> = {
      '种子': '🌱',
      '肥料': '🌿',
      '农药': '🛡️',
      '人工': '👨‍🌾',
      '机械': '🚜',
      '水电': '💧',
      '其他': '📋'
    };
    return iconMap[category] || '💰';
  }

  /**
   * 获取分类颜色
   */
  private getCategoryColor(category: string): string {
    const colorMap: Record<string, string> = {
      '种子': '#4CAF50',
      '肥料': '#8BC34A',
      '农药': '#FF9800',
      '人工': '#2196F3',
      '机械': '#9C27B0',
      '水电': '#00BCD4',
      '其他': '#607D8B'
    };
    return colorMap[category] || '#9E9E9E';
  }

  /**
   * 格式化日期
   */
  private formatDate(timestamp: number): string {
    const date = new Date(timestamp);
    const year = date.getFullYear();
    const month = (date.getMonth() + 1).toString().padStart(2, '0');
    const day = date.getDate().toString().padStart(2, '0');
    return `${year}-${month}-${day}`;
  }

  /**
   * 显示删除确认对话框
   */
  private showDeleteConfirm(record: CostAccountingRecord): void {
    promptAction.showDialog({
      title: '确认删除',
      message: `确定要删除成本记录 "${record.item}" 吗?`,
      buttons: [
        { text: '取消', color: '#999999' },
        { text: '删除', color: '#FF6B6B' }
      ]
    }).then(async (result) => {
      if (result.index === 1) {
        const success = await this.costService.deleteRecord(record.id);
        if (success) {
          await this.loadData();
          promptAction.showToast({
            message: '删除成功',
            duration: 2000
          });
        } else {
          promptAction.showToast({
            message: '删除失败',
            duration: 2000
          });
        }
      }
    }).catch(() => {
      // 用户取消
    });
  }
}

3.3 页面功能说明

功能模块 实现说明
统计卡片 显示总成本、记录数、类别数,一目了然掌握成本情况
分析图表 点击"分析"按钮显示/隐藏,展示各类别成本占比和进度条
分类筛选 横向滚动的分类标签,支持全部、种子、肥料等筛选
记录卡片 显示项目名称、金额、日期、数量单价、地块等信息
点击编辑 点击卡片进入编辑页面,可修改成本记录
长按删除 长按卡片显示删除确认对话框,避免误删除

四、创建添加成本记录页面

现在实现添加成本记录的页面,包含完整的表单验证和自动计算功能。

4.1 创建页面文件

文件路径:entry/src/main/ets/pages/Services/AddCostRecordPage.ets

页面功能:

  • 选择地块
  • 选择成本分类
  • 选择项目名称(根据分类动态显示)
  • 输入数量和单位(根据分类动态显示)
  • 输入单价
  • 自动计算总成本
  • 表单验证

4.2 完整页面代码(分段说明)

第一部分:导入和状态定义

/**
 * 添加成本记录页面
 * 用于添加新的成本记录
 */

import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { FieldService } from '../../services/FieldService';
import { CostAccountingService, CostAccountingRecord } from '../../services/CostAccountingService';
import { FieldInfo, FieldType, IrrigationSystem } from '../../models/ProfessionalAgricultureModels';

@Entry
@ComponentV2
struct AddCostRecordPage {
  // ===== 表单状态 =====
  @Local selectedCategory: string = '';          // 选中的成本类别
  @Local item: string = '';                      // 项目名称
  @Local quantity: string = '';                  // 数量
  @Local unit: string = '';                      // 单位
  @Local unitPrice: string = '';                 // 单价
  @Local supplier: string = '';                  // 供应商
  @Local notes: string = '';                     // 备注
  @Local selectedFieldId: string = '';           // 选中的地块ID
  @Local selectedFieldName: string = '';         // 选中的地块名称

  // ===== UI状态 =====
  @Local fieldList: FieldInfo[] = [];            // 地块列表
  @Local showFieldSelector: boolean = false;     // 是否显示地块选择器
  @Local showCategorySelector: boolean = false;  // 是否显示分类选择器
  @Local showUnitSelector: boolean = false;      // 是否显示单位选择器
  @Local showItemSelector: boolean = false;      // 是否显示项目选择器

  // ===== 服务实例 =====
  private fieldService: FieldService = FieldService.getInstance();
  private costService: CostAccountingService = CostAccountingService.getInstance();

  // ===== 成本分类选项 =====
  private costCategories = ['种子', '肥料', '农药', '人工', '机械', '水电', '其他'];

  // ===== 项目选项(根据分类动态显示) =====
  private itemOptions: Record<string, string[]> = {
    '种子': ['玉米种子', '小麦种子', '水稻种子', '大豆种子', '蔬菜种子', '其他种子'],
    '肥料': ['复合肥', '尿素', '磷肥', '钾肥', '有机肥', '叶面肥', '其他肥料'],
    '农药': ['杀虫剂', '杀菌剂', '除草剂', '植物生长调节剂', '其他农药'],
    '人工': ['播种用工', '施肥用工', '除草用工', '收获用工', '日常管理', '其他用工'],
    '机械': ['耕地费', '播种费', '收割费', '运输费', '灌溉费', '其他机械费'],
    '水电': ['灌溉用水', '生活用水', '生产用电', '灌溉用电', '其他水电'],
    '其他': ['农膜', '大棚材料', '工具设备', '包装材料', '运输费用', '其他费用']
  };

  // ===== 单位选项(根据分类动态显示) =====
  private unitOptions: Record<string, string[]> = {
    '种子': ['斤', '公斤', '袋', '包'],
    '肥料': ['斤', '公斤', '袋', '吨'],
    '农药': ['瓶', '袋', '升', '公斤'],
    '人工': ['天', '小时', '人次'],
    '机械': ['天', '小时', '亩'],
    '水电': ['度', '吨', '立方米'],
    '其他': ['个', '件', '次', '项']
  };

  /**
   * 生命周期:页面即将出现
   */
  aboutToAppear(): void {
    this.loadFieldList();
  }

  /**
   * 加载地块列表
   */
  async loadFieldList(): Promise<void> {
    try {
      this.fieldList = await this.fieldService.getAllFields();
      // 如果没有地块数据,添加示例数据
      if (this.fieldList.length === 0) {
        const demoField: FieldInfo = {
          id: 'field_demo_1',
          name: '东地块',
          location: '村东500米',
          area: 15,
          fieldType: FieldType.PLAIN,
          soilType: '黄土',
          irrigation: IrrigationSystem.DRIP,
          mechanizationLevel: '部分机械',
          images: [],
          createdAt: Date.now(),
          updatedAt: Date.now()
        };
        this.fieldList = [demoField];
      }
    } catch (error) {
      console.error('Failed to load field list:', error);
    }
  }

  /**
   * 页面主体结构
   */
  build() {
    Column() {
      this.buildHeader()

      Scroll() {
        Column({ space: 16 }) {
          this.buildFieldSelector()
          this.buildCategorySelector()
          this.buildItemInput()
          this.buildQuantityInput()
          this.buildPriceInput()
          this.buildSupplierInput()
          this.buildNotesInput()
          this.buildTotalCost()
        }
        .padding(16)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Auto)

      this.buildSubmitButton()
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.background'))
  }

  /**
   * 构建顶部导航栏
   */
  @Builder
  buildHeader() {
    Row() {
      Button('< 返回')
        .backgroundColor(Color.Transparent)
        .fontColor($r('app.color.text_primary'))
        .onClick(() => {
          router.back();
        })

      Text('添加成本记录')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      Text('     ')
        .width(60)
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor($r('app.color.card_background'))
    .shadow({ radius: 4, color: $r('app.color.shadow_light'), offsetY: 2 })
  }

第二部分:地块选择器

  /**
   * 构建地块选择器
   */
  @Builder
  buildFieldSelector() {
    Column({ space: 8 }) {
      Text('使用地块 *')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      // 选择框
      Row() {
        Text(this.selectedFieldName || '请选择地块')
          .fontSize(15)
          .fontColor(this.selectedFieldName ? $r('app.color.text_primary') : $r('app.color.text_tertiary'))
          .layoutWeight(1)

        Text('▼')
          .fontSize(12)
          .fontColor($r('app.color.text_tertiary'))
      }
      .width('100%')
      .height(48)
      .padding({ left: 12, right: 12 })
      .backgroundColor($r('app.color.card_background'))
      .borderRadius(8)
      .onClick(() => {
        this.showFieldSelector = !this.showFieldSelector;
        this.showCategorySelector = false;
      })

      // 下拉列表
      if (this.showFieldSelector) {
        Column() {
          ForEach(this.fieldList, (field: FieldInfo) => {
            Row() {
              Column({ space: 2 }) {
                Text(field.name)
                  .fontSize(15)
                  .fontColor($r('app.color.text_primary'))

                Text(`${field.area}亩 · ${field.location}`)
                  .fontSize(12)
                  .fontColor($r('app.color.text_tertiary'))
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              if (this.selectedFieldId === field.id) {
                Text('✓')
                  .fontSize(16)
                  .fontColor($r('app.color.primary_professional'))
              }
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .backgroundColor(this.selectedFieldId === field.id ? '#E3F2FD' : Color.Transparent)
            .onClick(() => {
              this.selectedFieldId = field.id;
              this.selectedFieldName = field.name;
              this.showFieldSelector = false;
            })
          })
        }
        .width('100%')
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .clip(true)
      }
    }
    .width('100%')
  }

第三部分:分类选择器

  /**
   * 构建成本分类选择器
   */
  @Builder
  buildCategorySelector() {
    Column({ space: 8 }) {
      Text('成本类别 *')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      Row() {
        Text(this.selectedCategory || '请选择类别')
          .fontSize(15)
          .fontColor(this.selectedCategory ? $r('app.color.text_primary') : $r('app.color.text_tertiary'))
          .layoutWeight(1)

        Text('▼')
          .fontSize(12)
          .fontColor($r('app.color.text_tertiary'))
      }
      .width('100%')
      .height(48)
      .padding({ left: 12, right: 12 })
      .backgroundColor($r('app.color.card_background'))
      .borderRadius(8)
      .onClick(() => {
        this.showCategorySelector = !this.showCategorySelector;
        this.showFieldSelector = false;
      })

      if (this.showCategorySelector) {
        Column() {
          ForEach(this.costCategories, (category: string) => {
            Row() {
              Text(this.getCategoryIcon(category))
                .fontSize(16)

              Text(category)
                .fontSize(15)
                .fontColor($r('app.color.text_primary'))
                .margin({ left: 8 })
                .layoutWeight(1)

              if (this.selectedCategory === category) {
                Text('✓')
                  .fontSize(16)
                  .fontColor($r('app.color.primary_professional'))
              }
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .backgroundColor(this.selectedCategory === category ? '#E3F2FD' : Color.Transparent)
            .onClick(() => {
              this.selectedCategory = category;
              this.unit = '';    // 重置单位
              this.item = '';    // 重置项目
              this.showCategorySelector = false;
            })
          })
        }
        .width('100%')
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .clip(true)
      }
    }
    .width('100%')
  }

第四部分:项目名称和数量输入

  /**
   * 构建项目名称输入框
   */
  @Builder
  buildItemInput() {
    Column({ space: 8 }) {
      Text('项目名称 *')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      Row() {
        Text(this.item || '请选择项目')
          .fontSize(15)
          .fontColor(this.item ? $r('app.color.text_primary') : $r('app.color.text_tertiary'))
          .layoutWeight(1)

        Text('▼')
          .fontSize(12)
          .fontColor($r('app.color.text_tertiary'))
      }
      .width('100%')
      .height(48)
      .padding({ left: 12, right: 12 })
      .backgroundColor($r('app.color.card_background'))
      .borderRadius(8)
      .onClick(() => {
        if (!this.selectedCategory) {
          promptAction.showToast({
            message: '请先选择成本类别',
            duration: 2000
          });
          return;
        }
        this.showItemSelector = !this.showItemSelector;
      })

      // 项目选择列表
      if (this.showItemSelector && this.selectedCategory) {
        Column() {
          ForEach(this.getItemOptions(), (itemOption: string) => {
            Row() {
              Text(itemOption)
                .fontSize(15)
                .fontColor($r('app.color.text_primary'))
                .layoutWeight(1)

              if (this.item === itemOption) {
                Text('✓')
                  .fontSize(16)
                  .fontColor($r('app.color.primary_professional'))
              }
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .backgroundColor(this.item === itemOption ? '#E3F2FD' : Color.Transparent)
            .onClick(() => {
              this.item = itemOption;
              this.showItemSelector = false;
            })
          })
        }
        .width('100%')
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .clip(true)
      }
    }
    .width('100%')
  }

  /**
   * 构建数量与单位输入框
   */
  @Builder
  buildQuantityInput() {
    Column({ space: 8 }) {
      Text('数量与单位')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      Row({ space: 12 }) {
        // 数量输入框
        TextInput({ placeholder: '数量', text: this.quantity })
          .layoutWeight(1)
          .height(48)
          .backgroundColor($r('app.color.card_background'))
          .borderRadius(8)
          .type(InputType.NUMBER_DECIMAL)
          .onChange((value: string) => {
            this.quantity = value;
          })

        // 单位选择器
        Column() {
          Text(this.unit || '单位')
            .fontSize(14)
            .fontColor(this.unit ? $r('app.color.text_primary') : $r('app.color.text_tertiary'))
        }
        .width(80)
        .height(48)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          if (!this.selectedCategory) {
            promptAction.showToast({
              message: '请先选择成本类别',
              duration: 2000
            });
            return;
          }
          this.showUnitSelector = !this.showUnitSelector;
        })
      }

      // 单位选择列表
      if (this.showUnitSelector && this.selectedCategory) {
        Column() {
          ForEach(this.getUnitOptions(), (unitOption: string) => {
            Row() {
              Text(unitOption)
                .fontSize(15)
                .fontColor($r('app.color.text_primary'))
                .layoutWeight(1)

              if (this.unit === unitOption) {
                Text('✓')
                  .fontSize(16)
                  .fontColor($r('app.color.primary_professional'))
              }
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .backgroundColor(this.unit === unitOption ? '#E3F2FD' : Color.Transparent)
            .onClick(() => {
              this.unit = unitOption;
              this.showUnitSelector = false;
            })
          })
        }
        .width('100%')
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .clip(true)
      }
    }
    .width('100%')
  }

第五部分:价格和其他信息输入

  /**
   * 构建单价输入框
   */
  @Builder
  buildPriceInput() {
    Column({ space: 8 }) {
      Text('单价 (元)')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      TextInput({ placeholder: '请输入单价', text: this.unitPrice })
        .width('100%')
        .height(48)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .type(InputType.NUMBER_DECIMAL)
        .onChange((value: string) => {
          this.unitPrice = value;
        })
    }
    .width('100%')
  }

  /**
   * 构建供应商输入框
   */
  @Builder
  buildSupplierInput() {
    Column({ space: 8 }) {
      Text('供应商')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      TextInput({ placeholder: '选填', text: this.supplier })
        .width('100%')
        .height(48)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .onChange((value: string) => {
          this.supplier = value;
        })
    }
    .width('100%')
  }

  /**
   * 构建备注输入框
   */
  @Builder
  buildNotesInput() {
    Column({ space: 8 }) {
      Text('备注')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      TextInput({ placeholder: '选填', text: this.notes })
        .width('100%')
        .height(48)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .onChange((value: string) => {
          this.notes = value;
        })
    }
    .width('100%')
  }

  /**
   * 构建总成本显示
   */
  @Builder
  buildTotalCost() {
    Row() {
      Text('总成本')
        .fontSize(16)
        .fontColor($r('app.color.text_primary'))

      Blank()

      // 自动计算并显示总成本
      Text(`¥${this.calculateTotalCost().toFixed(2)}`)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FF6B6B')
    }
    .width('100%')
    .padding(16)
    .backgroundColor($r('app.color.card_background'))
    .borderRadius(8)
  }

  /**
   * 构建提交按钮
   */
  @Builder
  buildSubmitButton() {
    Button('保存记录')
      .width('100%')
      .height(48)
      .margin(16)
      .backgroundColor($r('app.color.primary_professional'))
      .fontColor(Color.White)
      .borderRadius(24)
      .onClick(() => {
        this.submitRecord();
      })
  }

第六部分:辅助方法和提交逻辑

  /**
   * 获取当前分类的项目选项
   */
  private getItemOptions(): string[] {
    if (!this.selectedCategory) {
      return ['其他'];
    }
    return this.itemOptions[this.selectedCategory] || ['其他'];
  }

  /**
   * 获取当前分类的单位选项
   */
  private getUnitOptions(): string[] {
    if (!this.selectedCategory) {
      return ['个'];
    }
    return this.unitOptions[this.selectedCategory] || ['个'];
  }

  /**
   * 计算总成本
   */
  private calculateTotalCost(): number {
    const qty = parseFloat(this.quantity) || 0;
    const price = parseFloat(this.unitPrice) || 0;
    return qty * price;
  }

  /**
   * 获取分类图标
   */
  private getCategoryIcon(category: string): string {
    const iconMap: Record<string, string> = {
      '种子': '🌱',
      '肥料': '🌿',
      '农药': '🛡️',
      '人工': '👨‍🌾',
      '机械': '🚜',
      '水电': '💧',
      '其他': '📋'
    };
    return iconMap[category] || '💰';
  }

  /**
   * 提交成本记录
   */
  private async submitRecord(): Promise<void> {
    // 验证必填字段
    if (!this.selectedFieldId) {
      promptAction.showToast({
        message: '请选择地块',
        duration: 2000
      });
      return;
    }

    if (!this.selectedCategory) {
      promptAction.showToast({
        message: '请选择成本类别',
        duration: 2000
      });
      return;
    }

    if (!this.item.trim()) {
      promptAction.showToast({
        message: '请输入项目名称',
        duration: 2000
      });
      return;
    }

    const totalCost = this.calculateTotalCost();
    if (totalCost <= 0) {
      promptAction.showToast({
        message: '请输入有效的数量和单价',
        duration: 2000
      });
      return;
    }

    // 创建成本记录
    const record: CostAccountingRecord = {
      id: this.costService.generateRecordId(),
      fieldId: this.selectedFieldId,
      fieldName: this.selectedFieldName,
      category: this.selectedCategory,
      item: this.item.trim(),
      quantity: parseFloat(this.quantity) || 0,
      unit: this.unit,
      unitPrice: parseFloat(this.unitPrice) || 0,
      totalCost: totalCost,
      date: Date.now(),
      supplier: this.supplier.trim() || undefined,
      notes: this.notes.trim() || undefined,
      createdAt: Date.now()
    };

    const success = await this.costService.addRecord(record);

    if (success) {
      promptAction.showToast({
        message: '成本记录添加成功',
        duration: 2000
      });
      router.back();
    } else {
      promptAction.showToast({
        message: '保存失败,请重试',
        duration: 2000
      });
    }
  }
}

4.3 表单设计要点

设计要点 实现说明
级联选择 选择分类后,项目和单位选项自动更新
自动计算 数量×单价实时计算总成本
必填验证 地块、类别、项目、数量、单价为必填项
下拉选择 地块、分类、项目、单位都采用下拉选择,减少输入错误
智能提示 未选择分类时,点击项目或单位会提示先选择分类

五、创建编辑成本记录页面

编辑页面与添加页面功能类似,主要区别是需要加载现有数据。

5.1 创建页面文件

文件路径:entry/src/main/ets/pages/Services/EditCostRecordPage.ets

实现要点:

  1. 使用router.getParams()获取传入的成本记录
  2. aboutToAppear()中加载记录数据到表单
  3. 提交时调用updateRecord()而不是addRecord()
  4. 保持记录的idcreatedAt不变

核心代码片段:

/**
 * 加载记录数据
 */
loadRecordData(): void {
  const params = router.getParams() as EditCostParams;
  if (params && params.record) {
    const record = params.record;
    this.recordId = record.id;
    this.selectedCategory = record.category;
    this.item = record.item;
    this.quantity = record.quantity.toString();
    this.unit = record.unit;
    this.unitPrice = record.unitPrice.toString();
    this.supplier = record.supplier || '';
    this.notes = record.notes || '';
    this.selectedFieldId = record.fieldId;
    this.selectedFieldName = record.fieldName;
    this.createdAt = record.createdAt;
  }
}

/**
 * 提交修改
 */
private async submitRecord(): Promise<void> {
  // ... 验证代码省略 ...

  const record: CostAccountingRecord = {
    id: this.recordId,              // 使用原来的ID
    fieldId: this.selectedFieldId,
    fieldName: this.selectedFieldName,
    category: this.selectedCategory,
    item: this.item.trim(),
    quantity: parseFloat(this.quantity) || 0,
    unit: this.unit,
    unitPrice: parseFloat(this.unitPrice) || 0,
    totalCost: this.calculateTotalCost(),
    date: Date.now(),
    supplier: this.supplier.trim() || undefined,
    notes: this.notes.trim() || undefined,
    createdAt: this.createdAt      // 保持原来的创建时间
  };

  const success = await this.costService.updateRecord(record);

  if (success) {
    promptAction.showToast({
      message: '成本记录修改成功',
      duration: 2000
    });
    router.back();
  } else {
    promptAction.showToast({
      message: '保存失败,请重试',
      duration: 2000
    });
  }
}

六、配置路由

现在需要配置路由,让页面可以被访问。

6.1 配置页面路由

文件位置:entry/src/main/resources/base/profile/route_map.json

添加路由配置:

{
  "routerMap": [
    // ... 其他路由 ...
    {
      "name": "CostAccountingPage",
      "pageSourceFile": "src/main/ets/pages/Services/CostAccountingPage.ets",
      "buildFunction": "CostAccountingPageBuilder"
    },
    {
      "name": "AddCostRecordPage",
      "pageSourceFile": "src/main/ets/pages/Services/AddCostRecordPage.ets",
      "buildFunction": "AddCostRecordPageBuilder"
    },
    {
      "name": "EditCostRecordPage",
      "pageSourceFile": "src/main/ets/pages/Services/EditCostRecordPage.ets",
      "buildFunction": "EditCostRecordPageBuilder"
    }
  ]
}

6.2 在首页添加入口

文件位置:entry/src/main/ets/pages/ProfessionalAgriculture/FieldMapPage.ets(或其他页面)

添加按钮:

Button('成本核算')
  .onClick(() => {
    router.pushUrl({
      url: 'pages/Services/CostAccountingPage'
    });
  })

七、测试与验证

现在让我们测试成本核算功能。

7.1 测试步骤

步骤1:运行应用

# 在DevEco Studio中点击运行按钮

步骤2:进入成本核算页面

  • 点击首页的"成本核算"按钮
  • 查看空状态提示

步骤3:添加第一条成本记录

  1. 点击"+ 添加成本记录"按钮
  2. 选择地块(如:东地块)
  3. 选择成本类别(如:种子)
  4. 选择项目名称(如:玉米种子)
  5. 输入数量:50,选择单位:
  6. 输入单价:5.5
  7. 查看自动计算的总成本:275.00元
  8. 可选填写供应商和备注
  9. 点击"保存记录"按钮

步骤4:查看记录列表

  • 返回列表页面,查看新添加的记录
  • 检查统计卡片数据是否正确
  • 点击"分析"按钮,查看分类占比

步骤5:添加更多记录

  • 添加不同分类的成本记录
  • 观察分类筛选功能
  • 查看成本分析图表

步骤6:测试编辑功能

  1. 点击某条成本记录
  2. 修改数量或单价
  3. 查看总成本自动更新
  4. 保存修改并返回

步骤7:测试删除功能

  1. 长按某条成本记录
  2. 确认删除对话框
  3. 点击"删除"按钮
  4. 查看记录是否被删除

7.2 预期效果

功能 预期效果
列表页面 显示总成本统计,记录按时间倒序排列
筛选功能 点击分类标签,只显示该分类的记录
分析图表 显示各分类成本占比,进度条长度正确
添加记录 表单验证通过,数据保存成功,返回列表
编辑记录 加载现有数据,修改保存成功
删除记录 长按显示确认框,删除后列表更新
自动计算 输入数量和单价后,总成本实时更新

八、常见问题与解决方案

问题1:添加记录后列表没有刷新

原因:页面状态没有更新

解决方案:

// 在onPageShow()生命周期中重新加载数据
onPageShow(): void {
  this.loadData();
}

问题2:总成本计算不准确

原因:浮点数精度问题

解决方案:

// 使用toFixed()保留两位小数
private calculateTotalCost(): number {
  const qty = parseFloat(this.quantity) || 0;
  const price = parseFloat(this.unitPrice) || 0;
  return Math.round(qty * price * 100) / 100;  // 精确到分
}

问题3:分类筛选后显示不正确

原因:数组过滤逻辑错误

解决方案:

private getFilteredCostRecords(): CostAccountingRecord[] {
  // 确保"全部"分类返回所有记录
  if (this.selectedCategory === '全部') {
    return this.costRecords;
  }
  // 严格匹配分类名称
  return this.costRecords.filter(r => r.category === this.selectedCategory);
}

问题4:删除记录时应用崩溃

原因:记录ID不存在

解决方案:

// 在删除前检查记录是否存在
const recordExists = this.costRecords.some(r => r.id === id);
if (!recordExists) {
  console.warn('Record not found:', id);
  return;
}

九、总结

通过本教程,我们完成了:

已实现功能

✅ 成本核算数据模型设计
✅ CostAccountingService服务层实现
✅ 成本记录的增删改查
✅ 成本统计与分析
✅ 成本分类筛选
✅ 分类占比可视化
✅ 自动计算总成本
✅ 表单验证

技术要点回顾

技术点 应用场景
单例模式 CostAccountingService确保数据一致性
Map数据结构 统计分类成本和月度趋势
状态管理 @Local装饰器管理页面状态
级联选择 分类→项目→单位的联动
表单验证 必填项检查和数据合法性验证
路由导航 列表→添加/编辑→列表的页面流转

下一步

在第13篇教程中,我们将继续实现销售管理与助手功能,包括:

  • 销售记录管理
  • 销售统计与分析
  • 收入趋势分析
  • 收益计算
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐