如果你想做一个充电记录App,需要用到Preferences和统计图表

开新能源车想记录每次充电的详情?打开鸿蒙应用市场,搜索"电桩记",下载安装后就能记录充电数据了。SOC追踪、费用统计、充电桩图鉴、充电效率分析,让你的充电记录一目了然。


写在前面

大家好,今天聊一个数据统计比较重的App。"电桩记"是一个新能源车充电记录工具。它的核心功能是记录每次充电的详细信息,然后做各种统计:月度费用、平均充电量、充电效率、充电桩使用频率等。

选这个App来讲,是因为它展示了如何在Preferences存储的基础上做统计聚合。统计聚合的思路是:把原始数据存起来,每次需要统计时从原始数据计算。这种"存储原始数据,计算派生数据"的模式在实际开发中非常常见。

这篇文章聊什么

  • 充电记录的数据结构设计
  • SOC(电池电量)追踪的逻辑
  • 费用统计和月度汇总
  • 充电桩图鉴(充电桩信息库)
  • React版本的localStorage实现
  • ArkTS版本的Preferences实现

第一步:设计数据结构

// 充电记录
interface ChargeRecord {
  id: string;
  date: string;             // 充电日期
  stationName: string;      // 充电站名称
  stationType: string;      // 充电桩类型:直流快充/交流慢充
  connectorType: string;     // 接口类型:CCS/CHAdeMO/GB/T
  startSoc: number;         // 开始SOC(%)
  endSoc: number;           // 结束SOC(%)
  chargedKwh: number;        // 充电电量(kWh)
  duration: number;          // 充电时长(分钟)
  cost: number;              // 费用(元)
  power: number;             // 充电功率(kW)
  location: string;          // 位置
  note: string;              // 备注
}

// 充电桩图鉴
interface StationInfo {
  id: string;
  name: string;
  brand: string;             // 品牌:特来电/星星充电/国家电网...
  address: string;
  type: string;              // 类型:直流快充/交流慢充
  maxPower: number;          // 最大功率(kW)
  price: string;             // 价格
  rating: number;            // 评分1-5
  useCount: number;          // 使用次数
  note: string;
}

SOC是State of Charge的缩写,表示电池当前的电量百分比。startSoc和endSoc的差值就是这次充电充了多少电(百分比),而chargedKwh是实际充了多少度电。这两个数据可以互相验证。

第二步:React版本 – 统计聚合

import React, { useState, useEffect, useMemo } from 'react';

function ChargeManager() {
  const [records, setRecords] = useState([]);
  const [stations, setStations] = useState([]);
  const [filterMonth, setFilterMonth] = useState('全部');

  useEffect(() => {
    const savedRecords = localStorage.getItem('dianzhuangji_records');
    const savedStations = localStorage.getItem('dianzhuangji_stations');
    if (savedRecords) setRecords(JSON.parse(savedRecords));
    if (savedStations) setStations(JSON.parse(savedStations));
  }, []);

  useEffect(() => {
    if (records.length > 0) {
      localStorage.setItem('dianzhuangji_records', JSON.stringify(records));
    }
  }, [records]);

  // 添加充电记录
  const addRecord = (recordData) => {
    const record = {
      ...recordData,
      id: Date.now().toString(36) + Math.random().toString(36).substr(2),
      date: new Date().toISOString().split('T')[0]
    };
    setRecords(prev => [...prev, record]);

    // 更新充电桩使用次数
    const station = stations.find(s => s.name === recordData.stationName);
    if (station) {
      setStations(prev => prev.map(s =>
        s.id === station.id ? { ...s, useCount: s.useCount + 1 } : s
      ));
    }
  };

  // 按月筛选
  const filteredRecords = useMemo(() => {
    if (filterMonth === '全部') return records;
    return records.filter(r => r.date.startsWith(filterMonth));
  }, [records, filterMonth]);

  // 获取所有月份(用于筛选选项)
  const allMonths = useMemo(() => {
    const months = new Set(records.map(r => r.date.substring(0, 7)));
    return [...months].sort().reverse();
  }, [records]);

  // 月度统计
  const monthlyStats = useMemo(() => {
    const stats = {};
    records.forEach(r => {
      const month = r.date.substring(0, 7);
      if (!stats[month]) {
        stats[month] = { totalCost: 0, totalKwh: 0, count: 0, totalDuration: 0 };
      }
      stats[month].totalCost += r.cost;
      stats[month].totalKwh += r.chargedKwh;
      stats[month].count++;
      stats[month].totalDuration += r.duration;
    });
    return stats;
  }, [records]);

  // 总体统计
  const overallStats = useMemo(() => {
    if (filteredRecords.length === 0) {
      return { totalCost: 0, totalKwh: 0, avgCost: 0, avgKwh: 0, avgPower: 0 };
    }
    const totalCost = filteredRecords.reduce((sum, r) => sum + r.cost, 0);
    const totalKwh = filteredRecords.reduce((sum, r) => sum + r.chargedKwh, 0);
    const avgCost = totalCost / filteredRecords.length;
    const avgKwh = totalKwh / filteredRecords.length;
    const avgPower = filteredRecords.reduce((sum, r) => sum + r.power, 0) / filteredRecords.length;
    return { totalCost, totalKwh, avgCost, avgKwh, avgPower };
  }, [filteredRecords]);

  // 平均充电效率(kWh/小时)
  const avgEfficiency = useMemo(() => {
    const validRecords = filteredRecords.filter(r => r.duration > 0);
    if (validRecords.length === 0) return 0;
    const totalKwh = validRecords.reduce((sum, r) => sum + r.chargedKwh, 0);
    const totalHours = validRecords.reduce((sum, r) => sum + r.duration, 0) / 60;
    return totalKwh / totalHours;
  }, [filteredRecords]);

  // 每度电费用
  const costPerKwh = useMemo(() => {
    if (overallStats.totalKwh === 0) return 0;
    return overallStats.totalCost / overallStats.totalKwh;
  }, [overallStats]);

  return (
    <div className="charge-manager">
      <h1>电桩记 - 充电记录</h1>

      {/* 总体统计 */}
      <div className="stats">
        <div>充电次数:{filteredRecords.length}</div>
        <div>总费用:{overallStats.totalCost.toFixed(1)}元</div>
        <div>总电量:{overallStats.totalKwh.toFixed(1)}kWh</div>
        <div>平均费用:{overallStats.avgCost.toFixed(1)}元/次</div>
        <div>每度电:{costPerKwh.toFixed(2)}元/kWh</div>
        <div>充电效率:{avgEfficiency.toFixed(1)}kWh/h</div>
      </div>

      {/* 月份筛选 */}
      <div className="filter">
        <button className={filterMonth === '全部' ? 'active' : ''}
          onClick={() => setFilterMonth('全部')}>全部</button>
        {allMonths.map(month => (
          <button key={month}
            className={filterMonth === month ? 'active' : ''}
            onClick={() => setFilterMonth(month)}>
            {month}
          </button>
        ))}
      </div>

      {/* 月度统计列表 */}
      <div className="monthly-stats">
        <h3>月度汇总</h3>
        {Object.entries(monthlyStats).sort().reverse().map(([month, stats]) => (
          <div key={month} className="month-card">
            <h4>{month}</h4>
            <p>充电 {stats.count} 次,{stats.totalKwh.toFixed(1)}kWh</p>
            <p>费用 {stats.totalCost.toFixed(1)}元</p>
            <p>平均 {(stats.totalCost / stats.count).toFixed(1)}元/次</p>
          </div>
        ))}
      </div>

      {/* 充电记录列表 */}
      <div className="record-list">
        {filteredRecords.map(record => (
          <div key={record.id} className="record-card">
            <h4>{record.stationName}</h4>
            <p>{record.date} | {record.stationType}</p>
            <p>SOC:{record.startSoc}% → {record.endSoc}%</p>
            <p>{record.chargedKwh}kWh | {record.duration}分钟 | {record.cost}元</p>
            <p>功率:{record.power}kW</p>
          </div>
        ))}
      </div>
    </div>
  );
}

这段代码的统计逻辑比较多,我们重点聊几个。

月度统计:用对象做分组,key是月份(如"2024-03"),value是聚合数据。遍历所有记录,按月份累加费用、电量、次数。这种"分组聚合"的模式在统计类App中非常常见。

充电效率:总电量除以总时长(换算成小时)。这个指标反映了充电桩的实际输出效率。理论上功率越大效率越高,但实际受温度、电池状态等因素影响。

每度电费用:总费用除以总电量。这个指标可以帮你比较不同充电桩的性价比。

第三步:ArkTS版本 – Preferences统计聚合

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

interface ChargeRecord {
  id: string;
  date: string;
  stationName: string;
  stationType: string;
  connectorType: string;
  startSoc: number;
  endSoc: number;
  chargedKwh: number;
  duration: number;
  cost: number;
  power: number;
  location: string;
  note: string;
}

interface StationInfo {
  id: string;
  name: string;
  brand: string;
  address: string;
  type: string;
  maxPower: number;
  price: string;
  rating: number;
  useCount: number;
  note: string;
}

@Entry
@Component
struct ChargePage {
  @State records: ChargeRecord[] = [];
  @State stations: StationInfo[] = [];
  @State filterMonth: string = '全部';
  private preferencesStore: preferences.Preferences | null = null;

  async aboutToAppear() {
    let context = getContext(this) as common.UIAbilityContext;
    this.preferencesStore = await preferences.getPreferences(context, 'dianzhuangji_store');

    const recordsData = await this.preferencesStore.get('records', '');
    if (recordsData && typeof recordsData === 'string' && recordsData.length > 0) {
      this.records = JSON.parse(recordsData) as ChargeRecord[];
    }

    const stationsData = await this.preferencesStore.get('stations', '');
    if (stationsData && typeof stationsData === 'string' && stationsData.length > 0) {
      this.stations = JSON.parse(stationsData) as StationInfo[];
    }
  }

  async saveRecords() {
    if (!this.preferencesStore) return;
    await this.preferencesStore.put('records', JSON.stringify(this.records));
    await this.preferencesStore.flush();
  }

  // 按月筛选
  getFilteredRecords(): ChargeRecord[] {
    if (this.filterMonth === '全部') return this.records;
    return this.records.filter(r => r.date.startsWith(this.filterMonth));
  }

  // 获取所有月份
  getAllMonths(): string[] {
    const months = new Set<string>();
    this.records.forEach(r => months.add(r.date.substring(0, 7)));
    return Array.from(months).sort().reverse();
  }

  // 总体统计
  getOverallStats(): { totalCost: number; totalKwh: number; avgCost: string; avgKwh: string; avgPower: string } {
    const filtered = this.getFilteredRecords();
    if (filtered.length === 0) {
      return { totalCost: 0, totalKwh: 0, avgCost: '0', avgKwh: '0', avgPower: '0' };
    }
    const totalCost = filtered.reduce((sum, r) => sum + r.cost, 0);
    const totalKwh = filtered.reduce((sum, r) => sum + r.chargedKwh, 0);
    const avgCost = (totalCost / filtered.length).toFixed(1);
    const avgKwh = (totalKwh / filtered.length).toFixed(1);
    const avgPower = (filtered.reduce((sum, r) => sum + r.power, 0) / filtered.length).toFixed(1);
    return { totalCost, totalKwh, avgCost, avgKwh, avgPower };
  }

  // 月度统计
  getMonthlyStats(): Record<string, { totalCost: number; totalKwh: number; count: number }> {
    const stats: Record<string, { totalCost: number; totalKwh: number; count: number }> = {};
    this.records.forEach(r => {
      const month = r.date.substring(0, 7);
      if (!stats[month]) {
        stats[month] = { totalCost: 0, totalKwh: 0, count: 0 };
      }
      stats[month].totalCost += r.cost;
      stats[month].totalKwh += r.chargedKwh;
      stats[month].count++;
    });
    return stats;
  }

  // 充电效率
  getAvgEfficiency(): string {
    const filtered = this.getFilteredRecords();
    const validRecords = filtered.filter(r => r.duration > 0);
    if (validRecords.length === 0) return '0';
    const totalKwh = validRecords.reduce((sum, r) => sum + r.chargedKwh, 0);
    const totalHours = validRecords.reduce((sum, r) => sum + r.duration, 0) / 60;
    return (totalKwh / totalHours).toFixed(1);
  }

  // 每度电费用
  getCostPerKwh(): string {
    const stats = this.getOverallStats();
    if (stats.totalKwh === 0) return '0';
    return (stats.totalCost / stats.totalKwh).toFixed(2);
  }

  build() {
    let overallStats = this.getOverallStats();
    let monthlyStats = this.getMonthlyStats();

    Column() {
      Text('电桩记')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 16 })

      // 总体统计卡片
      Column() {
        Row() {
          Column() {
            Text(this.getFilteredRecords().length + '次')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
            Text('充电次数')
              .fontSize(12)
              .fontColor('#999999')
          }
          .layoutWeight(1)
          Column() {
            Text(overallStats.totalCost.toFixed(1) + '元')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FF6B6B')
            Text('总费用')
              .fontSize(12)
              .fontColor('#999999')
          }
          .layoutWeight(1)
          Column() {
            Text(overallStats.totalKwh.toFixed(1) + 'kWh')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4ECDC4')
            Text('总电量')
              .fontSize(12)
              .fontColor('#999999')
          }
          .layoutWeight(1)
        }
        .width('100%')
        .margin({ bottom: 12 })

        Row() {
          Text(`平均 ${overallStats.avgCost}元/次`)
            .fontSize(13)
            .fontColor('#666666')
            .layoutWeight(1)
          Text(`${this.getCostPerKwh()}元/kWh`)
            .fontSize(13)
            .fontColor('#FFA726')
            .layoutWeight(1)
          Text(`${this.getAvgEfficiency()}kWh/h`)
            .fontSize(13)
            .fontColor('#45B7D1')
            .layoutWeight(1)
        }
        .width('100%')
      }
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ left: 16, right: 16, bottom: 16 })

      // 月份筛选
      Row() {
        Button('全部')
          .fontSize(13)
          .backgroundColor(this.filterMonth === '全部' ? '#4ECDC4' : '#F0F0F0')
          .fontColor(this.filterMonth === '全部' ? '#FFFFFF' : '#333333')
          .margin({ right: 6 })
          .onClick(() => { this.filterMonth = '全部'; })
        ForEach(this.getAllMonths().slice(0, 6), (month: string) => {
          Button(month)
            .fontSize(13)
            .backgroundColor(this.filterMonth === month ? '#4ECDC4' : '#F0F0F0')
            .fontColor(this.filterMonth === month ? '#FFFFFF' : '#333333')
            .margin({ right: 6 })
            .onClick(() => { this.filterMonth = month; })
        })
      }
      .padding({ left: 16, right: 16 })
      .margin({ bottom: 16 })

      // 月度汇总
      Column() {
        Text('月度汇总')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 8 })

        ForEach(
          Object.entries(monthlyStats).sort((a, b) => b[0].localeCompare(a[0])),
          (entry: [string, { totalCost: number; totalKwh: number; count: number }]) => {
            Row() {
              Text(entry[0])
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .width(80)
              Text(`${entry[1].count}次 / ${entry[1].totalKwh.toFixed(1)}kWh`)
                .fontSize(13)
                .fontColor('#666666')
                .layoutWeight(1)
              Text(entry[1].totalCost.toFixed(1) + '元')
                .fontSize(14)
                .fontColor('#FF6B6B')
            }
            .width('100%')
            .padding(12)
            .backgroundColor('#F8F8F8')
            .borderRadius(8)
            .margin({ bottom: 4 })
          }
        )
      }
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ left: 16, right: 16, bottom: 16 })

      // 充电记录列表
      List({ space: 8 }) {
        ForEach(this.getFilteredRecords(), (record: ChargeRecord) => {
          ListItem() {
            Column() {
              Row() {
                Text(record.stationName)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .layoutWeight(1)
                Text(record.cost.toFixed(1) + '元')
                  .fontSize(14)
                  .fontColor('#FF6B6B')
              }
              .width('100%')
              .margin({ bottom: 6 })

              Row() {
                Text(record.date)
                  .fontSize(12)
                  .fontColor('#999999')
                  .margin({ right: 8 })
                Text(record.stationType)
                  .fontSize(12)
                  .fontColor('#45B7D1')
                  .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                  .backgroundColor('#E0F7FA')
                  .borderRadius(3)
                  .margin({ right: 8 })
                Text(record.power + 'kW')
                  .fontSize(12)
                  .fontColor('#666666')
              }
              .width('100%')
              .margin({ bottom: 6 })

              // SOC进度条
              Row() {
                Text(`SOC ${record.startSoc}%`)
                  .fontSize(12)
                  .fontColor('#999999')
                Text(`${record.endSoc}%`)
                  .fontSize(12)
                  .fontColor('#4ECDC4')
                  .fontWeight(FontWeight.Bold)
                Text(` (${record.chargedKwh}kWh)`)
                  .fontSize(12)
                  .fontColor('#666666')
              }
              .width('100%')
              .margin({ bottom: 4 })

              Text(`${record.duration}分钟`)
                .fontSize(12)
                .fontColor('#999999')
            }
            .width('100%')
            .padding(16)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .shadow({ radius: 4, color: '#00000010', offsetY: 2 })
          }
        }, (record: ChargeRecord) => record.id)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAFAFA')
  }
}

第四步:统计计算流程图

原始充电记录

按月分组

2024-01月数据

2024-02月数据

2024-03月数据

累加费用/电量/次数

累加费用/电量/次数

累加费用/电量/次数

月度汇总表

按条件筛选

筛选后的记录

计算总费用/总电量

计算平均值

计算效率指标

显示统计结果

React vs ArkTS 对比表

功能 React (Web) ArkTS (鸿蒙)
分组聚合 对象做Map 对象做Map(一样)
月份提取 substring(0, 7) substring(0, 7)(一样)
Set去重 new Set() new Set()(一样)
排序 sort + localeCompare sort + localeCompare(一样)
统计显示 JSX模板 Text组件(一样)

总结

这篇文章我们用"电桩记"这个充电记录App,演示了如何在Preferences存储的基础上做统计聚合。核心要点:

  1. "存储原始数据,计算派生数据"的模式:只存充电记录,统计数据实时计算
  2. 分组聚合用对象做Map,key是分组字段(月份),value是聚合结果
  3. 多种统计指标:总量、平均值、效率指标,根据需求选择合适的计算方式
  4. 月份筛选用startsWith匹配,简单高效

统计聚合是数据驱动App的核心能力。Preferences虽然不如数据库强大,但对于个人工具类App的统计需求完全够用。最后一篇我们会聊"叠杯计时",演示高精度计时和动画的组合使用。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐