HarmonyOS ArkTS API 24实现从零手写鸿蒙 ArkTS 食材管理系统(含理赔、弹窗、数据渲染)
前言

食材保险App是一款基于鸿蒙ArkTS开发的单页面应用,核心解决两个生活痛点:一是家庭食材管理混乱、过期浪费严重;二是食材变质引发健康问题后缺乏保障机制。通过数字化的方式将食材采购、保鲜跟踪、保险理赔三个环节串联起来,用户只需在发现食材问题时一键发起理赔,系统自动审核并在1-3个工作日内完成赔付。本文围绕完整代码逐模块讲解,涵盖数据模型设计、响应式状态管理、弹框交互、列表渲染等核心功能。
一、项目结构与文件概览
FoodInsuranceApp/
└── entry/src/main/ets/pages/
└── Index.ets // 单文件包含所有代码,约900行
项目采用单文件架构,所有代码集中在一个.ets文件中,便于整体阅读和调试。实际工程中建议按组件拆分为独立.ets文件。
二、数据模型设计
2.1 食材项FoodItem
@Observed
class FoodItem {
id: string; // 唯一标识
name: string; // 食材名称
category: string; // 分类:蔬菜/肉类/水果/乳制品/海鲜
purchaseDate: string; // 购买日期
expiryDate: string; // 保质期截止日
freshDays: number; // 保鲜天数
isInsured: boolean; // 是否投保
coverUrl: ResourceStr; // 图片资源
description: string; // 备注描述
status: 'fresh' | 'warning' | 'expired'; // 计算属性
calcStatus(): 'fresh' | 'warning' | 'expired' {
const today = new Date();
const expiry = new Date(this.expiryDate);
const diff = Math.floor((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diff < 0) return 'expired'; // 已过保质期
if (diff <= 2) return 'warning'; // 临期(2天内)
return 'fresh'; // 新鲜
}
}
设计要点:@Observed装饰器使类属性变更可被ArkUI框架追踪,从而触发UI自动刷新。status是一个派生属性,通过calcStatus()方法实时计算当前食材所处的新鲜阶段。计算逻辑基于当前日期与保质期截止日的差值,分为三级:新鲜(剩余>2天)、临期(剩余1-2天)、过期(已过期)。

2.2 投保记录InsuranceRecord
@Observed
class InsuranceRecord {
id: string;
foodId: string; // 关联食材ID
foodName: string; // 冗余存储便于展示
claimDate: string; // 理赔申请日期
claimAmount: number; // 理赔金额
status: 'pending' | 'approved' | 'rejected'; // 三种审核状态
reason: string; // 理赔原因描述
}
设计要点:使用pending(审核中)、approved(已通过)、rejected(已驳回)三个状态覆盖完整业务流程。理赔金额支持系统自动估算或用户手动输入,适配不同场景需求。
2.3 分类配置表
const CATEGORY_CONFIGS: Record<string, CategoryConfig> = {
'蔬菜': { label: '蔬菜', icon: '🌿', color: '#4CAF50' },
'肉类': { label: '肉类', icon: '🥩', color: '#F44336' },
'水果': { label: '水果', icon: '🍎', color: '#FF9800' },
'乳制品': { label: '乳制品', icon: '🥛', color: '#2196F3' },
'海鲜': { label: '海鲜', icon: '🦐', color: '#00BCD4' },
};
使用Record<string, CategoryConfig>类型定义固定分类配置,包含标签名、表情符号、主题色三个维度,在UI渲染时直接引用,避免散落在各处硬编码。

三、页面整体布局架构
主Index组件通过Stack层叠布局组织所有UI元素:
Stack() {
Column() { ... } // 底层:主内容区
this.buildFloatButton() // 中层:悬浮添加按钮
this.buildDialogBackdrop() // 顶层:弹框蒙层
}
.width('100%').height('100%');
三层架构设计:
- 底层Column:承载顶部栏、Tab栏和内容区,是页面主体
- 中层悬浮按钮:通过
position定位固定在右下角,始终可见 - 顶层弹框:通过条件渲染(
if)控制显示隐藏,配合半透明黑色蒙层
四、顶部栏与Tab导航
4.1 顶部栏
@Builder
buildHeader() {
Row() {
Column() {
Text('🥬 食材保险').fontSize(22).fontWeight(FontWeight.Bold)
Text(`${date} · 已保障 ${this.totalInsuredValue} 件食材`)
.fontSize(12).fontColor('#888888')
}
Blank()
Button() {
Image($r('sys.media.ohos_ic_public_menu'))
}.onClick(() => { this.isFilterDialogOpen = true; })
}
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
.backgroundColor('#FFFFFF')
}
关键细节:顶部栏左侧展示应用名称和动态统计数据(保障食材件数),右侧放置筛选入口按钮。Blank()组件自动撑满中间剩余空间,实现左右分布的对齐效果。

4.2 Tab标签栏
@Builder
buildTabBar() {
Row() {
ForEach(['食材库', '投保记录', '我的'], (tab: string, index: number) => {
Column() {
Text(tab)
.fontColor(this.selectedTab === index ? '#2196F3' : '#888888')
.fontWeight(this.selectedTab === index ? FontWeight.Bold : FontWeight.Medium)
Divider()
.width(28).height(3).backgroundColor('#2196F3').borderRadius(2)
.visibility(this.selectedTab === index ? Visibility.Visible : Visibility.Collapsed)
}
.layoutWeight(1)
.onClick(() => { this.selectedTab = index; })
});
}
}
交互设计:通过ForEach遍历Tab数组,动态渲染标签。当前选中标签通过selectedTab状态变量控制,选中时文字变蓝加粗并显示下划线指示器。未选中时隐藏指示器,维持干净视觉效果。layoutWeight(1)使三个Tab均分父容器宽度。

五、食材库Tab — 列表核心
5.1 搜索与过滤
Search({ placeholder: '搜索食材名称或分类' })
.onChange((value: string) => { this.searchKeyword = value; })
get filteredFoodList(): FoodItem[] {
let list = this.foodList;
if (this.searchKeyword) {
list = list.filter(f =>
f.name.includes(this.searchKeyword) ||
f.category.includes(this.searchKeyword)
);
}
if (this.filterCategory !== '全部') {
list = list.filter(f => f.category === this.filterCategory);
}
return list;
}
使用getter作为计算属性实现链式过滤:先按关键词匹配名称和分类,再按分类二次筛选。搜索栏onChange事件实时触发状态更新,配合@State响应式驱动列表重渲染。
5.2 状态概览卡片
Row() {
StatCard({ label: '新鲜', count: this.freshCount, color: '#4CAF50', icon: '✓' })
StatCard({ label: '临期', count: this.warningCount, color: '#FF9800', icon: '!' })
StatCard({ label: '过期', count: this.expiredCount, color: '#F44336', icon: '✗' })
StatCard({ label: '已投保', count: this.totalInsuredValue, color: '#2196F3', icon: '🛡' })
}
.backgroundColor('#FFFFFF').borderRadius(12).shadow({ radius: 6, ... })
四个统计数据用Row排列,layoutWeight均分宽度。颜色编码遵循绿/橙/红三色语义:绿色表示正常、橙色表示警示、红色表示危险或过期。这种颜色语义贯穿整个应用。
5.3 食材卡片FoodCard
@Component
struct FoodCard {
@ObjectLink food: FoodItem;
build() {
Row() {
// 分类图标区
Column() {
Text(this.getCategoryEmoji(this.food.category))
.fontSize(28)
.backgroundColor(this.getCategoryBgColor(this.food.category))
.borderRadius(12)
}
// 信息区
Column() {
Row() {
Text(this.food.name).fontWeight(FontWeight.Medium)
if (this.food.isInsured) Text('🛡').fontSize(12).margin({ left: 4 })
}
Text(`${this.food.category} · 剩余${this.food.getRemainingDays()}天`)
.fontColor('#999999').margin({ top: 3 })
// 进度条
Progress({
value: Math.max(0, this.food.getRemainingDays()),
total: this.food.freshDays,
type: ProgressType.Linear
}).color(this.getStatusColor(this.food.status))
}
// 操作区
Column() {
if (this.food.isInsured &&
(this.food.status === 'warning' || this.food.status === 'expired')) {
Button('理赔').backgroundColor('#FF9800').borderRadius(14)
.onClick(() => this.onClaimClick())
}
}
}
.backgroundColor('#FFFFFF').borderRadius(14).shadow({ ... })
}
}
设计要点:
@ObjectLink装饰器配合@Observed类实现细粒度数据绑定——当单个食材的status变化时,只重渲染该卡片而非整个列表- 线性进度条
Progress直观展示保鲜进度,颜色随状态动态切换 - 只有已投保且处于临期或过期状态的食材才显示"理赔"按钮,降低用户决策负担
六、弹框体系 — 三种弹框详解
6.1 弹框蒙层架构
@Builder
buildDialogBackdrop() {
Column() {
if (this.isAddDialogOpen) { this.buildAddFoodDialog() }
if (this.isClaimDialogOpen) { this.buildClaimDialog() }
if (this.isDetailDialogOpen) { this.buildDetailDialog() }
}
.backgroundColor('rgba(0,0,0,0.5)') // 半透明黑色蒙层
.justifyContent(FlexAlign.Center) // 弹框居中
.onClick((event: ClickEvent) => {
// 点击蒙层空白处关闭
if (event.target === undefined) { this.isAddDialogOpen = false; ... }
})
}
实现技巧:rgba(0,0,0,0.5)半透明蒙层提供视觉聚焦效果,用户点击蒙层空白区域时自动关闭弹框。通过判断event.target === undefined区分点击蒙层与点击弹框内容的行为——蒙层的背景是整层Column,点击其空白处时target为空。
6.2 添加食材弹框
@Builder
buildAddFoodDialog() {
Column() {
// 标题栏
Row() {
Text('添加食材').fontWeight(FontWeight.Bold)
Text('✕').onClick(() => { this.isAddDialogOpen = false; })
}
Scroll() {
Column() {
// 食材名称输入
TextInput({ placeholder: '例如:有机西兰花' })
.onChange((v) => { this.newFoodName = v; })
// 分类选择Chip
ForEach(Object.keys(CATEGORY_CONFIGS), (cat: string) => {
Chip(cat)
.backgroundColor(this.newFoodCategory === cat ? '#E3F2FD' : '#F0F0F0')
.fontColor(this.newFoodCategory === cat ? '#1976D2' : '#666666')
.onClick(() => { this.newFoodCategory = cat; })
});
// 日期选择器
DatePicker({
start: new Date(),
end: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
selected: this.newFoodExpiry
}).onDateChange((value: Date) => { this.newFoodExpiry = value; })
// 投保开关
Toggle({ type: ToggleType.Switch, isOn: this.newFoodIsInsured })
.onChange((v: boolean) => { this.newFoodIsInsured = v; })
}
}
.constraintSize({ maxHeight: 520 }) // 限制高度,超出可滚动
// 底部双按钮
Row() {
Button('取消').onClick(() => { this.isAddDialogOpen = false; })
Button('确认添加').onClick(() => this.addFoodItem())
}
}
.width('88%').backgroundColor('#FFFFFF').borderRadius(20).clip(true)
}
核心组件解析:
Chip:分类选择使用Chip组件代替RadioButton,视觉更轻量,选中态通过背景色区分DatePicker:原生日期选择器,配置start和end限定可选范围,onDateChange回调更新状态Toggle:Switch类型开关,控制食材是否纳入保险保障- **
Scroll**包裹内容区+constraintSize({ maxHeight: 520 }):防止弹框超出屏幕高度,内容超出时自动滚动
6.3 理赔申请弹框
@Builder
buildClaimDialog() {
Column() {
// 展示食材信息
Row() {
Text(getCategoryEmoji(this.currentFood.category))
Column() {
Text(this.currentFood.name)
Text(`${this.currentFood.category} · ${status}`)
}
}
.backgroundColor('#F8F8F8').borderRadius(10)
// 理赔原因输入
TextArea({ placeholder: '请描述食材问题(变质/异味/过期/包装破损)' })
.onChange((v) => { this.claimReason = v; })
// 预估金额输入
TextInput({ placeholder: '系统自动估算,可手动调整' })
.keyboardType(KeyboardType.Number)
.onChange((v) => { this.claimAmount = v; })
// 提示文字
Text('• 提交后1-3个工作日内审核\n• 理赔款将原路返回\n• 最终赔付金额以审核结果为准')
.fontColor('#BBBBBB')
}
.width('88%').backgroundColor('#FFFFFF').borderRadius(20)
}
交互设计:理赔弹框在提交前展示保险说明文案,明确告知用户审核时效和到账方式,降低不确定性带来的焦虑。TextArea用于多行理赔原因输入,TextInput用于金额输入并限制数字键盘。
6.4 食材详情弹框
@Builder
buildDetailDialog() {
Column() {
DetailRow({ label: '食材名称', value: this.food.name });
DetailRow({ label: '保质期截止', value: this.food.expiryDate });
DetailRow({ label: '剩余保质', value: `${this.food.getRemainingDays()}天`,
valueColor: colorByStatus(this.food.status) });
// 保险状态
Row() {
Text('保险状态')
Blank()
if (this.food.isInsured) {
Text('🛡 已投保').fontColor('#4CAF50')
} else {
Text('未投保').fontColor('#999999')
}
}
Button('删除食材').backgroundColor('#FFEBEE').fontColor('#F44336')
.onClick(() => this.deleteFoodItem(this.food.id))
}
}
使用DetailRow通用组件渲染键值对列表,金额数字颜色根据食材状态动态变化。底部"删除食材"按钮使用红色警告色调,与确认按钮形成明显的危险操作区分。
七、数据流转与业务逻辑
7.1 添加食材流程
addFoodItem() {
if (!this.newFoodName.trim()) {
promptAction.showToast({ message: '请输入食材名称', duration: 2000 });
return; // 前端校验,名称不能为空
}
const purchaseDate = new Date().toISOString().split('T')[0];
const expiryDate = this.newFoodExpiry.toISOString().split('T')[0];
const newItem = new FoodItem(
`F${Date.now()}`, // 简单ID生成
this.newFoodName.trim(),
this.newFoodCategory,
purchaseDate,
expiryDate,
parseInt(this.newFoodFreshDays) || 7, // 默认7天
this.newFoodIsInsured,
$r('app.media.icon'),
this.newFoodDescription.trim()
);
// 头部插入新食材
this.foodList = [newItem, ...this.foodList];
this.isAddDialogOpen = false;
promptAction.showToast({ message: '食材添加成功,已纳入保险保障', duration: 2000 });
}
关键设计:使用[newItem, ...this.foodList]展开运算符实现头部插入,新添加的食材立即出现在列表最顶部,无需手动刷新。通过promptAction.showToast提供操作反馈,用户明确感知操作已生效。
7.2 理赔提交流程
submitClaim() {
if (!this.claimReason.trim()) {
promptAction.showToast({ message: '请填写理赔原因', duration: 2000 });
return;
}
// 自动估算金额(演示用随机值)
const amount = parseFloat(this.claimAmount) ||
Math.floor(Math.random() * 200 + 50);
const record = new InsuranceRecord(
`C${Date.now()}`,
this.currentFood?.id || '',
this.currentFood?.name || '',
new Date().toISOString().split('T')[0],
amount,
'pending', // 新建记录默认"审核中"
this.claimReason.trim()
);
// 头部插入新记录
this.insuranceRecords = [record, ...this.insuranceRecords];
this.isClaimDialogOpen = false;
promptAction.showToast({
message: '理赔申请已提交,预计1-3个工作日审核',
duration: 3000
});
}
7.3 状态变量的响应式驱动
所有UI交互通过@State状态变量驱动:
@State foodList: FoodItem[] = []; // 食材列表
@State selectedTab: number = 0; // 当前Tab索引
@State searchKeyword: string = ''; // 搜索关键词
@State filterCategory: string = '全部'; // 分类过滤
@State isAddDialogOpen: boolean = false; // 弹框开关
@State currentFood: FoodItem | null = null; // 当前选中食材
当任何一个@State变量变化时,ArkUI框架自动追踪依赖该变量的UI组件并触发重渲染。列表渲染时ForEach监听到filteredFoodList变化(其getter依赖多个@State),自动更新列表内容。
八、子组件设计
8.1 StatCard — 统计数据卡片
@Component
struct StatCard {
@Prop label: string;
@Prop count: number;
@Prop color: string;
@Prop icon: string;
build() {
Column() {
Text(`${this.icon} ${this.count}`)
.fontSize(18).fontWeight(FontWeight.Bold).fontColor(this.color)
Text(this.label).fontSize(11).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).padding(8)
}
}
使用@Prop接收父组件传入的简单值(非对象类型),组件内部只负责UI渲染。layoutWeight(1)使四个卡片均分Row容器宽度。
8.2 InsuranceRecordCard — 投保记录卡片
@Component
struct InsuranceRecordCard {
@ObjectLink record: InsuranceRecord;
build() {
Row() {
Column() {
Text(this.getStatusIcon(this.record.status))
.fontSize(22)
.backgroundColor(this.getStatusBgColor(this.record.status))
.borderRadius(22) // 圆形背景
}
Column() {
Text(this.record.foodName).fontWeight(FontWeight.Medium)
Text(this.record.reason).fontColor('#888888')
Text(this.record.claimDate).fontColor('#BBBBBB')
}
Column() {
Text(this.getStatusText(this.record.status))
.fontColor(this.getStatusTextColor(this.record.status))
.backgroundColor(this.getStatusBgColor(this.record.status))
.borderRadius(10).padding({ left: 8, right: 8, top: 3, bottom: 3 })
if (this.record.claimAmount > 0) {
Text(`¥${this.record.claimAmount.toFixed(2)}`)
.fontWeight(FontWeight.Bold).fontColor('#333333').margin({ top: 6 })
}
}
}
.backgroundColor('#FFFFFF').borderRadius(12).shadow({ ... })
}
}
设计要点:@ObjectLink用于接收被@Observed修饰的对象引用,实现双向绑定——当InsuranceRecord中的status属性变化时,该卡片自动重渲染。圆形状态图标配合颜色语义(绿/黄/红)让用户一眼识别记录状态。有赔付金额时显示金额,无金额(维修/换货类型)则不显示。
九、UI细节与UX设计
9.1 颜色语义体系
| 颜色 | 色值 | 语义 |
|---|---|---|
| 绿色 | #4CAF50 |
正常、新鲜、已通过 |
| 橙色 | #FF9800 |
警示、临期待处理 |
| 红色 | #F44336 |
危险、过期、已驳回 |
| 蓝色 | #2196F3 |
主色调、保险、交互 |
9.2 阴影与层次
.shadow({ radius: 8, color: '#402196F3', offsetX: 0, offsetY: 4 })
.backgroundColor('#FFFFFF').borderRadius(20)
卡片统一使用shadow阴影提升层次感,弹框使用更大的radius和带透明度的color(#40前缀表示40%透明度),营造"浮于页面之上"的立体感。
9.3 字体层级
.fontSize(22).fontWeight(FontWeight.Bold) // 标题
.fontSize(15).fontWeight(FontWeight.Medium) // 正文标题
.fontSize(14).fontColor('#666666') // 辅助信息
.fontSize(12).fontColor('#999999') // 次要说明
.fontSize(11).fontColor('#BBBBBB') // 提示文字
字号从22到11形成五级层级,配合颜色深浅区分信息重要性,减少视觉噪音。
9.4 空状态设计
if (this.filteredFoodList.length === 0) {
Column() {
Text('🍽').fontSize(48).margin({ top: 60 })
Text('暂无食材记录').fontColor('#999999').margin({ top: 12 })
Text('点击下方 + 按钮添加食材').fontColor('#BBBBBB').margin({ top: 6 })
}
}
列表为空时展示空状态占位图和引导文案,配合悬浮添加按钮形成"操作闭环"——用户看到空状态,立刻知道下一步该做什么。
十、效率对比表
| 维度 | 未使用App管理 | 使用食材保险App | 差异来源 |
|---|---|---|---|
| 食材浪费率 | 约20-30%家庭食材因过期丢弃 | 临期预警提醒,浪费率降至5-10% | 主动管理替代被动遗忘 |
| 变质发现时机 | 做饭时才发现食材问题 | 新鲜度实时追踪,变质前预警 | 数据化追踪替代凭记忆判断 |
| 保险理赔时效 | 无对应保障机制 | 1-3个工作日审核+原路退款 | 数字化流程替代无保障状态 |
| 食材管理方式 | 冰箱门贴便签/记忆 | 手机统一录入+分类+日期管理 | 数字化清单替代物理记录 |
| 过期食材处置 | 直接丢弃无补偿 | 已投保食材可申请理赔获得补偿 | 风险保障机制 |
约束说明:App中的理赔金额为演示数据,实际赔付规则以保险条款为准。过期预警提醒依赖用户准确录入保质期,数据准确性由用户负责。部分手机型号对后台弹框权限管控严格,生产环境需做权限适配处理。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 食材保险App - Index.ets
// HarmonyOS NEXT API 24 · ArkTS V2 状态管理 · 严格模式
// @ObservedV2 / @Trace / @ComponentV2 / @Local / @Param / @Event
import promptAction from '@ohos.promptAction';
// ====================== 数据模型 ======================
type FoodStatus = 'fresh' | 'warning' | 'expired';
type ClaimStatus = 'pending' | 'approved' | 'rejected';
@ObservedV2
class FoodItem {
@Trace id: string = '';
@Trace name: string = '';
@Trace category: string = '';
@Trace purchaseDate: string = '';
@Trace expiryDate: string = '';
@Trace freshDays: number = 0;
@Trace isInsured: boolean = false;
@Trace coverUrl: ResourceStr = '';
@Trace description: string = '';
@Trace status: FoodStatus = 'fresh';
constructor(
id: string, name: string, category: string, purchaseDate: string,
expiryDate: string, freshDays: number, isInsured: boolean,
coverUrl: ResourceStr, description: string
) {
this.id = id; this.name = name; this.category = category;
this.purchaseDate = purchaseDate; this.expiryDate = expiryDate;
this.freshDays = freshDays; this.isInsured = isInsured;
this.coverUrl = coverUrl; this.description = description;
this.status = this.calcStatus();
}
calcStatus(): FoodStatus {
const diff: number = this.remainDays();
if (diff < 0) return 'expired';
if (diff <= 2) return 'warning';
return 'fresh';
}
remainDays(): number {
const today: Date = new Date();
const expiry: Date = new Date(this.expiryDate);
return Math.floor((expiry.getTime() - today.getTime()) / 86400000);
}
}
@ObservedV2
class InsuranceRecord {
@Trace id: string = '';
@Trace foodId: string = '';
@Trace foodName: string = '';
@Trace claimDate: string = '';
@Trace claimAmount: number = 0;
@Trace status: ClaimStatus = 'pending';
@Trace reason: string = '';
constructor(
id: string, foodId: string, foodName: string, claimDate: string,
claimAmount: number, status: ClaimStatus, reason: string
) {
this.id = id; this.foodId = foodId; this.foodName = foodName;
this.claimDate = claimDate; this.claimAmount = claimAmount;
this.status = status; this.reason = reason;
}
}
interface CategoryConfig {
label: string;
iconNormal: ResourceStr;
iconWarning: ResourceStr;
iconExpired: ResourceStr;
color: string;
}
const CATEGORY_CONFIGS: Record<string, CategoryConfig> = {
'蔬菜': { label: '蔬菜', iconNormal: $r('app.media.icon_vegetable'), iconWarning: $r('app.media.icon_vegetable_warn'), iconExpired: $r('app.media.icon_vegetable_expired'), color: '#4CAF50' },
'肉类': { label: '肉类', iconNormal: $r('app.media.icon_meat'), iconWarning: $r('app.media.icon_meat_warn'), iconExpired: $r('app.media.icon_meat_expired'), color: '#F44336' },
'水果': { label: '水果', iconNormal: $r('app.media.icon_fruit'), iconWarning: $r('app.media.icon_fruit_warn'), iconExpired: $r('app.media.icon_fruit_expired'), color: '#FF9800' },
'乳制品': { label: '乳制品', iconNormal: $r('app.media.icon_dairy'), iconWarning: $r('app.media.icon_dairy_warn'), iconExpired: $r('app.media.icon_dairy_expired'), color: '#2196F3' },
'海鲜': { label: '海鲜', iconNormal: $r('app.media.icon_seafood'), iconWarning: $r('app.media.icon_seafood_warn'), iconExpired: $r('app.media.icon_seafood_expired'), color: '#00BCD4' }
};
function categoryImage(category: string, status: FoodStatus): ResourceStr {
const cfg: CategoryConfig | undefined = CATEGORY_CONFIGS[category];
if (!cfg) return $r('app.media.icon_default');
if (status === 'expired') return cfg.iconExpired;
if (status === 'warning') return cfg.iconWarning;
return cfg.iconNormal;
}
function categoryBgColor(category: string): string {
return (CATEGORY_CONFIGS[category]?.color ?? '#F0F0F0') + '20';
}
// ====================== 主页面 ======================
@Entry
@ComponentV2
struct Index {
// ---- 状态 ----
@Local foodList: FoodItem[] = [];
@Local insuranceRecords: InsuranceRecord[] = [];
@Local selectedTab: number = 0;
@Local searchKeyword: string = '';
@Local filterCategory: string = '全部';
@Local addOpen: boolean = false;
@Local claimOpen: boolean = false;
@Local filterOpen: boolean = false;
@Local detailOpen: boolean = false;
@Local currentFood: FoodItem | null = null;
@Local claimReason: string = '';
@Local claimAmount: string = '';
@Local newName: string = '';
@Local newCategory: string = '蔬菜';
@Local newExpiry: Date = new Date();
@Local newFreshDays: string = '';
@Local newInsured: boolean = true;
@Local newDesc: string = '';
// ---- 计算属性(getter,插在 lifecycle 之前)----
get filteredFoodList(): FoodItem[] {
let list: FoodItem[] = this.foodList;
const kw: string = this.searchKeyword.trim();
if (kw) {
list = list.filter((f: FoodItem): boolean =>
f.name.includes(kw) || f.category.includes(kw));
}
if (this.filterCategory !== '全部') {
list = list.filter((f: FoodItem): boolean => f.category === this.filterCategory);
}
return list;
}
get freshCount(): number {
return this.foodList.filter((f: FoodItem): boolean => f.status === 'fresh').length;
}
get warningCount(): number {
return this.foodList.filter((f: FoodItem): boolean => f.status === 'warning').length;
}
get expiredCount(): number {
return this.foodList.filter((f: FoodItem): boolean => f.status === 'expired').length;
}
get insuredCount(): number {
return this.foodList.filter((f: FoodItem): boolean => f.isInsured).length;
}
get pendingCount(): number {
return this.insuranceRecords.filter((r: InsuranceRecord): boolean => r.status === 'pending').length;
}
// ---- 生命周期 ----
aboutToAppear(): void {
this.loadMock();
}
// ---- 业务方法 ----
loadMock(): void {
this.foodList = [
new FoodItem('F001', '有机西兰花', '蔬菜', '2026-07-10', '2026-07-17', 7, true, $r('app.media.icon_vegetable'), '产自云南高原,无农药残留'),
new FoodItem('F002', '澳洲和牛M7', '肉类', '2026-07-13', '2026-07-20', 7, true, $r('app.media.icon_meat'), '谷饲200天,雪花纹路清晰'),
new FoodItem('F003', '智利车厘子', '水果', '2026-07-12', '2026-07-16', 4, false, $r('app.media.icon_fruit'), 'JJ级特大果,脆甜多汁'),
new FoodItem('F004', '荷兰进口牛奶', '乳制品', '2026-07-08', '2026-07-18', 10, true, $r('app.media.icon_dairy'), '全程冷链运输,蛋白质3.4g/100ml'),
new FoodItem('F005', '鲜活大明虾', '海鲜', '2026-07-14', '2026-07-16', 2, true, $r('app.media.icon_seafood'), '东海海域捕捞,急速冷冻锁鲜'),
new FoodItem('F006', '散装菠菜', '蔬菜', '2026-07-11', '2026-07-15', 4, false, $r('app.media.icon_vegetable'), '新鲜直采,叶片翠绿'),
new FoodItem('F007', '五花肉', '肉类', '2026-07-12', '2026-07-19', 7, true, $r('app.media.icon_meat'), '土猪五花,肥瘦相间'),
new FoodItem('F008', '国产蓝莓', '水果', '2026-07-13', '2026-07-18', 5, true, $r('app.media.icon_fruit'), '云南产区,花青素含量高'),
new FoodItem('F009', '希腊酸奶', '乳制品', '2026-07-10', '2026-07-17', 7, true, $r('app.media.icon_dairy'), '高蛋白零添加,质地浓稠'),
new FoodItem('F010', '三文鱼刺身', '海鲜', '2026-07-14', '2026-07-16', 2, false, $r('app.media.icon_seafood'), '挪威进口,空运直达')
];
this.insuranceRecords = [
new InsuranceRecord('C001', 'F003', '智利车厘子', '2026-07-15', 128.00, 'approved', '变质发霉'),
new InsuranceRecord('C002', 'F006', '散装菠菜', '2026-07-14', 0, 'rejected', '超过投保期'),
new InsuranceRecord('C003', 'F001', '有机西兰花', '2026-07-16', 0, 'pending', '包装破损')
];
}
openAdd(): void {
this.newName = '';
this.newCategory = '蔬菜';
this.newExpiry = new Date(Date.now() + 604800000);
this.newFreshDays = '7';
this.newInsured = true;
this.newDesc = '';
this.addOpen = true;
}
openClaim(food: FoodItem): void {
this.currentFood = food;
this.claimReason = '';
this.claimAmount = '';
this.claimOpen = true;
}
openDetail(food: FoodItem): void {
this.currentFood = food;
this.detailOpen = true;
}
closeAll(): void {
this.addOpen = false;
this.claimOpen = false;
this.detailOpen = false;
}
addFood(): void {
if (!this.newName.trim()) {
promptAction.showToast({ message: '请输入食材名称', duration: 2000 });
return;
}
const item: FoodItem = new FoodItem(
`F${Date.now()}`, this.newName.trim(), this.newCategory,
new Date().toISOString().slice(0, 10),
this.newExpiry.toISOString().slice(0, 10),
parseInt(this.newFreshDays) || 7,
this.newInsured, $r('app.media.icon_default'), this.newDesc.trim()
);
this.foodList = [item, ...this.foodList];
this.addOpen = false;
promptAction.showToast({ message: '食材添加成功,已纳入保险保障', duration: 2000 });
}
submitClaim(): void {
if (!this.claimReason.trim()) {
promptAction.showToast({ message: '请填写理赔原因', duration: 2000 });
return;
}
const amount: number = parseFloat(this.claimAmount) || Math.floor(Math.random() * 200 + 50);
const food: FoodItem | null = this.currentFood;
if (!food) return;
const record: InsuranceRecord = new InsuranceRecord(
`C${Date.now()}`, food.id, food.name,
new Date().toISOString().slice(0, 10), amount, 'pending', this.claimReason.trim()
);
this.insuranceRecords = [record, ...this.insuranceRecords];
this.claimOpen = false;
promptAction.showToast({ message: '理赔申请已提交,预计1-3个工作日审核', duration: 3000 });
}
deleteFood(id: string): void {
this.foodList = this.foodList.filter((f: FoodItem): boolean => f.id !== id);
this.detailOpen = false;
promptAction.showToast({ message: '食材已从清单中移除', duration: 2000 });
}
// ====================== UI 入口 ======================
build() {
Stack() {
Column() {
this.header()
this.tabBar()
if (this.selectedTab === 0) { this.foodTab() }
else if (this.selectedTab === 1) { this.insuranceTab() }
else { this.myTab() }
}
.width('100%').height('100%').backgroundColor('#F5F5F5')
this.floatButton()
if (this.addOpen || this.claimOpen || this.detailOpen) {
this.backdrop()
}
}
.width('100%').height('100%')
}
// ====================== UI 组件 ======================
@Builder
header() {
Row() {
Column() {
Row({ space: 6 }) {
Image($r('app.media.icon_app_logo')).width(26).height(26).borderRadius(6)
Text('食材保险').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
}
Text(`${new Date().toLocaleDateString('zh-CN')} · 已保障 ${this.insuredCount} 件食材`)
.fontSize(12).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Blank()
Button() {
Image($r('app.media.icon_menu')).width(22).height(22).fillColor('#333333')
}
.width(36).height(36).backgroundColor('#FFFFFF').borderRadius(18)
.shadow({ radius: 4, color: '#20000000', offsetX: 0, offsetY: 2 })
.onClick(() => { this.filterOpen = true; })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 }).backgroundColor('#FFFFFF')
}
@Builder
tabBar() {
Row() {
ForEach(['食材库', '投保记录', '我的'], (tab: string, index: number): void => {
Column() {
Text(tab)
.fontSize(15)
.fontWeight(this.selectedTab === index ? FontWeight.Bold : FontWeight.Medium)
.fontColor(this.selectedTab === index ? '#2196F3' : '#888888')
if (this.selectedTab === index) {
Divider().width(28).height(3).backgroundColor('#2196F3').borderRadius(2)
} else {
Blank().height(3)
}
}
.layoutWeight(1)
.onClick(() => { this.selectedTab = index; })
}, (tab: string): string => tab);
}
.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 }).backgroundColor('#FFFFFF')
}
@Builder
foodTab() {
Column() {
Search({ placeholder: '搜索食材名称或分类' })
.width('90%').height(40).backgroundColor('#F0F0F0').borderRadius(20)
.placeholderFont({ size: 14 }).textFont({ size: 14 })
.onChange((value: string): void => { this.searchKeyword = value; })
.margin({ top: 12, bottom: 12 })
Row() {
StatCard({ label: '新鲜', count: this.freshCount, color: '#4CAF50', iconPath: $r('app.media.icon_ok') })
StatCard({ label: '临期', count: this.warningCount, color: '#FF9800', iconPath: $r('app.media.icon_warning') })
StatCard({ label: '过期', count: this.expiredCount, color: '#F44336', iconPath: $r('app.media.icon_close') })
StatCard({ label: '已投保', count: this.insuredCount, color: '#2196F3', iconPath: $r('app.media.icon_shield') })
}
.width('90%').padding(12).backgroundColor('#FFFFFF').borderRadius(12)
.shadow({ radius: 6, color: '#15000000', offsetX: 0, offsetY: 2 }).margin({ bottom: 12 })
if (this.filteredFoodList.length === 0) {
Column() {
Image($r('app.media.icon_empty_food')).width(80).height(80).margin({ top: 60 })
Text('暂无食材记录').fontSize(16).fontColor('#999999').margin({ top: 16 })
Text('点击下方 + 按钮添加食材').fontSize(13).fontColor('#BBBBBB').margin({ top: 6 })
}
.width('100%').layoutWeight(1)
} else {
List() {
ForEach(this.filteredFoodList, (food: FoodItem): void => {
ListItem() {
FoodCard({
food: food,
onClaimClick: (): void => this.openClaim(food),
onDetailClick: (): void => this.openDetail(food)
})
}
.padding({ left: 16, right: 16, bottom: 10 })
}, (food: FoodItem): string => food.id);
}
.width('100%').layoutWeight(1).scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
}
}
.width('100%').layoutWeight(1)
}
@Builder
insuranceTab() {
Column() {
if (this.insuranceRecords.length === 0) {
Column() {
Image($r('app.media.icon_empty_record')).width(80).height(80).margin({ top: 80 })
Text('暂无投保记录').fontSize(16).fontColor('#999999').margin({ top: 16 })
Text('食材过期后可申请理赔').fontSize(13).fontColor('#BBBBBB').margin({ top: 6 })
}
.width('100%').layoutWeight(1)
} else {
List() {
ForEach(this.insuranceRecords, (record: InsuranceRecord): void => {
ListItem() {
RecordCard({ record: record })
}
.padding({ left: 16, right: 16, bottom: 10 })
}, (record: InsuranceRecord): string => record.id);
}
.width('100%').layoutWeight(1).scrollBar(BarState.Off)
}
}
.width('100%').layoutWeight(1).padding({ top: 12 })
}
@Builder
myTab() {
Column() {
Column() {
Row() {
Image($r('app.media.icon_avatar')).width(44).height(44).borderRadius(22).backgroundColor('#E3F2FD')
Column() {
Text('食材守护者').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Text('青铜会员').fontSize(12).fontColor('#2196F3').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).margin({ left: 12 })
}.width('100%').padding(16)
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 8, color: '#15000000', offsetX: 0, offsetY: 3 }).margin({ top: 16 })
Column() {
Text('保障概览').fontSize(15).fontColor('#666666').fontWeight(FontWeight.Medium)
.width('100%').padding({ bottom: 12 })
Row() {
StatItem({ label: '在保食材', value: `${this.insuredCount}件`, color: '#2196F3' })
StatItem({ label: '理赔记录', value: `${this.insuranceRecords.length}条`, color: '#4CAF50' })
StatItem({ label: '待处理', value: `${this.pendingCount}件`, color: '#FF9800' })
}
}
.width('90%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
.shadow({ radius: 6, color: '#15000000', offsetX: 0, offsetY: 2 }).margin({ top: 16 })
Column() {
MenuItemRow({ iconPath: $r('app.media.icon_coupon'), label: '我的优惠券', subLabel: '3张可用' })
MenuItemRow({ iconPath: $r('app.media.icon_bill'), label: '保险账单' })
MenuItemRow({ iconPath: $r('app.media.icon_notification'), label: '理赔通知', badge: this.pendingCount > 0 ? `${this.pendingCount}` : '' })
MenuItemRow({ iconPath: $r('app.media.icon_settings'), label: '设置' })
MenuItemRow({ iconPath: $r('app.media.icon_help'), label: '帮助与反馈' })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(12)
.shadow({ radius: 6, color: '#15000000', offsetX: 0, offsetY: 2 }).margin({ top: 16, bottom: 16 })
}
.width('100%').layoutWeight(1)
}
@Builder
floatButton() {
Button() {
Text('+').fontSize(28).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width(54).height(54).backgroundColor('#2196F3').borderRadius(27)
.shadow({ radius: 8, color: '#402196F3', offsetX: 0, offsetY: 4 })
.position({ x: '80%', y: '85%' })
.onClick(() => this.openAdd());
}
@Builder
backdrop() {
Column() {
if (this.addOpen) { this.addDialog() }
if (this.claimOpen) { this.claimDialog() }
if (this.detailOpen && this.currentFood) { this.detailDialog() }
}
.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.onClick((event: ClickEvent): void => {
if (event.target === undefined) { this.closeAll(); }
})
}
@Builder
closeBtn() {
Text('×').fontSize(22).fontColor('#999999').onClick(() => this.closeAll());
}
@Builder
addDialog() {
Column() {
Row() {
Text('添加食材').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Blank()
this.closeBtn()
}
.width('100%').padding({ left: 20, right: 12, top: 16, bottom: 12 })
.border({ width: { bottom: 1 }, color: '#EEEEEE' })
Scroll() {
Column() {
Text('食材名称').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 12 })
TextInput({ placeholder: '例如:有机西兰花' })
.width('100%').height(44).backgroundColor('#F5F5F5').borderRadius(10).padding({ left: 12 })
.onChange((v: string): void => { this.newName = v; })
Text('食材分类').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 16 })
// 用 Text 模拟 Chip,避免 Chip 组件不存在
Row() {
ForEach(Object.keys(CATEGORY_CONFIGS), (cat: string): void => {
Text(cat)
.fontSize(12).fontColor(this.newCategory === cat ? '#1976D2' : '#666666')
.backgroundColor(this.newCategory === cat ? '#E3F2FD' : '#F0F0F0')
.borderRadius(16).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.newCategory = cat; })
Blank().width(8)
}, (cat: string): string => cat);
}
.width('100%')
Text('保质期截止').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 16 })
DatePicker({
start: new Date(),
end: new Date(Date.now() + 31536000000),
selected: this.newExpiry
})
.width('100%')
.onDateChange((value: Date): void => { this.newExpiry = value; })
Text('保鲜天数').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 16 })
TextInput({ placeholder: '7' })
.width('100%').height(44).backgroundColor('#F5F5F5').borderRadius(10)
.padding({ left: 12 })
.onChange((v: string): void => { this.newFreshDays = v; })
Row() {
Text('纳入食材保险').fontSize(15).fontColor('#1A1A1A')
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.newInsured })
.selectedColor('#2196F3')
.onChange((v: boolean): void => { this.newInsured = v; })
}.width('100%').padding({ top: 16, bottom: 16 })
Divider().color('#EEEEEE')
Text('备注信息').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 12 })
TextArea({ placeholder: '可选:产地、品牌等信息' })
.width('100%').height(72).backgroundColor('#F5F5F5').borderRadius(10)
.padding(12).onChange((v: string): void => { this.newDesc = v; })
}
.width('100%').padding({ left: 20, right: 20, bottom: 20 })
}
.width('100%').constraintSize({ maxHeight: 520 })
Row() {
Button('取消').width('45%').height(44).backgroundColor('#F0F0F0').fontColor('#666666').borderRadius(22)
.onClick(() => { this.addOpen = false; })
Button('确认添加').width('45%').height(44).backgroundColor('#2196F3').fontColor('#FFFFFF').borderRadius(22)
.onClick(() => this.addFood())
}
.width('100%').padding({ left: 20, right: 20, top: 12, bottom: 20 })
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%').backgroundColor('#FFFFFF').borderRadius(20).clip(true)
}
@Builder
claimDialog() {
Column() {
Row() {
Text('申请理赔').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Blank()
this.closeBtn()
}
.width('100%').padding({ left: 20, right: 12, top: 16, bottom: 12 })
.border({ width: { bottom: 1 }, color: '#EEEEEE' })
Column() {
if (this.currentFood) {
Row() {
Image(categoryImage(this.currentFood.category, this.currentFood.status))
.width(44).height(44).borderRadius(10).backgroundColor(categoryBgColor(this.currentFood.category))
.objectFit(ImageFit.Cover)
Column() {
Text(this.currentFood.name).fontSize(15).fontColor('#1A1A1A').fontWeight(FontWeight.Medium)
Text(`${this.currentFood.category} · ${this.currentFood.status === 'expired' ? '已过期' : '临期'}`)
.fontSize(12).fontColor('#999999').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).margin({ left: 10 })
}
.width('100%').padding(12).backgroundColor('#F8F8F8').borderRadius(10)
Row() {
Image($r('app.media.icon_shield')).width(16).height(16)
Text('食材保险承保范围内变质、腐坏等问题食品')
.fontSize(12).fontColor('#4CAF50').margin({ left: 6 })
}.width('100%').padding({ top: 12 })
Text('理赔原因').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 16 })
TextArea({ placeholder: '请描述食材问题(变质/异味/过期/包装破损)' })
.width('100%').height(80).backgroundColor('#F5F5F5').borderRadius(10).padding(12)
.onChange((v: string): void => { this.claimReason = v; })
Text('预估赔付金额').fontSize(13).fontColor('#888888').fontWeight(FontWeight.Medium)
.width('100%').margin({ top: 16 })
TextInput({ placeholder: '系统自动估算,可手动调整' })
.width('100%').height(44).backgroundColor('#F5F5F5').borderRadius(10)
.padding({ left: 12 })
.onChange((v: string): void => { this.claimAmount = v; })
Text('• 提交后1-3个工作日内审核\n• 理赔款将原路返回至支付账户\n• 最终赔付金额以审核结果为准')
.fontSize(11).fontColor('#BBBBBB').lineHeight(18).width('100%').margin({ top: 12 })
}
}
.width('100%').padding({ left: 20, right: 20 })
Row() {
Button('取消').width('45%').height(44).backgroundColor('#F0F0F0').fontColor('#666666').borderRadius(22)
.onClick(() => { this.claimOpen = false; })
Button('提交申请').width('45%').height(44).backgroundColor('#FF9800').fontColor('#FFFFFF').borderRadius(22)
.onClick(() => this.submitClaim())
}
.width('100%').padding({ left: 20, right: 20, top: 16, bottom: 20 })
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%').backgroundColor('#FFFFFF').borderRadius(20).clip(true)
}
@Builder
detailDialog() {
Column() {
Row() {
Text('食材详情').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Blank()
this.closeBtn()
}
.width('100%').padding({ left: 20, right: 12, top: 16, bottom: 12 })
.border({ width: { bottom: 1 }, color: '#EEEEEE' })
if (this.currentFood) {
Scroll() {
Column() {
DetailRow({ label: '食材名称', value: this.currentFood.name });
DetailRow({ label: '所属分类', value: this.currentFood.category });
DetailRow({ label: '购买日期', value: this.currentFood.purchaseDate });
DetailRow({ label: '保质期截止', value: this.currentFood.expiryDate });
DetailRow({ label: '保鲜天数', value: `${this.currentFood.freshDays}天` });
DetailRow({
label: '剩余保质',
value: `${this.currentFood.remainDays()}天`,
valueColor: this.currentFood.status === 'expired' ? '#F44336' :
this.currentFood.status === 'warning' ? '#FF9800' : '#4CAF50'
});
DetailRow({ label: '备注信息', value: this.currentFood.description || '无' });
Divider().color('#EEEEEE').margin({ top: 12, bottom: 12 })
Row() {
Text('保险状态').fontSize(14).fontColor('#666666')
Blank()
if (this.currentFood.isInsured) {
Row({ space: 4 }) {
Image($r('app.media.icon_shield')).width(14).height(14)
Text('已投保').fontSize(14).fontColor('#4CAF50')
}
} else {
Text('未投保').fontSize(14).fontColor('#999999')
}
}.width('100%')
Row() {
Button('删除食材').width('100%').height(40)
.backgroundColor('#FFEBEE').fontColor('#F44336').borderRadius(20)
.onClick(() => this.deleteFood(this.currentFood!.id))
}.width('100%').padding({ top: 20 })
}
.width('100%').padding({ left: 20, right: 20, bottom: 20 })
}
.width('100%').constraintSize({ maxHeight: 400 })
}
}
.width('88%').backgroundColor('#FFFFFF').borderRadius(20).clip(true)
}
}
// ====================== 子组件 ======================
@ComponentV2
struct StatCard {
@Param label: string = '';
@Param count: number = 0;
@Param color: string = '#000000';
@Param iconPath: ResourceStr = $r('app.media.icon_ok');
build() {
Column() {
Row({ space: 4 }) {
Image(this.iconPath).width(16).height(16).fillColor(this.color)
Text(`${this.count}`).fontSize(18).fontWeight(FontWeight.Bold).fontColor(this.color)
}
Text(this.label).fontSize(11).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).padding(8)
}
}
@ComponentV2
struct StatItem {
@Param label: string = '';
@Param value: string = '';
@Param color: string = '#000000';
build() {
Column() {
Text(this.value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(this.color)
Text(this.label).fontSize(11).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1)
}
}
@ComponentV2
struct FoodCard {
@Require @Param food!: FoodItem;
@Event onClaimClick: () => void = (): void => {};
@Event onDetailClick: () => void = (): void => {};
statusColor(status: FoodStatus): string {
return status === 'fresh' ? '#4CAF50' : status === 'warning' ? '#FF9800' : '#F44336';
}
build() {
Row() {
Image(categoryImage(this.food.category, this.food.status))
.width(52).height(52).borderRadius(12).backgroundColor(categoryBgColor(this.food.category))
.objectFit(ImageFit.Cover)
Column() {
Row() {
Text(this.food.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
if (this.food.isInsured) {
Image($r('app.media.icon_shield_small')).width(14).height(14).margin({ left: 4 })
}
if (this.food.status === 'warning') {
Image($r('app.media.icon_warning')).width(14).height(14).margin({ left: 4 }).fillColor('#FF9800')
}
}
Text(`${this.food.category} · 剩余${this.food.remainDays()}天`)
.fontSize(12).fontColor('#999999').margin({ top: 3 })
Progress({
value: Math.max(0, this.food.remainDays()),
total: this.food.freshDays,
type: ProgressType.Linear
})
.color(this.statusColor(this.food.status)).backgroundColor('#EEEEEE').width('100%').margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).padding({ left: 12 })
Column() {
if (this.food.isInsured && (this.food.status === 'warning' || this.food.status === 'expired')) {
Button('理赔').fontSize(12).height(28).backgroundColor('#FF9800').borderRadius(14)
.onClick(() => this.onClaimClick())
}
Text('详情').fontSize(12).fontColor('#999999').margin({ top: 6 })
.onClick(() => this.onDetailClick())
}
}
.width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(14)
.shadow({ radius: 6, color: '#10000000', offsetX: 0, offsetY: 2 })
}
}
@ComponentV2
struct RecordCard {
@Require @Param record!: InsuranceRecord;
statusBg(status: ClaimStatus): string {
return status === 'approved' ? '#E8F5E9' : status === 'pending' ? '#FFF8E1' : '#FFEBEE';
}
statusText(status: ClaimStatus): string {
return status === 'approved' ? '已通过' : status === 'pending' ? '审核中' : '已驳回';
}
statusColor(status: ClaimStatus): string {
return status === 'approved' ? '#4CAF50' : status === 'pending' ? '#FF9800' : '#F44336';
}
build() {
Row() {
Column() {
if (this.record.status === 'approved') {
Image($r('app.media.icon_ok')).width(28).height(28).fillColor('#4CAF50')
} else if (this.record.status === 'pending') {
Image($r('app.media.icon_more')).width(28).height(28).fillColor('#FF9800')
} else {
Image($r('app.media.icon_close')).width(28).height(28).fillColor('#F44336')
}
}
.width(44).height(44).backgroundColor(this.statusBg(this.record.status)).borderRadius(22)
.justifyContent(FlexAlign.Center)
Column() {
Text(this.record.foodName).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
Text(this.record.reason).fontSize(12).fontColor('#888888').margin({ top: 2 })
Text(this.record.claimDate).fontSize(11).fontColor('#BBBBBB').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).padding({ left: 12 })
Column() {
Text(this.statusText(this.record.status))
.fontSize(12).fontColor(this.statusColor(this.record.status))
.backgroundColor(this.statusBg(this.record.status))
.borderRadius(10).padding({ left: 8, right: 8, top: 3, bottom: 3 })
if (this.record.claimAmount > 0) {
Text(`¥${this.record.claimAmount.toFixed(2)}`)
.fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333').margin({ top: 6 })
}
}
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(12)
.shadow({ radius: 4, color: '#10000000', offsetX: 0, offsetY: 2 })
}
}
@ComponentV2
struct DetailRow {
@Param label: string = '';
@Param value: string = '';
@Param valueColor: string = '#333333';
build() {
Row() {
Text(this.label).fontSize(14).fontColor('#888888')
Blank()
Text(this.value).fontSize(14).fontColor(this.valueColor).textAlign(TextAlign.End)
}
.width('100%').padding({ top: 8, bottom: 8 })
}
}
@ComponentV2
struct MenuItemRow {
@Param iconPath: ResourceStr = $r('app.media.icon_default');
@Param label: string = '';
@Param subLabel: string = '';
@Param badge: string = '';
build() {
Row() {
Image(this.iconPath).width(20).height(20).fillColor('#666666')
Text(this.label).fontSize(15).fontColor('#1A1A1A').margin({ left: 12 })
if (this.subLabel) {
Text(this.subLabel).fontSize(12).fontColor('#999999').margin({ left: 6 })
}
Blank()
if (this.badge) {
Text(this.badge).fontSize(11).fontColor('#FFFFFF').backgroundColor('#F44336')
.borderRadius(10).padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
Image($r('app.media.icon_arrow_right')).width(16).height(16).fillColor('#CCCCCC')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
.border({ width: { bottom: 1 }, color: '#F5F5F5' })
}
}
十一、总结
本代码展示了鸿蒙ArkTS单页面应用的完整开发范式:从数据模型设计(@Observed+@ObjectLink)、响应式状态管理(@State)、条件渲染(if)、列表渲染(ForEach)、组件封装(@Builder+@Component)到弹框交互和表单输入,涵盖HarmonyOS应用开发中最核心的能力。组件复用设计使统计卡片、详情行、菜单项等重复元素只需定义一次,在多处调用。颜色语义体系贯穿始终,确保用户无需阅读文字即可理解数据含义。实际工程中建议将各组件拆分到独立.ets文件,并通过promptAction替代部分console.log调试输出。

更多推荐




所有评论(0)