HarmonyOS智慧农业管理应用开发教程--高高种地--第13篇:销售管理与助手

第13篇:销售管理与助手

教程目标

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

  • 理解销售数据模型设计
  • 实现销售记录的增删改查
  • 创建销售助手列表页面
  • 创建添加销售记录页面
  • 实现销售统计与分析功能
  • 按时间周期筛选销售数据

完成本教程后,你将拥有完整的农产品销售管理和收益分析功能。


一、销售数据模型

在实现销售管理功能之前,我们需要了解销售数据模型的结构。销售助手服务已在 SalesAssistantService.ets 中实现。

1.1 查看销售数据模型

文件位置 :entry/src/main/ets/services/SalesAssistantService.ets(第9-62行)

操作说明:

  1. 打开 entry/src/main/ets/services/SalesAssistantService.ets
  2. 查看销售相关的接口定义
typescript 复制代码
/**
 * 销售记录接口
 */
export interface SalesRecord {
  id: string;              // 记录唯一标识
  cropName: string;        // 作物名称
  quantity: number;        // 销售数量
  unit: string;            // 单位
  unitPrice: number;       // 单价
  totalRevenue: number;    // 总收入
  buyer: string;           // 买方
  date: number;            // 销售日期
  fieldId: string;         // 关联地块ID
  fieldName: string;       // 地块名称
  notes?: string;          // 备注(可选)
  createdAt: number;       // 创建时间
}

/**
 * 销售统计信息接口
 */
export interface SalesStatistics {
  totalRevenue: number;                 // 总收入
  recordCount: number;                  // 记录数量
  cropBreakdown: CropSales[];           // 作物销售明细
  monthlyTrend: MonthlySales[];         // 月度销售趋势
}

/**
 * 作物销售接口
 */
export interface CropSales {
  cropName: string;        // 作物名称
  revenue: number;         // 该作物总收入
  quantity: number;        // 累计销售数量
  unit: string;            // 单位
  percentage: number;      // 收入占比百分比
}

/**
 * 月度销售接口
 */
export interface MonthlySales {
  month: string;           // 月份(格式:YYYY-MM)
  revenue: number;         // 该月总收入
}

模型设计要点:

设计要点 说明
地块关联 通过fieldIdfieldName关联销售来源地块
自动计算 totalRevenue = quantity × unitPrice,简化录入
作物统计 统计每种作物的总收入、数量和占比
时间分析 提供月度销售趋势,便于把握销售规律
买方信息 记录买家名称,便于客户管理

1.2 销售数据与成本数据的关系

复制代码
地块管理系统
├── 成本记录: 种植过程中的各项支出
│   └── CostAccountingRecord (总成本统计)
│
└── 销售记录: 农产品销售的各项收入
    └── SalesRecord (总收入统计)

收益分析 = 总收入 - 总成本

通过成本和销售两个模块,可以完整分析农业生产的投入产出比和利润情况。


二、销售助手服务

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

2.1 SalesAssistantService核心方法

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

基础CRUD方法:

typescript 复制代码
/**
 * 获取所有销售记录
 */
async getAllRecords(): Promise<SalesRecord[]>

/**
 * 添加销售记录
 */
async addRecord(record: SalesRecord): Promise<boolean>

/**
 * 更新销售记录
 */
async updateRecord(record: SalesRecord): Promise<boolean>

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

时间周期筛选方法:

typescript 复制代码
/**
 * 根据时间周期获取记录
 * @param period 时间周期('全部'|'本月'|'本季度'|'本年')
 */
async getRecordsByPeriod(period: string): Promise<SalesRecord[]>

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

统计分析方法:

typescript 复制代码
/**
 * 获取销售统计信息
 * 包含总收入、记录数、作物销售排行、月度趋势
 */
async getStatistics(): Promise<SalesStatistics>

/**
 * 获取地块销售统计
 * @param fieldId 地块ID
 * @returns 该地块的总销售收入
 */
async getFieldSalesStatistics(fieldId: string): Promise<number>

2.2 时间周期筛选实现原理

本月筛选:

typescript 复制代码
const now = Date.now();
const currentDate = new Date(now);
// 获取本月1号0点的时间戳
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getTime();
// 筛选大于等于本月1号的记录
return records.filter(r => r.date >= monthStart);

本季度筛选:

typescript 复制代码
// 计算当前是第几季度(0-3)
const quarter = Math.floor(currentDate.getMonth() / 3);
// 获取本季度第一个月的1号0点
const quarterStart = new Date(currentDate.getFullYear(), quarter * 3, 1).getTime();
return records.filter(r => r.date >= quarterStart);

本年筛选:

typescript 复制代码
// 获取本年1月1号0点的时间戳
const yearStart = new Date(currentDate.getFullYear(), 0, 1).getTime();
return records.filter(r => r.date >= yearStart);

2.3 作物销售统计原理

typescript 复制代码
// 使用Map累加每种作物的收入和数量
const cropMap = new Map<string, CropAccumulator>();
for (const record of records) {
  const current = cropMap.get(record.cropName);
  if (current) {
    // 累加收入和数量
    current.revenue += record.totalRevenue;
    current.quantity += record.quantity;
  } else {
    // 首次出现,创建新记录
    cropMap.set(record.cropName, {
      revenue: record.totalRevenue,
      quantity: record.quantity,
      unit: record.unit
    });
  }
}

// 计算每种作物的收入占比
const percentage = totalRevenue > 0 ? (revenue / totalRevenue) * 100 : 0;

// 按收入降序排列
cropBreakdown.sort((a, b) => b.revenue - a.revenue);

三、创建销售助手列表页面

现在开始实现销售助手的列表页面,展示销售记录和收入统计。

3.1 创建页面文件

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

页面功能:

  • 展示总收入统计卡片
  • 支持按时间周期筛选记录
  • 显示作物销售占比分析
  • 长按删除销售记录
  • 点击进入编辑页面

3.2 完整页面代码

typescript 复制代码
/**
 * 销售助手页面
 * 管理农产品销售记录和收入统计
 */

import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { SalesAssistantService, SalesRecord, SalesStatistics, CropSales } from '../../services/SalesAssistantService';

@Entry
@ComponentV2
export struct SalesAssistantPage {
  // ===== 状态变量 =====
  @Local salesRecords: SalesRecord[] = [];          // 销售记录列表
  @Local statistics: SalesStatistics | null = null; // 统计数据
  @Local totalRevenue: number = 0;                  // 总收入
  @Local selectedPeriod: string = '全部';           // 选中的时间周期
  @Local isLoading: boolean = true;                 // 加载状态
  @Local showAnalysis: boolean = false;             // 是否显示分析图表

  // ===== 服务实例 =====
  private salesService: SalesAssistantService = SalesAssistantService.getInstance();

  // ===== 时间周期选项 =====
  private periodOptions = ['全部', '本月', '本季度', '本年'];

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

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

  /**
   * 加载销售数据
   */
  async loadData(): Promise<void> {
    try {
      this.salesRecords = await this.salesService.getAllRecords();
      this.statistics = await this.salesService.getStatistics();
      this.totalRevenue = this.statistics.totalRevenue;
      this.isLoading = false;
    } catch (error) {
      console.error('Failed to load sales data:', error);
      this.isLoading = false;
    }
  }

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

      if (this.isLoading) {
        this.buildLoading()
      } else {
        Column() {
          this.buildSummaryCards()
          if (this.showAnalysis && this.statistics) {
            this.buildAnalysis()
          }
          this.buildPeriodFilter()
          this.buildSalesList()
        }
        .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
  buildSummaryCards() {
    Column({ space: 12 }) {
      Text('总收入')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))

      // 总收入金额
      Text(`¥${this.totalRevenue.toFixed(2)}`)
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .fontColor('#4CAF50')  // 绿色表示收入

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

        if (this.statistics && this.statistics.cropBreakdown.length > 0) {
          Text(`${this.statistics.cropBreakdown.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.cropBreakdown.length > 0) {
        Column({ space: 8 }) {
          ForEach(this.statistics.cropBreakdown, (item: CropSales) => {
            Row({ space: 12 }) {
              // 作物图标
              Text('📦')
                .fontSize(16)

              // 作物名称
              Text(item.cropName)
                .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('#4CAF50')
                  .borderRadius(4)
              }
              .layoutWeight(1)

              // 收入金额
              Text(`¥${item.revenue.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
  buildPeriodFilter() {
    Scroll() {
      Row({ space: 8 }) {
        ForEach(this.periodOptions, (period: string) => {
          Text(period)
            .fontSize(14)
            .fontColor(this.selectedPeriod === period ?
              Color.White : $r('app.color.text_primary'))
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .backgroundColor(this.selectedPeriod === period ?
              $r('app.color.primary_professional') : $r('app.color.card_background'))
            .borderRadius(16)
            .onClick(() => {
              this.selectedPeriod = period;
            })
        })
      }
      .padding({ left: 16, right: 16 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ bottom: 16 })
  }

  /**
   * 构建销售记录列表
   */
  @Builder
  buildSalesList() {
    if (this.getFilteredSalesRecords().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.getFilteredSalesRecords(), (record: SalesRecord) => {
            this.buildSalesCard(record)
          })
        }
        .padding({ left: 16, right: 16, bottom: 16 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Auto)
    }
  }

  /**
   * 构建单个销售记录卡片
   */
  @Builder
  buildSalesCard(record: SalesRecord) {
    Column({ space: 12 }) {
      // 第一行:作物名称、来源地块、总收入
      Row() {
        Text('📦')
          .fontSize(20)

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

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

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

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

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

        Blank()

        if (record.buyer) {
          Text(record.buyer)
            .fontSize(12)
            .fontColor($r('app.color.primary_professional'))
        }
      }
      .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/EditSalesRecordPage',
        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/AddSalesRecordPage'
        });
      })
  }

  /**
   * 获取筛选后的销售记录
   */
  private getFilteredSalesRecords(): SalesRecord[] {
    if (this.selectedPeriod === '全部') {
      return this.salesRecords;
    }

    const now = Date.now();
    const currentDate = new Date(now);

    if (this.selectedPeriod === '本月') {
      const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getTime();
      return this.salesRecords.filter(r => r.date >= monthStart);
    } else if (this.selectedPeriod === '本季度') {
      const quarter = Math.floor(currentDate.getMonth() / 3);
      const quarterStart = new Date(currentDate.getFullYear(), quarter * 3, 1).getTime();
      return this.salesRecords.filter(r => r.date >= quarterStart);
    } else if (this.selectedPeriod === '本年') {
      const yearStart = new Date(currentDate.getFullYear(), 0, 1).getTime();
      return this.salesRecords.filter(r => r.date >= yearStart);
    }

    return this.salesRecords;
  }

  /**
   * 格式化日期
   */
  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: SalesRecord): void {
    promptAction.showDialog({
      title: '确认删除',
      message: `确定要删除销售记录 "${record.cropName}" 吗?`,
      buttons: [
        { text: '取消', color: '#999999' },
        { text: '删除', color: '#FF6B6B' }
      ]
    }).then(async (result) => {
      if (result.index === 1) {
        const success = await this.salesService.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/AddSalesRecordPage.ets

页面功能:

  • 选择来源地块
  • 选择作物名称
  • 输入数量和单位
  • 输入单价
  • 自动计算总收入
  • 输入买家信息
  • 表单验证

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

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

typescript 复制代码
/**
 * 添加销售记录页面
 * 用于添加新的销售记录
 */

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

@Entry
@ComponentV2
struct AddSalesRecordPage {
  // ===== 表单状态 =====
  @Local cropName: string = '';              // 作物名称
  @Local quantity: string = '';              // 数量
  @Local unit: string = '';                  // 单位
  @Local unitPrice: string = '';             // 单价
  @Local buyer: string = '';                 // 买家
  @Local notes: string = '';                 // 备注
  @Local selectedFieldId: string = '';       // 选中的地块ID
  @Local selectedFieldName: string = '';     // 选中的地块名称

  // ===== UI状态 =====
  @Local fieldList: FieldInfo[] = [];        // 地块列表
  @Local showFieldSelector: boolean = false; // 是否显示地块选择器
  @Local showCropSelector: boolean = false;  // 是否显示作物选择器
  @Local showUnitSelector: boolean = false;  // 是否显示单位选择器

  // ===== 服务实例 =====
  private fieldService: FieldService = FieldService.getInstance();
  private salesService: SalesAssistantService = SalesAssistantService.getInstance();

  // ===== 作物选项 =====
  private cropOptions: string[] = [
    '玉米', '小麦', '水稻', '大豆', '花生', '棉花',
    '白菜', '萝卜', '土豆', '番茄', '黄瓜', '茄子',
    '苹果', '梨', '桃', '葡萄', '西瓜', '草莓',
    '其他'
  ];

  // ===== 单位选项 =====
  private unitOptions: 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.buildCropSelector()
          this.buildQuantityInput()
          this.buildPriceInput()
          this.buildBuyerInput()
          this.buildNotesInput()
          this.buildTotalRevenue()
        }
        .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 })
  }

第二部分:地块和作物选择器

typescript 复制代码
  /**
   * 构建地块选择器
   */
  @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.showCropSelector = false;
        this.showUnitSelector = 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
  buildCropSelector() {
    Column({ space: 8 }) {
      Text('作物名称 *')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      Row() {
        Text(this.cropName || '请选择作物')
          .fontSize(15)
          .fontColor(this.cropName ? $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.showCropSelector = !this.showCropSelector;
        this.showFieldSelector = false;
        this.showUnitSelector = false;
      })

      if (this.showCropSelector) {
        Column() {
          ForEach(this.cropOptions, (crop: string) => {
            Row() {
              Text(crop)
                .fontSize(15)
                .fontColor($r('app.color.text_primary'))
                .layoutWeight(1)

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

第三部分:数量、单价和其他信息输入

typescript 复制代码
  /**
   * 构建数量与单位输入框
   */
  @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(() => {
          this.showUnitSelector = !this.showUnitSelector;
          this.showFieldSelector = false;
          this.showCropSelector = false;
        })
      }

      // 单位选择列表
      if (this.showUnitSelector) {
        Column() {
          ForEach(this.unitOptions, (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
  buildBuyerInput() {
    Column({ space: 8 }) {
      Text('买家')
        .fontSize(14)
        .fontColor($r('app.color.text_secondary'))
        .width('100%')

      TextInput({ placeholder: '选填', text: this.buyer })
        .width('100%')
        .height(48)
        .backgroundColor($r('app.color.card_background'))
        .borderRadius(8)
        .onChange((value: string) => {
          this.buyer = 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
  buildTotalRevenue() {
    Row() {
      Text('总收入')
        .fontSize(16)
        .fontColor($r('app.color.text_primary'))

      Blank()

      // 自动计算并显示总收入
      Text(`¥${this.calculateTotalRevenue().toFixed(2)}`)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#4CAF50')
    }
    .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();
      })
  }

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

typescript 复制代码
  /**
   * 计算总收入
   */
  private calculateTotalRevenue(): number {
    const qty = parseFloat(this.quantity) || 0;
    const price = parseFloat(this.unitPrice) || 0;
    return qty * price;
  }

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

    if (!this.cropName) {
      promptAction.showToast({
        message: '请选择作物',
        duration: 2000
      });
      return;
    }

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

    if (!this.unit) {
      promptAction.showToast({
        message: '请选择单位',
        duration: 2000
      });
      return;
    }

    // 创建销售记录
    const record: SalesRecord = {
      id: this.salesService.generateRecordId(),
      cropName: this.cropName,
      quantity: parseFloat(this.quantity) || 0,
      unit: this.unit,
      unitPrice: parseFloat(this.unitPrice) || 0,
      totalRevenue: totalRevenue,
      buyer: this.buyer.trim() || '未知买家',
      date: Date.now(),
      fieldId: this.selectedFieldId,
      fieldName: this.selectedFieldName,
      notes: this.notes.trim() || undefined,
      createdAt: Date.now()
    };

    const success = await this.salesService.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/EditSalesRecordPage.ets

实现要点:

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

核心代码片段:

typescript 复制代码
/**
 * 加载记录数据
 */
loadRecordData(): void {
  const params = router.getParams() as EditSalesParams;
  if (params && params.record) {
    const record = params.record;
    this.recordId = record.id;
    this.cropName = record.cropName;
    this.quantity = record.quantity.toString();
    this.unit = record.unit;
    this.unitPrice = record.unitPrice.toString();
    this.buyer = record.buyer;
    this.notes = record.notes || '';
    this.selectedFieldId = record.fieldId;
    this.selectedFieldName = record.fieldName;
    this.createdAt = record.createdAt;
  }
}

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

  const record: SalesRecord = {
    id: this.recordId,              // 使用原来的ID
    cropName: this.cropName,
    quantity: parseFloat(this.quantity) || 0,
    unit: this.unit,
    unitPrice: parseFloat(this.unitPrice) || 0,
    totalRevenue: this.calculateTotalRevenue(),
    buyer: this.buyer.trim() || '未知买家',
    date: Date.now(),
    fieldId: this.selectedFieldId,
    fieldName: this.selectedFieldName,
    notes: this.notes.trim() || undefined,
    createdAt: this.createdAt      // 保持原来的创建时间
  };

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

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

六、收益分析功能扩展

虽然本教程重点讲解销售管理,但结合第12篇的成本核算,可以实现完整的收益分析。

6.1 收益计算公式

typescript 复制代码
/**
 * 计算总收益
 */
async calculateProfit(): Promise<number> {
  // 获取总收入
  const salesStats = await salesService.getStatistics();
  const totalRevenue = salesStats.totalRevenue;

  // 获取总成本
  const costStats = await costService.getStatistics();
  const totalCost = costStats.totalCost;

  // 计算净收益
  const profit = totalRevenue - totalCost;
  return profit;
}

/**
 * 计算收益率
 */
async calculateProfitRate(): Promise<number> {
  const salesStats = await salesService.getStatistics();
  const totalRevenue = salesStats.totalRevenue;

  const costStats = await costService.getStatistics();
  const totalCost = costStats.totalCost;

  if (totalCost === 0) {
    return 0;
  }

  // 收益率 = (收入 - 成本) / 成本 × 100%
  const profitRate = ((totalRevenue - totalCost) / totalCost) * 100;
  return profitRate;
}

6.2 按地块计算收益

typescript 复制代码
/**
 * 计算单个地块的收益
 */
async calculateFieldProfit(fieldId: string): Promise<number> {
  // 获取地块销售收入
  const fieldRevenue = await salesService.getFieldSalesStatistics(fieldId);

  // 获取地块成本支出
  const fieldCost = await costService.getFieldCostStatistics(fieldId);

  // 计算地块净收益
  const fieldProfit = fieldRevenue - fieldCost;
  return fieldProfit;
}

七、测试与验证

现在让我们测试销售管理功能。

7.1 测试步骤

步骤1:运行应用

bash 复制代码
# 在DevEco Studio中点击运行按钮

步骤2:进入销售助手页面

  • 点击首页的"销售助手"按钮
  • 查看空状态提示

步骤3:添加第一条销售记录

  1. 点击"+ 添加销售记录"按钮
  2. 选择地块(如:东地块)
  3. 选择作物名称(如:玉米)
  4. 输入数量:1000,选择单位:
  5. 输入单价:2.5
  6. 查看自动计算的总收入:2500.00元
  7. 可选填写买家和备注
  8. 点击"保存记录"按钮

步骤4:查看记录列表

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

步骤5:测试时间周期筛选

  • 点击"本月"标签,查看本月销售记录
  • 点击"本季度"标签,查看本季度销售记录
  • 点击"本年"标签,查看本年销售记录
  • 点击"全部"标签,返回所有记录

步骤6:添加更多记录

  • 添加不同作物的销售记录
  • 观察作物销售占比变化
  • 查看收入排行

步骤7:测试编辑功能

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

步骤8:测试删除功能

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

7.2 预期效果

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

八、常见问题与解决方案

问题1:时间周期筛选不准确

原因:时间戳计算错误

解决方案:

typescript 复制代码
// 确保使用0点的时间戳
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getTime();

// 注意月份从0开始,季度计算要除以3
const quarter = Math.floor(currentDate.getMonth() / 3);

问题2:作物销售统计数量单位不一致

原因:同一作物使用了不同单位

解决方案:

typescript 复制代码
// 在统计时使用第一次出现的单位
if (!current) {
  cropMap.set(record.cropName, {
    revenue: record.totalRevenue,
    quantity: record.quantity,
    unit: record.unit  // 保存首次出现的单位
  });
}

问题3:买家字段为空导致显示异常

原因:可选字段未处理空值

解决方案:

typescript 复制代码
// 添加记录时设置默认值
buyer: this.buyer.trim() || '未知买家',

// 显示时检查是否存在
if (record.buyer) {
  Text(record.buyer)
}

问题4:删除记录后统计数据未更新

原因:删除后没有重新加载统计数据

解决方案:

typescript 复制代码
// 删除成功后重新加载数据
if (success) {
  await this.loadData();  // 重新加载包含统计数据
  promptAction.showToast({
    message: '删除成功',
    duration: 2000
  });
}

九、总结

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

已实现功能

✅ 销售数据模型设计

✅ SalesAssistantService服务层实现

✅ 销售记录的增删改查

✅ 销售统计与分析

✅ 时间周期筛选(本月/本季度/本年)

✅ 作物销售占比可视化

✅ 自动计算总收入

✅ 表单验证

技术要点回顾

技术点 应用场景
单例模式 SalesAssistantService确保数据一致性
Map数据结构 统计作物销售和累加数量
时间计算 本月/本季度/本年的时间范围筛选
状态管理 @Local装饰器管理页面状态
表单验证 必填项检查和数据合法性验证
路由导航 列表→添加/编辑→列表的页面流转

成本与销售对比

特性 成本核算 销售管理
颜色 红色(支出) 绿色(收入)
筛选 按分类(种子/肥料等) 按时间周期(本月/本季度等)
统计 分类成本占比 作物销售占比
分析 成本结构分析 销售趋势分析
目标 控制成本 增加收入

下一步

在第14篇教程中,我们将实现数据分析与智能决策功能,整合成本和销售数据,提供:

  • 综合收益分析
  • 产量预测
  • 投入产出比计算
  • 智能决策建议
相关推荐
Easonmax2 小时前
零基础入门 React Native 鸿蒙跨平台开发:9——表格数据动态加载与分页
react native·react.js·harmonyos
Miguo94well2 小时前
Flutter框架跨平台鸿蒙开发——定时生日提醒APP的开发流程
flutter·华为·harmonyos
BlackWolfSky2 小时前
鸿蒙中级课程笔记2—状态管理V2—@Monitor装饰器:状态变量修改监听
笔记·华为·harmonyos
lqj_本人2 小时前
Flutter PDF 渲染插件(pdf_image_renderer)适配鸿蒙 (HarmonyOS) 平台实战
flutter·pdf·harmonyos
BlackWolfSky3 小时前
鸿蒙中级课程笔记2—状态管理V2—@Provider装饰器和@Consumer装饰器:跨组件层级双向同步
笔记·华为·harmonyos
禁默3 小时前
【鸿蒙PC命令行适配】rust应用交叉编译环境搭建和bat命令的移植实战指南
华为·rust·harmonyos
Easonmax3 小时前
零基础入门 React Native 鸿蒙跨平台开发:5——横向滚动表格实现
react native·react.js·harmonyos
bst@微胖子3 小时前
HarmonyOS应用四之页面构建
华为·harmonyos
小风呼呼吹儿3 小时前
Flutter 框架跨平台鸿蒙开发 - 充电温度检测器应用开发教程
flutter·华为·harmonyos