如果你想做一个急救包管理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. 打开文件:用 openSync 或 open。需要指定打开模式(只读、读写、创建等)。
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 readSync:readTextSync 适合读取文本文件(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.fs(fileIo)实现了「急救囊」急救包管理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,核心是关系型数据库的复杂查询和日历热图。