前言

在这里插入图片描述

食材保险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:原生日期选择器,配置startend限定可选范围,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调试输出。

在这里插入图片描述

Logo

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

更多推荐