HarmonyOS APP开发---「「急救囊」急救包管理App,需要用到文件读写和倒计时

3 阅读12分钟

如果你想做一个急救包管理App,需要用到文件读写和倒计时

如果你身边有华为手机或者鸿蒙设备,欢迎去鸿蒙应用市场搜索「急救囊」,下载体验一下这个急救包管理App。帮你管好家中的急救物资,药品效期不再过期都不知道。


写在前面

大家好,我是一名鸿蒙开发者。我家有个急救箱,里面放着各种创可贴、碘伏、纱布、退烧药、止泻药之类的。但有个问题——有些药品是会过期的,而且有些东西用了之后需要补货,但我总是记不住。上次家里小朋友发烧了,翻出退烧药一看,三个月前就过期了,那个尴尬。

所以我做了「急救囊」这个App,用来管理急救包里的物品清单、追踪每件物品的有效期、到期前自动提醒更换。这篇文章的核心是鸿蒙的文件系统 API —— @ohos.file.fs,也就是 @kit.CoreFileKit 里的 fileIo 模块。

为什么选文件存储而不是数据库?因为急救包物品数据量不大,而且我们想把数据导出成 JSON 文件方便备份和分享。用文件存储的话,读写操作直接在文件上完成,不需要额外的序列化/反序列化步骤,代码更简单。同时文件存储还有一个好处:你可以直接用文件管理器找到这个 JSON 文件,拷出来备份,或者发给别人。

这篇文章聊什么

  • 第一步:理解鸿蒙文件系统的基本操作——创建、读写、删除
  • 第二步:React 版本用 localStorage 模拟文件操作
  • 第三步:ArkTS 实现物品清单的文件存储和读取
  • 第四步:ArkTS 实现效期倒计时和过期提醒
  • 第五步:ArkTS 实现步骤卡展开和导出功能
  • 流程图:文件操作流程
  • React vs ArkTS 对比表

第一步:理解鸿蒙的文件系统

鸿蒙的文件操作 API 在 @kit.CoreFileKit 里,模块名叫 fileIo。导入方式:

import { fileIo } from '@kit.CoreFileKit';

文件操作的基本流程是:

1. 确定文件路径:鸿蒙的应用数据存储在应用沙箱里,你可以通过 context 获取沙箱目录。

2. 打开文件:用 openSyncopen。需要指定打开模式(只读、读写、创建等)。

3. 读写文件writeSync 写入、readTextSync 读取文本、readSync 读取二进制。

4. 关闭文件:用 closeSync 关闭文件句柄。这一步很重要,不关闭会泄漏资源。

其他常用操作:

  • mkdirSync:创建目录
  • listFileSync:列出目录下的文件
  • unlinkSync:删除文件
  • copyFileSync:复制文件
  • statSync:获取文件属性(大小、修改时间等)

对于「急救囊」这个App,我们的文件操作策略是:

  • 所有物品数据存在一个 JSON 文件里(比如 firstaid_data.json
  • 每次修改物品后,把整个数据数组序列化成 JSON 写入文件
  • 每次打开 App 时,从文件读取 JSON 反序列化成数据数组
  • 导出时,把文件复制到用户可访问的目录

第二步:React 版本的前端实现

React 版本用 localStorage 模拟文件读写,但逻辑是一致的——核心就是把数据序列化成 JSON 字符串存储,读取时再反序列化。

// FirstAidKitApp.jsx - React版本
import React, { useState, useEffect } from 'react';

// 急救物品数据结构
interface FirstAidItem {
  id: number;
  name: string;
  category: string;    // 分类:药品、敷料、工具、其他
  expiryDate: string;  // 有效期(日期字符串)
  quantity: number;     // 数量
  note: string;        // 备注
  steps: string[];      // 使用步骤(步骤卡)
  addedDate: string;   // 添加日期
}

const CATEGORIES = ['药品', '敷料', '工具', '其他'];

function FirstAidKitApp() {
  const [items, setItems] = useState<FirstAidItem[]>([]);
  const [showAddForm, setShowAddForm] = useState(false);
  const [expandedItem, setExpandedItem] = useState(null);
  const [formName, setFormName] = useState('');
  const [formCategory, setFormCategory] = useState('药品');
  const [formExpiry, setFormExpiry] = useState('');
  const [formQuantity, setFormQuantity] = useState(1);
  const [formNote, setFormNote] = useState('');
  const [formSteps, setFormSteps] = useState('');

  // 加载数据(模拟文件读取)
  useEffect(() => {
    const savedData = localStorage.getItem('firstaid_items');
    if (savedData) {
      setItems(JSON.parse(savedData));
    }
  }, []);

  // 保存数据(模拟文件写入)
  const saveItems = (newItems) => {
    setItems(newItems);
    localStorage.setItem('firstaid_items', JSON.stringify(newItems));
  };

  // 计算距离过期的天数
  const getDaysUntilExpiry = (expiryDate) => {
    const now = new Date();
    const expiry = new Date(expiryDate);
    const diff = expiry.getTime() - now.getTime();
    return Math.ceil(diff / (1000 * 60 * 60 * 24));
  };

  // 添加物品
  const addItem = () => {
    const newItem = {
      id: Date.now(),
      name: formName,
      category: formCategory,
      expiryDate: formExpiry,
      quantity: formQuantity,
      note: formNote,
      steps: formSteps.split('\n').filter(s => s.trim()),
      addedDate: new Date().toISOString().split('T')[0],
    };
    saveItems([...items, newItem]);
    setShowAddForm(false);
    // 清空表单
    setFormName(''); setFormExpiry(''); setFormQuantity(1);
    setFormNote(''); setFormSteps('');
  };

  // 删除物品
  const deleteItem = (id) => {
    saveItems(items.filter(item => item.id !== id));
  };

  // 获取过期状态
  const getExpiryStatus = (days) => {
    if (days < 0) return { text: '已过期', color: '#F44336' };
    if (days <= 7) return { text: `还有${days}天`, color: '#FF9800' };
    if (days <= 30) return { text: `还有${days}天`, color: '#FFC107' };
    return { text: `还有${days}天`, color: '#4CAF50' };
  };

  // 导出数据
  const exportData = () => {
    const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'firstaidkit_backup.json';
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ padding: 20, fontFamily: 'Arial', maxWidth: 500, margin: '0 auto' }}>
      <h1>急救囊 - 急救包管理</h1>

      <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
        <button onClick={() => setShowAddForm(!showAddForm)}>添加物品</button>
        <button onClick={exportData}>导出数据</button>
      </div>

      {/* 添加物品表单 */}
      {showAddForm && (
        <div style={{ padding: 16, border: '1px solid #ddd', borderRadius: 8, marginBottom: 16 }}>
          <input placeholder="物品名称" value={formName} onChange={e => setFormName(e.target.value)} /><br/>
          <select value={formCategory} onChange={e => setFormCategory(e.target.value)}>
            {CATEGORIES.map(c => <option key={c}>{c}</option>)}
          </select><br/>
          <input type="date" value={formExpiry} onChange={e => setFormExpiry(e.target.value)} /><br/>
          <input type="number" placeholder="数量" value={formQuantity} onChange={e => setFormQuantity(Number(e.target.value))} /><br/>
          <input placeholder="备注" value={formNote} onChange={e => setFormNote(e.target.value)} /><br/>
          <textarea placeholder="使用步骤(每行一步)" value={formSteps} onChange={e => setFormSteps(e.target.value)} /><br/>
          <button onClick={addItem}>确认添加</button>
        </div>
      )}

      {/* 物品列表 */}
      {items.map(item => {
        const days = getDaysUntilExpiry(item.expiryDate);
        const status = getExpiryStatus(days);
        const isExpanded = expandedItem === item.id;
        return (
          <div key={item.id} style={{ padding: 12, borderBottom: '1px solid #eee', borderLeft: `4px solid ${status.color}` }}>
            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
              <strong>{item.name}</strong>
              <span style={{ color: status.color }}>{status.text}</span>
            </div>
            <p>{item.category} | 数量: {item.quantity}</p>
            {isExpanded && (
              <div>
                <p>有效期: {item.expiryDate}</p>
                <p>添加日期: {item.addedDate}</p>
                {item.note && <p>备注: {item.note}</p>}
                {item.steps.length > 0 && (
                  <div>
                    <strong>使用步骤:</strong>
                    {item.steps.map((step, i) => <p key={i}>{i + 1}. {step}</p>)}
                  </div>
                )}
                <button onClick={() => deleteItem(item.id)}>删除</button>
              </div>
            )}
            <button onClick={() => setExpandedItem(isExpanded ? null : item.id)}>
              {isExpanded ? '收起' : '展开'}
            </button>
          </div>
        );
      })}
    </div>
  );
}

export default FirstAidKitApp;

React 版本的核心逻辑:数据以 JSON 字符串存在 localStorage 里,每次增删改后重新序列化保存。效期倒计时用日期差值计算天数。步骤卡通过展开/收起状态控制。


第三步:ArkTS 版本 - 文件存储和读取

现在用鸿蒙的 fileIo 来实现真正的文件读写。

// entry/src/main/ets/utils/FileHelper.ets
// 急救囊 - 文件操作工具类

import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';

// 急救物品数据结构
export interface FirstAidItem {
  id: number;
  name: string;
  category: string;
  expiryDate: string;
  quantity: number;
  note: string;
  steps: string[];
  addedDate: string;
}

// 文件操作工具类
export class FileHelper {
  // 数据文件名
  private readonly DATA_FILE_NAME: string = 'firstaid_data.json';
  // 文件句柄
  private fileFD: number = -1;

  // ===== 获取数据文件的完整路径 =====
  // 在鸿蒙里,应用的数据存储在应用沙箱目录下
  // 通过 context.filesDir 可以获取到这个目录的路径
  getDataFilePath(context: common.UIAbilityContext): string {
    // context.filesDir 返回应用沙箱的 files 目录路径
    // 比如返回 "/data/storage/el2/base/haps/entry/files"
    // 我们把数据文件放在这个目录下
    return `${context.filesDir}/${this.DATA_FILE_NAME}`;
  }

  // ===== 检查数据文件是否存在 =====
  async dataFileExists(context: common.UIAbilityContext): Promise<boolean> {
    const filePath = this.getDataFilePath(context);
    try {
      // fileIo.accessSync 检查文件是否存在
      // 如果文件存在返回 true,不存在抛异常
      fileIo.accessSync(filePath);
      return true;
    } catch (err) {
      return false;
    }
  }

  // ===== 确保数据文件存在 =====
  // 如果文件不存在就创建一个空的
  async ensureDataFile(context: common.UIAbilityContext) {
    const filePath = this.getDataFilePath(context);
    const exists = await this.dataFileExists(context);
    if (!exists) {
      // fileIo.openSync 打开/创建文件
      // 参数1: 文件路径
      // 参数2: 打开模式(可以用 | 组合多个模式)
      //   READ_WRITE: 可读可写
      //   CREATE: 不存在则创建
      // 返回值是文件描述符(fd),后续的读写操作都要用到这个 fd
      const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      // 写入一个空数组的 JSON
      fileIo.writeSync(file.fd, '[]');
      // 用完一定要关闭文件
      fileIo.closeSync(file);
      console.info('急救囊:数据文件已创建');
    }
  }

  // ===== 读取物品数据 =====
  // 从 JSON 文件中读取并反序列化成对象数组
  async loadItems(context: common.UIAbilityContext): Promise<FirstAidItem[]> {
    const filePath = this.getDataFilePath(context);
    try {
      // fileIo.readTextSync 直接读取整个文件为文本字符串
      // 这是读取文本文件最方便的方法
      const content = fileIo.readTextSync(filePath);
      // JSON.parse 把文本解析成对象数组
      if (content && content.length > 0) {
        return JSON.parse(content) as FirstAidItem[];
      }
      return [];
    } catch (err) {
      console.error('急救囊:读取数据文件失败:' + JSON.stringify(err));
      return [];
    }
  }

  // ===== 保存物品数据 =====
  // 把对象数组序列化成 JSON 字符串写入文件
  async saveItems(context: common.UIAbilityContext, items: FirstAidItem[]) {
    const filePath = this.getDataFilePath(context);
    try {
      // 打开文件(READ_WRITE | CREATE)
      const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      // JSON.stringify 序列化,第三个参数 2 表示缩进2格,方便阅读
      const jsonContent = JSON.stringify(items, null, 2);
      // fileIo.writeSync 写入内容
      // 参数1: 文件描述符 fd
      // 参数2: 要写入的内容(字符串)
      // 返回值是写入的字节数
      fileIo.writeSync(file.fd, jsonContent);
      // 关闭文件(重要!不关闭的话文件描述符会泄漏)
      fileIo.closeSync(file);
    } catch (err) {
      console.error('急救囊:保存数据文件失败:' + JSON.stringify(err));
    }
  }

  // ===== 导出数据到备份文件 =====
  // 复制数据文件到一个新的位置,方便用户备份
  async exportData(context: common.UIAbilityContext): Promise<string | null> {
    const sourcePath = this.getDataFilePath(context);
    const exportPath = `${context.filesDir}/firstaid_backup_${Date.now()}.json`;
    try {
      // fileIo.copyFileSync 复制文件
      // 参数1: 源文件路径
      // 参数2: 目标文件路径
      fileIo.copyFileSync(sourcePath, exportPath);
      return exportPath;
    } catch (err) {
      console.error('急救囊:导出数据失败:' + JSON.stringify(err));
      return null;
    }
  }

  // ===== 获取文件信息 =====
  async getFileInfo(context: common.UIAbilityContext) {
    const filePath = this.getDataFilePath(context);
    try {
      // fileIo.statSync 获取文件属性
      // 返回 Stat 对象,包含 size(文件大小,字节)和 mtime(修改时间)
      const stat = fileIo.statSync(filePath);
      return {
        size: stat.size,        // 文件大小
        lastModified: stat.mtime, // 最后修改时间
      };
    } catch (err) {
      console.error('急救囊:获取文件信息失败:' + JSON.stringify(err));
      return null;
    }
  }
}

// 导出单例
export const fileHelper = new FileHelper();

关于文件操作有几个要注意的地方:

fileIo.openSync 的模式组合OpenMode.READ_WRITE | OpenMode.CREATE 表示以读写模式打开,如果文件不存在就创建。| 是位运算符的或,用来组合多个模式标志。

文件描述符(fd)的管理:每次 openSync 都会返回一个文件描述符,这个 fd 是系统资源,用完必须 closeSync 关掉。如果忘记关,累积多了会导致系统资源耗尽。这是一个常见的新手坑。

readTextSync vs readSyncreadTextSync 适合读取文本文件(JSON、TXT等),直接返回字符串。readSync 读取二进制数据,返回 ArrayBuffer。我们存的是 JSON 文本,用 readTextSync 就够了。

writeSync 的覆盖行为:默认情况下 writeSync 是从文件开头写入,会覆盖已有内容。如果你想追加,需要设置偏移量。对于我们的场景,每次保存都是全量覆盖(整个数据数组序列化后写入),所以默认行为是对的。


第四步:ArkTS 版本 - 主页面(含效期倒计时和步骤卡)

// entry/src/main/ets/pages/Index.ets
// 急救囊 - 主页面

import { fileIo } from '@kit.CoreFileKit';
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { fileHelper, FirstAidItem } from '../utils/FileHelper';

const CATEGORIES: string[] = ['药品', '敷料', '工具', '其他'];

@Entry
@Component
struct Index {
  // 物品列表
  @State itemList: FirstAidItem[] = [];
  // 展开的物品ID(null表示没有展开的)
  @State expandedItemId: number = -1;
  // 显示添加表单
  @State showAddForm: boolean = false;
  // 显示导入导出菜单
  @State showExportMenu: boolean = false;
  // 过期预警数量
  @State warningCount: number = 0;

  // 表单数据
  @State formName: string = '';
  @State formCategory: string = '药品';
  @State formExpiry: string = '';
  @State formQuantity: string = '1';
  @State formNote: string = '';
  @State formSteps: string = '';

  // context 引用
  private context: common.UIAbilityContext | null = null;

  async aboutToAppear() {
    // 获取 UIAbilityContext
    // 在 @Entry 组件中可以通过 this.getContext 获取
    this.context = this.getContext(this) as common.UIAbilityContext;
    // 确保数据文件存在
    await fileHelper.ensureDataFile(this.context);
    // 加载物品数据
    await this.loadItems();
    // 计算过期预警
    this.calculateWarnings();
  }

  // ===== 加载物品数据 =====
  async loadItems() {
    if (!this.context) return;
    this.itemList = await fileHelper.loadItems(this.context);
  }

  // ===== 保存物品数据 =====
  async saveItemsData() {
    if (!this.context) return;
    await fileHelper.saveItems(this.context, this.itemList);
    this.calculateWarnings();
  }

  // ===== 计算过期预警数量 =====
  calculateWarnings() {
    const now = new Date();
    this.warningCount = this.itemList.filter((item) => {
      const expiry = new Date(item.expiryDate);
      const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
      return daysLeft <= 30; // 30天内到期的都算预警
    }).length;
  }

  // ===== 计算距离过期的天数 =====
  getDaysUntilExpiry(expiryDate: string): number {
    const now = new Date();
    const expiry = new Date(expiryDate);
    return Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  }

  // ===== 获取过期状态 =====
  getExpiryStatus(days: number): { text: string; color: string } {
    if (days < 0) return { text: `已过期${Math.abs(days)}天`, color: '#F44336' };
    if (days === 0) return { text: '今天到期', color: '#F44336' };
    if (days <= 7) return { text: `还有${days}天`, color: '#FF9800' };
    if (days <= 30) return { text: `还有${days}天`, color: '#FFC107' };
    return { text: `还有${days}天`, color: '#4CAF50' };
  }

  // ===== 添加物品 =====
  async addItem() {
    if (this.formName.trim() === '') {
      this.showToast('请输入物品名称');
      return;
    }
    if (this.formExpiry === '') {
      this.showToast('请选择有效期');
      return;
    }

    const newItem: FirstAidItem = {
      id: Date.now(),
      name: this.formName.trim(),
      category: this.formCategory,
      expiryDate: this.formExpiry,
      quantity: parseInt(this.formQuantity) || 1,
      note: this.formNote.trim(),
      steps: this.formSteps.split('\n').filter((s) => s.trim().length > 0),
      addedDate: new Date().toLocaleDateString(),
    };

    // 往数组里追加新物品
    // ArkTS 中更新 @State 数组要用新数组替换
    this.itemList = [...this.itemList, newItem];
    // 保存到文件
    await this.saveItemsData();

    // 关闭表单,清空字段
    this.showAddForm = false;
    this.formName = '';
    this.formExpiry = '';
    this.formQuantity = '1';
    this.formNote = '';
    this.formSteps = '';
    this.showToast('物品添加成功');
  }

  // ===== 删除物品 =====
  async deleteItem(itemId: number) {
    // 用 filter 过滤掉要删除的物品
    this.itemList = this.itemList.filter((item) => item.id !== itemId);
    // 如果展开的正好是被删除的物品,关闭展开
    if (this.expandedItemId === itemId) {
      this.expandedItemId = -1;
    }
    await this.saveItemsData();
    this.showToast('物品已删除');
  }

  // ===== 导出数据 =====
  async exportData() {
    if (!this.context) return;
    const exportPath = await fileHelper.exportData(this.context);
    if (exportPath) {
      this.showToast('数据已导出');
      console.info('导出路径:' + exportPath);
    } else {
      this.showToast('导出失败');
    }
  }

  // ===== 切换展开/收起 =====
  toggleExpand(itemId: number) {
    // 如果当前展开的就是这个物品,收起
    // 否则展开这个物品
    this.expandedItemId = this.expandedItemId === itemId ? -1 : itemId;
  }

  showToast(message: string) {
    this.getUIContext().getPromptAction().openToast({
      message: message,
      duration: 2000,
      bottom: 100,
    });
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Text('急救囊 - 急救包管理')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)

        if (this.warningCount > 0) {
          Text(`${this.warningCount}项预警`)
            .fontSize(14)
            .fontColor('#FFFFFF')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor('#FF5722')
            .borderRadius(12)
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 8 })

      // 操作按钮
      Row({ space: 12 }) {
        Button('添加物品')
          .height(40)
          .backgroundColor('#4CAF50')
          .fontColor(Color.White)
          .onClick(() => { this.showAddForm = !this.showAddForm; })
        Button('导出数据')
          .height(40)
          .backgroundColor('#2196F3')
          .fontColor(Color.White)
          .onClick(() => { this.exportData(); })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 4, bottom: 8 })

      Scroll() {
        Column({ space: 12 }) {
          // 添加物品表单
          if (this.showAddForm) {
            Column({ space: 12 }) {
              Text('添加急救物品')
                .fontSize(18)
                .fontWeight(FontWeight.Medium)

              TextInput({ placeholder: '物品名称' })
                .onChange((value) => { this.formName = value; })

              Text('分类')
                .fontSize(14)
                .fontColor('#666666')
              Flex({ wrap: FlexWrap.Wrap }) {
                ForEach(CATEGORIES, (cat: string) => {
                  Button(cat)
                    .height(36)
                    .fontSize(14)
                    .backgroundColor(this.formCategory === cat ? '#4CAF50' : '#EEEEEE')
                    .fontColor(this.formCategory === cat ? '#FFFFFF' : '#333333')
                    .borderRadius(8)
                    .margin({ right: 8, bottom: 4 })
                    .onClick(() => { this.formCategory = cat; })
                }, (cat: string) => cat)
              }

              Text('有效期')
                .fontSize(14)
                .fontColor('#666666')
              TextInput({ placeholder: 'YYYY-MM-DD' })
                .onChange((value) => { this.formExpiry = value; })

              TextInput({ placeholder: '数量' })
                .type(InputType.Number)
                .onChange((value) => { this.formQuantity = value; })

              TextInput({ placeholder: '备注' })
                .onChange((value) => { this.formNote = value; })

              TextArea({ placeholder: '使用步骤(每行一步)' })
                .height(80)
                .onChange((value) => { this.formSteps = value; })

              Row({ space: 12 }) {
                Button('确认添加')
                  .backgroundColor('#4CAF50')
                  .fontColor(Color.White)
                  .onClick(() => { this.addItem(); })
                Button('取消')
                  .onClick(() => { this.showAddForm = false; })
              }
            }
            .padding(16)
            .backgroundColor('#E8F5E9')
            .borderRadius(12)
            .width('100%')
          }

          // 物品列表
          if (this.itemList.length === 0) {
            Text('急救包还是空的,快添加物品吧')
              .fontSize(16)
              .fontColor('#999999')
              .width('100%')
              .textAlign(TextAlign.Center)
              .padding(40)
          }

          ForEach(this.itemList, (item: FirstAidItem) => {
            const days = this.getDaysUntilExpiry(item.expiryDate);
            const status = this.getExpiryStatus(days);
            const isExpanded = this.expandedItemId === item.id;

            Column() {
              // 物品信息行
              Row() {
                // 左侧颜色指示条通过整体背景色模拟
                Column() {
                  Row() {
                    Text(item.name)
                      .fontSize(16)
                      .fontWeight(FontWeight.Bold)
                      .layoutWeight(1)
                    Text(item.category)
                      .fontSize(12)
                      .fontColor('#FFFFFF')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor('#2196F3')
                      .borderRadius(4)
                    Text(`x${item.quantity}`)
                      .fontSize(14)
                      .fontColor('#666666')
                  }
                  .width('100%')

                  Row() {
                    Text(status.text)
                      .fontSize(13)
                      .fontColor(status.color)
                      .fontWeight(FontWeight.Bold)
                    Text('  |  点击展开/收起')
                      .fontSize(12)
                      .fontColor('#999999')
                  }
                  .width('100%')
                }
                .alignItems(HorizontalAlign.Start)
                .padding(12)
              }
              .width('100%')
              .onClick(() => { this.toggleExpand(item.id); })

              // 展开详情
              if (isExpanded) {
                Column({ space: 8 }) {
                  // 效期倒计时(大字显示)
                  if (days <= 30) {
                    Row() {
                      Text(days < 0 ? `已过期 ${Math.abs(days)} 天!` : `即将到期:${days} 天`)
                        .fontSize(18)
                        .fontWeight(FontWeight.Bold)
                        .fontColor(status.color)
                    }
                    .width('100%')
                    .padding(8)
                    .backgroundColor('#FFF3E0')
                    .borderRadius(8)
                  }

                  // 基本信息
                  Row() {
                    Text('有效期:').fontSize(14).fontColor('#666').width(70)
                    Text(item.expiryDate).fontSize(14).layoutWeight(1)
                  }
                  Row() {
                    Text('添加日期:').fontSize(14).fontColor('#666').width(70)
                    Text(item.addedDate).fontSize(14).layoutWeight(1)
                  }

                  // 备注
                  if (item.note.length > 0) {
                    Row() {
                      Text('备注:').fontSize(14).fontColor('#666').width(70)
                      Text(item.note).fontSize(14).layoutWeight(1)
                    }
                  }

                  // 使用步骤卡
                  if (item.steps.length > 0) {
                    Column({ space: 6 }) {
                      Text('使用步骤')
                        .fontSize(15)
                        .fontWeight(FontWeight.Medium)
                        .alignSelf(ItemAlign.Start)

                      ForEach(item.steps, (step: string, index: number) => {
                        Row() {
                          // 步骤序号圆圈
                          Text(`${index + 1}`)
                            .fontSize(12)
                            .fontColor('#FFFFFF')
                            .width(24)
                            .height(24)
                            .borderRadius(12)
                            .backgroundColor('#4CAF50')
                            .textAlign(TextAlign.Center)

                          Text(step)
                            .fontSize(14)
                            .layoutWeight(1)
                        }
                        .width('100%')
                        .alignItems(VerticalAlign.Center)
                      }, (step: string, index: number) => `${index}`)
                    }
                    .width('100%')
                    .padding(12)
                    .backgroundColor('#F5F5F5')
                    .borderRadius(8)
                  }

                  // 删除按钮
                  Button('删除此物品')
                    .width('100%')
                    .height(40)
                    .backgroundColor('#F44336')
                    .fontColor(Color.White)
                    .onClick(() => {
                      this.deleteItem(item.id);
                    })
                }
                .width('100%')
                .padding({ left: 12, right: 12, bottom: 12 })
              }
            }
            .width('100%')
            .backgroundColor('#FFFFFF')
            .borderRadius(8)
            .shadow({ radius: 4, color: '#00000008', offsetY: 2 })
          }, (item: FirstAidItem) => `${item.id}`)
        }
        .padding({ left: 16, right: 16, bottom: 24 })
      }
      .layoutWeight(1)
    }
    .height('100%')
    .width('100%')
    .backgroundColor('#FAFAFA')
  }
}

流程图

flowchart TD
    A[App启动] --> B[ensureDataFile 检查/创建数据文件]
    B --> C[loadItems 从 JSON 文件读取数据]
    C --> D[calculateWarnings 计算过期预警]
    D --> E[显示物品列表]

    E --> F{用户操作}
    F -->|添加物品| G[创建 FirstAidItem 对象]
    G --> H[追加到 itemList 数组]
    H --> I[saveItems 序列化写入文件]
    I --> J[recalculateWarnings]
    J --> E

    F -->|展开物品| K[显示详情+步骤卡+效期倒计时]
    K --> L{30天内到期?}
    L -->|是| M[显示预警高亮]
    L -->|否| N[正常显示]

    F -->|删除物品| O[从数组中过滤]
    O --> I

    F -->|导出数据| P[copyFileSync 复制到备份文件]
    P --> Q[Toast 提示导出成功]

React vs ArkTS 对比表

对比项React (Web)ArkTS (鸿蒙)
存储方式localStorage (JSON字符串)fileIo (文件系统)
初始化无需,直接读写ensureDataFile 检查/创建文件
读取数据JSON.parse(localStorage.getItem(key))JSON.parse(fileIo.readTextSync(filePath))
保存数据localStorage.setItem(key, JSON.stringify(data))openSync + writeSync + closeSync
导出备份Blob + URL.createObjectURL + <a>copyFileSync 复制到沙箱目录
文件信息无直接获取方式fileIo.statSync 获取 size/mtime
文件删除无对应操作fileIo.unlinkSync
目录操作无对应操作fileIo.mkdirSync / listFileSync
数据安全同源策略限制应用沙箱隔离

总结

这篇文章我们用鸿蒙的文件系统 @ohos.file.fsfileIo)实现了「急救囊」急救包管理App。核心知识点:

文件创建openSync(path, OpenMode.READ_WRITE | OpenMode.CREATE) 同时处理打开和创建。记得用 | 组合模式。

文件写入writeSync(file.fd, content) 写入字符串。写入后必须 closeSync(file) 关闭文件描述符。

文件读取readTextSync(filePath) 读取整个文件为字符串。配合 JSON.parse 反序列化。

JSON 序列化存储:把数组序列化成 JSON 字符串存文件,是文件存储中最常见的数据格式。JSON.stringify(data, null, 2) 加缩进方便阅读。

文件复制和属性copyFileSync 复制文件用于备份,statSync 获取文件大小和修改时间。

下一篇我们来做「更年记」症状追踪App,核心是关系型数据库的复杂查询和日历热图。