如果你想做一个车辆保养App,需要用到Preferences和通知提醒

总是忘记车子什么时候该保养?打开鸿蒙应用市场,搜索"车管家",下载安装后就能管理车辆的保养记录了。自动计算下次保养时间和里程,还能设置提醒通知,再也不怕错过保养。


写在前面

大家好,今天聊一个跟之前都不太一样的App。前几篇主要讲数据存储,这篇要加入一个新元素:通知提醒。

"车管家"是一个车辆保养管理工具。它的核心功能是记录每次保养的信息(机油、轮胎、刹车片等),然后根据保养间隔(里程或时间)自动计算下次保养的时间,并在快到保养时间时发送通知提醒。

在Web端,通知用的是Notification API;在鸿蒙端,对应的是@ohos.notificationManager。两者的思路差不多:创建一个通知对象,设置标题和内容,然后发送出去。

这篇文章聊什么

  • 保养记录的数据结构设计
  • 里程和时间双重提醒的计算逻辑
  • Web端Notification API的用法
  • 鸿蒙端@ohos.notificationManager的用法
  • Preferences存储 + 通知的组合使用
  • 提醒判断的流程图

第一步:设计数据结构

// 保养类型
interface MaintenanceType {
  name: string;          // 名称:机油/轮胎/刹车片/空调滤芯/火花塞
  intervalMileage: number; // 保养间隔里程(公里)
  intervalMonths: number;   // 保养间隔月数
  category: string;      // 分类:发动机/底盘/电气/车身
}

// 保养记录
interface MaintenanceRecord {
  id: string;
  typeName: string;      // 保养类型名称
  mileage: number;        // 保养时的里程数
  cost: number;           // 花费
  date: string;           // 保养日期
  location: string;       // 保养地点
  note: string;           // 备注
  nextMileage: number;    // 下次保养里程
  nextDate: string;       // 下次保养日期
}

// 车辆信息
interface Vehicle {
  id: string;
  name: string;           // 车辆昵称
  brand: string;           // 品牌
  model: string;           // 车型
  currentMileage: number;  // 当前里程
  purchaseDate: string;    // 购车日期
  records: MaintenanceRecord[]; // 保养记录
}

这里的关键是nextMileagenextDate。每次保养后,根据保养类型的间隔自动计算下次保养的里程和时间。比如机油每5000公里或6个月保养一次,那么上次在10000公里做的保养,下次就是15000公里或6个月后。

第二步:React版本 – localStorage + Notification

2.1 保养记录管理

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

// 预设保养类型
const MAINTENANCE_TYPES = [
  { name: '机油', intervalMileage: 5000, intervalMonths: 6, category: '发动机' },
  { name: '机油滤芯', intervalMileage: 5000, intervalMonths: 6, category: '发动机' },
  { name: '空气滤芯', intervalMileage: 10000, intervalMonths: 12, category: '发动机' },
  { name: '刹车片', intervalMileage: 30000, intervalMonths: 24, category: '底盘' },
  { name: '轮胎', intervalMileage: 40000, intervalMonths: 36, category: '底盘' },
  { name: '空调滤芯', intervalMileage: 10000, intervalMonths: 12, category: '车身' },
  { name: '火花塞', intervalMileage: 20000, intervalMonths: 24, category: '电气' }
];

function CarManager() {
  const [vehicle, setVehicle] = useState(null);
  const [records, setRecords] = useState([]);

  useEffect(() => {
    const savedVehicle = localStorage.getItem('cheguanjia_vehicle');
    const savedRecords = localStorage.getItem('cheguanjia_records');
    if (savedVehicle) setVehicle(JSON.parse(savedVehicle));
    if (savedRecords) setRecords(JSON.parse(savedRecords));
  }, []);

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

  // 添加保养记录
  const addRecord = (typeName, mileage, cost, location, note) => {
    const type = MAINTENANCE_TYPES.find(t => t.name === typeName);
    if (!type) return;

    const record = {
      id: Date.now().toString(36) + Math.random().toString(36).substr(2),
      typeName,
      mileage,
      cost,
      date: new Date().toISOString().split('T')[0],
      location,
      note,
      nextMileage: mileage + type.intervalMileage,
      nextDate: getNextDate(type.intervalMonths)
    };
    setRecords(prev => [...prev, record].sort((a, b) => b.mileage - a.mileage));
  };

  // 计算下次保养日期
  function getNextDate(months) {
    const date = new Date();
    date.setMonth(date.getMonth() + months);
    return date.toISOString().split('T')[0];
  }

  // 检查是否需要提醒
  const getUpcomingMaintenance = () => {
    const today = new Date().toISOString().split('T')[0];
    const currentMileage = vehicle?.currentMileage || 0;
    const reminderDays = 30; // 提前30天提醒
    const reminderMileage = 500; // 提前500公里提醒

    return records.filter(record => {
      const daysUntilNext = Math.ceil(
        (new Date(record.nextDate).getTime() - new Date(today).getTime()) / (1000 * 60 * 60 * 24)
      );
      const mileageUntilNext = record.nextMileage - currentMileage;
      return daysUntilNext <= reminderDays || mileageUntilNext <= reminderMileage;
    });
  };

  // 发送浏览器通知
  const sendNotification = (title, body) => {
    if ('Notification' in window && Notification.permission === 'granted') {
      new Notification(title, { body });
    } else if ('Notification' in window && Notification.permission !== 'denied') {
      Notification.requestPermission().then(permission => {
        if (permission === 'granted') {
          new Notification(title, { body });
        }
      });
    }
  };

  // 统计总花费
  const getTotalCost = () => {
    return records.reduce((sum, r) => sum + r.cost, 0);
  };

  return (
    <div className="car-manager">
      <h1>车管家</h1>

      {/* 提醒区域 */}
      {getUpcomingMaintenance().length > 0 && (
        <div className="reminder-section">
          <h3>保养提醒</h3>
          {getUpcomingMaintenance().map(record => (
            <div key={record.id} className="reminder-item">
              <p>{record.typeName}:下次保养 {record.nextMileage}km 或 {record.nextDate}</p>
              <button onClick={() => sendNotification(
                '保养提醒',
                `${record.typeName}即将到保养时间,请尽快安排`
              )}>发送提醒</button>
            </div>
          ))}
        </div>
      )}

      {/* 统计信息 */}
      <div className="stats">
        <p>总保养次数:{records.length}</p>
        <p>总花费:{getTotalCost()}元</p>
      </div>

      {/* 保养记录列表 */}
      <div className="record-list">
        {records.map(record => (
          <div key={record.id} className="record-card">
            <h4>{record.typeName}</h4>
            <p>里程:{record.mileage}km | 花费:{record.cost}元</p>
            <p>下次保养:{record.nextMileage}km / {record.nextDate}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

2.2 Web端Notification要点

Web端的Notification API需要用户授权。第一次调用时需要requestPermission,用户同意后才能发送通知。这个授权是一次性的,用户同意后后续不需要再请求。

// 检查通知权限
if ('Notification' in window) {
  if (Notification.permission === 'granted') {
    // 已授权,直接发送
    new Notification('标题', { body: '内容' });
  } else if (Notification.permission !== 'denied') {
    // 未授权也未拒绝,请求授权
    Notification.requestPermission().then(permission => {
      if (permission === 'granted') {
        new Notification('标题', { body: '内容' });
      }
    });
  }
}

第三步:ArkTS版本 – Preferences + notificationManager

3.1 导入依赖

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

这里多导入了两个模块:notificationManager用于发送通知,wantAgent用于点击通知后跳转到指定页面。

3.2 通知发送方法

// 发送保养提醒通知
async function sendMaintenanceNotification(title: string, content: string) {
  try {
    // 创建通知请求
    let request: notificationManager.NotificationRequest = {
      id: Date.now(),
      content: {
        contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: title,
          text: content
        }
      }
    };

    // 发送通知
    await notificationManager.publish(request);
    console.info('通知发送成功');
  } catch (err) {
    console.error('通知发送失败', JSON.stringify(err));
  }
}

这里有几个要点:

NotificationRequest:通知请求对象,包含id和content。id是通知的唯一标识,content是通知的内容。content有多种类型,我们用的是BASIC_TEXT,就是最简单的文字通知。

contentType:通知内容类型,BASIC_TEXT是最常用的。还有LONG_TEXT(长文本)、PICTURE(图片)等类型。

publish:发送通知的方法,是异步的。发送成功后,通知会显示在系统的通知栏里。

3.3 完整页面组件

interface MaintenanceRecord {
  id: string;
  typeName: string;
  mileage: number;
  cost: number;
  date: string;
  location: string;
  note: string;
  nextMileage: number;
  nextDate: string;
}

interface VehicleInfo {
  name: string;
  brand: string;
  model: string;
  currentMileage: number;
  purchaseDate: string;
}

const MAINTENANCE_TYPES = [
  { name: '机油', intervalMileage: 5000, intervalMonths: 6, category: '发动机' },
  { name: '机油滤芯', intervalMileage: 5000, intervalMonths: 6, category: '发动机' },
  { name: '空气滤芯', intervalMileage: 10000, intervalMonths: 12, category: '发动机' },
  { name: '刹车片', intervalMileage: 30000, intervalMonths: 24, category: '底盘' },
  { name: '轮胎', intervalMileage: 40000, intervalMonths: 36, category: '底盘' },
  { name: '空调滤芯', intervalMileage: 10000, intervalMonths: 12, category: '车身' },
  { name: '火花塞', intervalMileage: 20000, intervalMonths: 24, category: '电气' }
];

@Entry
@Component
struct CarManagerPage {
  @State vehicle: VehicleInfo = { name: '', brand: '', model: '', currentMileage: 0, purchaseDate: '' };
  @State records: MaintenanceRecord[] = [];
  @State upcomingList: MaintenanceRecord[] = [];
  private preferencesStore: preferences.Preferences | null = null;

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

    // 加载车辆信息
    const vehicleData = await this.preferencesStore.get('vehicle', '');
    if (vehicleData && typeof vehicleData === 'string' && vehicleData.length > 0) {
      this.vehicle = JSON.parse(vehicleData) as VehicleInfo;
    }

    // 加载保养记录
    const recordsData = await this.preferencesStore.get('records', '');
    if (recordsData && typeof recordsData === 'string' && recordsData.length > 0) {
      this.records = JSON.parse(recordsData) as MaintenanceRecord[];
    }

    // 检查提醒
    this.checkReminders();
  }

  // 保存数据
  async saveData() {
    if (!this.preferencesStore) return;
    await this.preferencesStore.put('vehicle', JSON.stringify(this.vehicle));
    await this.preferencesStore.put('records', JSON.stringify(this.records));
    await this.preferencesStore.flush();
  }

  // 计算下次保养日期
  getNextDate(months: number): string {
    const date = new Date();
    date.setMonth(date.getMonth() + months);
    return date.toISOString().split('T')[0];
  }

  // 添加保养记录
  async addRecord(typeName: string, mileage: number, cost: number, location: string, note: string) {
    const type = MAINTENANCE_TYPES.find(t => t.name === typeName);
    if (!type) return;

    const record: MaintenanceRecord = {
      id: Date.now().toString(36) + Math.random().toString(36).substring(2),
      typeName,
      mileage,
      cost,
      date: new Date().toISOString().split('T')[0],
      location,
      note,
      nextMileage: mileage + type.intervalMileage,
      nextDate: this.getNextDate(type.intervalMonths)
    };
    this.records = [record, ...this.records].sort((a, b) => b.mileage - a.mileage);
    await this.saveData();
    this.checkReminders();
  }

  // 检查保养提醒
  checkReminders() {
    const today = new Date().toISOString().split('T')[0];
    const reminderDays = 30;
    const reminderMileage = 500;

    this.upcomingList = this.records.filter(record => {
      const daysUntilNext = Math.ceil(
        (new Date(record.nextDate).getTime() - new Date(today).getTime()) / (1000 * 60 * 60 * 24)
      );
      const mileageUntilNext = record.nextMileage - this.vehicle.currentMileage;
      return daysUntilNext <= reminderDays || mileageUntilNext <= reminderMileage;
    });
  }

  // 发送通知
  async sendNotification(title: string, content: string) {
    try {
      let request: notificationManager.NotificationRequest = {
        id: Date.now(),
        content: {
          contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
          normal: {
            title: title,
            text: content
          }
        }
      };
      await notificationManager.publish(request);
    } catch (err) {
      console.error('通知发送失败', JSON.stringify(err));
    }
  }

  // 统计总花费
  getTotalCost(): number {
    return this.records.reduce((sum, r) => sum + r.cost, 0);
  }

  build() {
    Column() {
      Text('车管家')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 16 })

      // 车辆信息卡片
      Column() {
        Text(this.vehicle.name || '未设置车辆')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
        if (this.vehicle.brand) {
          Text(`${this.vehicle.brand} ${this.vehicle.model}`)
            .fontSize(14)
            .fontColor('#666666')
            .margin({ top: 4 })
        }
        Text(`当前里程:${this.vehicle.currentMileage}km`)
          .fontSize(14)
          .fontColor('#333333')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ left: 16, right: 16, bottom: 16 })

      // 保养提醒区域
      if (this.upcomingList.length > 0) {
        Column() {
          Text('保养提醒')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF4444')
            .margin({ bottom: 8 })

          ForEach(this.upcomingList, (record: MaintenanceRecord) => {
            Row() {
              Column() {
                Text(record.typeName)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                Text(`下次:${record.nextMileage}km / ${record.nextDate}`)
                  .fontSize(12)
                  .fontColor('#FF6B6B')
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Button('提醒')
                .fontSize(12)
                .backgroundColor('#FF4444')
                .fontColor('#FFFFFF')
                .onClick(() => {
                  this.sendNotification(
                    '保养提醒',
                    `${record.typeName}即将到保养时间,请尽快安排`
                  );
                })
            }
            .width('100%')
            .padding(12)
            .backgroundColor('#FFF5F5')
            .borderRadius(8)
            .margin({ bottom: 6 })
          }, (record: MaintenanceRecord) => record.id)
        }
        .padding(16)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .margin({ left: 16, right: 16, bottom: 16 })
      }

      // 统计信息
      Row() {
        Text(`保养次数:${this.records.length}`)
          .fontSize(14)
          .fontColor('#666666')
          .layoutWeight(1)
        Text(`总花费:${this.getTotalCost()}`)
          .fontSize(14)
          .fontColor('#FF6B6B')
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ left: 16, right: 16, bottom: 16 })

      // 保养记录列表
      List({ space: 8 }) {
        ForEach(this.records, (record: MaintenanceRecord) => {
          ListItem() {
            Column() {
              Row() {
                Text(record.typeName)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .layoutWeight(1)
                Text(record.date)
                  .fontSize(12)
                  .fontColor('#999999')
              }
              .width('100%')
              .margin({ bottom: 6 })

              Row() {
                Text(`${record.mileage}km`)
                  .fontSize(13)
                  .fontColor('#333333')
                  .margin({ right: 12 })
                Text(`${record.cost}`)
                  .fontSize(13)
                  .fontColor('#FF6B6B')
                  .margin({ right: 12 })
                Text(record.location || '未记录')
                  .fontSize(12)
                  .fontColor('#999999')
              }
              .width('100%')
              .margin({ bottom: 6 })

              Text(`下次保养:${record.nextMileage}km / ${record.nextDate}`)
                .fontSize(12)
                .fontColor('#4ECDC4')
            }
            .width('100%')
            .padding(16)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .shadow({ radius: 4, color: '#00000010', offsetY: 2 })
          }
        }, (record: MaintenanceRecord) => record.id)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAFAFA')
  }
}

第四步:提醒判断流程图

App启动/数据更新

加载保养记录

遍历每条记录

计算距下次保养天数

天数 <= 30?

加入提醒列表

剩余里程 <= 500km?

不需要提醒

显示提醒卡片

用户点击提醒按钮

发送通知

通知显示在通知栏

React vs ArkTS 对比表

功能 React (Web) ArkTS (鸿蒙)
通知权限 Notification.requestPermission module.json5声明 + 动态授权
发送通知 new Notification(title, options) notificationManager.publish(request)
通知内容 title + body contentType + normal对象
通知ID 自动生成 手动指定
里程计算 纯JS逻辑 纯JS逻辑(一样)
日期计算 Date对象 + setMonth Date对象 + setMonth(一样)

鸿蒙发送通知需要在module.json5中声明通知权限,这一点跟Web端不同。Web端是在运行时请求权限,鸿蒙端是先在配置文件中声明,然后在运行时也可以动态请求。

总结

这篇文章我们用"车管家"这个车辆保养管理App,演示了Preferences存储和通知提醒的组合使用。核心要点:

  1. 里程和时间双重提醒的计算逻辑:分别计算剩余天数和剩余里程
  2. Web端Notification API需要用户授权,鸿蒙端notificationManager需要在配置文件中声明权限
  3. 通知的创建和发送都是异步操作
  4. Preferences存储车辆信息和保养记录,提醒数据通过计算得出,不需要额外存储

通知功能让App从"被动记录"变成了"主动提醒",用户体验提升很大。下一篇我们会聊"改车志",演示多维筛选和排序逻辑。

更多推荐