本文基于一个完整的"宠物生活管家"HarmonyOS 应用源码,从接口定义、数据模型、配置对象、Mock 数据、子组件、弹框 Builder、主入口组件到底部导航栏,逐段进行极其详尽的技术分析。无论你是 ArkTS 初学者还是有一定经验的开发者,都能从中收获关于声明式 UI、状态管理、组件复用、自定义弹框等方面的实战知识。

在这里插入图片描述


一、项目整体架构概览

本应用是一个典型的 HarmonyOS ArkTS 多 Tab 页移动端应用,采用暖橙黄系彩色卡片风格设计,核心功能涵盖宠物信息管理、健康档案记录、日程提醒管理、社区互动交流以及个人中心设置五大模块。整体代码结构清晰,从上到下可以分为以下几个层次:

  1. 接口定义层:使用 TypeScript interface 定义了12个数据接口,为整个应用提供类型安全保障。
  2. 数据模型层:通过 @Observed 装饰器创建了可观察的数据模型类,为状态管理奠定基础。
  3. 配置对象层:使用 const + Record 类型定义了提醒类型、快捷操作、设置项等静态配置。
  4. Mock 数据层:包含宠物信息、健康记录、日程提醒、社区帖子、用品商城、月度支出、疫苗记录、体重记录、驱虫记录等9组仿真数据。
  5. 工具函数层:提供性别标签和疫苗徽章颜色的快捷转换方法。
  6. 枚举定义层:定义主 Tab 页的枚举类型。
  7. 子组件层:封装了柱状图视图、体重趋势视图、疫苗徽章视图三个可复用组件。
  8. 弹框 Builder 层:实现了新增提醒、删除确认、编辑宠物信息三个全局弹框。
  9. 主入口组件层:使用 @Entry@Component 装饰器定义应用主组件,包含5个 Tab 页面的完整 UI 构建逻辑和辅助方法。

这种从"数据定义"到"UI 表现"的分层架构,是 ArkTS 开发中推荐的最佳实践,使得数据流转清晰、组件职责明确、代码可维护性强。


二、接口定义:12个 TypeScript Interface

interface PetInfo { id: number; name: string; breed: string; age: number; weight: number; gender: string; avatar: string; vaccineStatus: string; isSterilized: boolean; birthday: string; color: string; favorite: string; microchipId: string; }
interface HealthRecord { id: number; petId: number; date: string; type: string; description: string; hospital: string; doctor: string; cost: number; result: string; nextDate: string; }
interface ScheduleReminder { id: number; petId: number; petName: string; type: string; title: string; time: string; date: string; isCompleted: boolean; isRepeat: boolean; note: string; icon: string; color: string; }
interface CommunityPost { id: number; userName: string; userAvatar: string; petName: string; petEmoji: string; content: string; imageCount: number; likeCount: number; commentCount: number; time: string; isLiked: boolean; }
interface PetSupply { id: number; name: string; category: string; price: number; brand: string; rating: number; image: string; stockStatus: string; purchaseCount: number; }
interface MonthlyExpense { month: string; food: number; medical: number; grooming: number; supplies: number; other: number; total: number; }
interface VaccineRecord { id: number; petId: number; vaccineName: string; date: string; nextDate: string; hospital: string; batchNumber: string; isCompleted: boolean; }
interface WeightRecord { id: number; petId: number; weight: number; date: string; note: string; }
interface DewormRecord { id: number; petId: number; type: string; medicine: string; date: string; nextDate: string; isInternal: boolean; }
interface QuickAction { id: number; title: string; icon: string; bgColor: string; iconColor: string; }
interface SettingItem { id: number; title: string; icon: string; subTitle: string; hasArrow: boolean; }
interface ReminderTypeOption { label: string; icon: string; color: string; bgColor: string; }

在这里插入图片描述

深度解析

本应用一口气定义了12个 TypeScript 接口,这是整个应用的"数据契约基石"。在 ArkTS 开发中,接口的作用不仅仅是提供类型提示,更是一种架构设计手段,让前端 UI 与后端数据之间建立清晰的映射关系。

PetInfo 接口是核心中的核心,包含15个字段,覆盖了宠物的身份信息(id、microchipId)、基本属性(name、breed、age、weight、gender、color)、健康管理字段(vaccineStatus、isSterilized)、个性化数据(avatar、favorite、birthday)。其中 microchipId(芯片编号)的设计体现了实际业务场景中对宠物身份唯一标识的需求;vaccineStatus 用字符串类型而非枚举,赋予了更高的灵活性,但也意味着需要在使用时进行字符串比较判断。

HealthRecord 接口设计了一个完整的医疗记录模型,包含 petId 实现多宠物关联,type 字段标识诊疗类型(体检、疫苗、驱虫、牙科、皮肤科、眼科等),cost 字段记录费用,nextDate 字段支持下次就诊提醒的链式追踪。值得注意的是,nextDate 为空字符串表示无需复查(如已痊愈),这种设计允许业务逻辑根据空值做出不同判断。

ScheduleReminder 接口是日程提醒的核心数据结构。isCompletedisRepeat 两个布尔字段分别控制完成状态显示和重复标识。iconcolor 字段直接存储在数据对象中,这是一种"数据驱动样式"的设计模式——每个提醒项自带视觉属性,使得 UI 渲染时无需再通过类型映射查找图标和颜色,提升了渲染效率。

CommunityPost 接口模拟了社交媒体帖子的数据模型。imageCount 而非实际的图片 URL 数组,是一个轻量化设计选择——在演示场景中只需展示"有几张图"而非真正加载图片资源。isLiked 字段体现了"状态可变"的设计,用户点赞后需要即时更新 UI。

MonthlyExpense 接口采用了"预聚合"数据结构——每月的总支出 total 已经预先计算好,而不是在前端运行时从各分类汇总。这种设计在数据量可控的情况下简化了 UI 渲染逻辑,适合展示型的统计图表。

QuickAction 和 SettingItem 接口是纯 UI 配置数据,它们的 bgColoriconColorhasArrow 等字段都是纯粹的样式属性。将样式配置数据化是本应用的一个显著设计特点,把视觉表现与数据定义统一管理。


三、@Observed 数据模型类

@Observed
class PetModel {
  id: number; name: string; breed: string; age: number;
  weight: number; gender: string; avatar: string; vaccineStatus: string;
  isSterilized: boolean; birthday: string; color: string; favorite: string; microchipId: string;
  constructor(info: PetInfo) {
    this.id = info.id; this.name = info.name; this.breed = info.breed;
    this.age = info.age; this.weight = info.weight; this.gender = info.gender;
    this.avatar = info.avatar; this.vaccineStatus = info.vaccineStatus;
    this.isSterilized = info.isSterilized; this.birthday = info.birthday;
    this.color = info.color; this.favorite = info.favorite; this.microchipId = info.microchipId;
  }
}

@Observed
class ScheduleModel {
  id: number; petId: number; petName: string; type: string; title: string;
  time: string; date: string; isCompleted: boolean; isRepeat: boolean; note: string; icon: string; color: string;
  constructor(item: ScheduleReminder) {
    this.id = item.id; this.petId = item.petId; this.petName = item.petName;
    this.type = item.type; this.title = item.title; this.time = item.time;
    this.date = item.date; this.isCompleted = item.isCompleted; this.isRepeat = item.isRepeat;
    this.note = item.note; this.icon = item.icon; this.color = item.color;
  }
}

在这里插入图片描述

深度解析

在 ArkTS 的状态管理体系中,@Observed 装饰器是一个至关重要的注解。它的作用是将一个普通的 class 标记为"可观察类",使得该类的实例属性变化可以被 ArkUI 框架自动感知,进而触发 UI 的重新渲染。这是 ArkTS 响应式编程的基石之一。

PetModel 类是对 PetInfo 接口的面向对象封装。虽然两者在字段上完全一致,但本质区别在于:PetInfo 是一个纯数据结构(DTO,Data Transfer Object),而 PetModel 是一个可被框架监听的状态对象。构造函数接受一个 PetInfo 类型的参数,通过逐一赋值将接口数据转化为可观察对象。这种"接口 + 模型类"的双层设计在实际项目中非常常见——接口用于定义数据契约和 Mock 数据类型,模型类用于运行时的状态管理。

ScheduleModel 类同理,将 ScheduleReminder 接口数据包装为可观察对象。在真正的业务场景中,这些模型类还可以扩展出业务方法,比如 toggleComplete()(切换完成状态)、updateTime()(更新时间)等,实现数据与行为的封装。

这里只对 PetInfoScheduleReminder 创建了对应的 Model 类,而其他接口(如 HealthRecordCommunityPost 等)没有创建。这说明设计者根据实际需求进行了取舍——只有在需要动态修改属性并触发 UI 更新的场景下,才需要使用 @Observed 模型类。对于仅用于列表展示的只读数据,直接使用接口类型即可,避免不必要的性能开销。


四、配置对象:Record 类型字典

const REMINDER_TYPES: Record<string, ReminderTypeOption> = {
  'feed': { label: '喂食', icon: '🍖', color: '#FF6B35', bgColor: '#FFF0E8' },
  'walk': { label: '遛弯', icon: '🐕', color: '#F7B801', bgColor: '#FFFBE8' },
  'bath': { label: '洗澡', icon: '🛁', color: '#7678ED', bgColor: '#F0F0FF' },
  'groom': { label: '美容', icon: '✂️', color: '#E85D75', bgColor: '#FFF0F3' },
  'vet': { label: '看医生', icon: '🏥', color: '#3D348B', bgColor: '#F0EEFF' },
  'deworm': { label: '驱虫', icon: '💊', color: '#06D6A0', bgColor: '#E8FFF8' },
  'vaccine': { label: '疫苗', icon: '💉', color: '#118AB2', bgColor: '#E8F8FF' },
  'nail': { label: '剪指甲', icon: '✋', color: '#EF476F', bgColor: '#FFF0F5' },
  'play': { label: '玩耍', icon: '🎾', color: '#FFD166', bgColor: '#FFFDF0' },
  'other': { label: '其他', icon: '📋', color: '#8D99AE', bgColor: '#F5F6FA' }
};

在这里插入图片描述

深度解析

这段代码定义了提醒类型的配置字典,使用了 Record<string, ReminderTypeOption> 类型签名。Record<K, V> 是 TypeScript 的一个工具类型,表示键类型为 K、值类型为 V 的对象。这里将键定义为 string 类型,值使用前面定义的 ReminderTypeOption 接口,实现了类型安全的字典查询。

每一个提醒类型都包含四个属性:label(中文标签)、icon(Emoji 图标)、color(主题色)、bgColor(背景色)。这种"一个类型配一套颜色"的设计模式在本应用中贯穿始终,形成了一致的"彩色卡片"视觉风格。从色彩心理学的角度看,喂食用暖橙色(#FF6B35)传递食物的温暖感,医疗用深紫色(#3D348B)传递专业性,驱虫用绿色(#06D6A0)传递健康感——配色选择与功能语义高度匹配。

10种提醒类型覆盖了宠物日常护理的方方面面,从基础的喂食遛弯到专业的疫苗驱虫,再到娱乐性的玩耍。这种穷举式配置虽然在类型数量上较多,但避免了运行时的条件分支判断,将逻辑转化为数据查询,符合"配置优于编码"的设计哲学。

在后续的弹框 UI 中,这个字典通过 Object.keys(REMINDER_TYPES) 获取所有键名,再用 ForEach 循环渲染,实现了类型选择器的动态生成。如果需要新增提醒类型,只需在这个配置对象中添加一条记录即可,无需修改任何 UI 代码。

const QUICK_ACTIONS: Record<string, QuickAction> = {
  'feed_log': { id: 1, title: '喂食记录', icon: '🍖', bgColor: '#FFF0E8', iconColor: '#FF6B35' },
  'walk_log': { id: 2, title: '遛弯打卡', icon: '🐕', bgColor: '#FFFBE8', iconColor: '#F7B801' },
  'health_check': { id: 3, title: '健康检查', icon: '💚', bgColor: '#E8FFF8', iconColor: '#06D6A0' },
  'reminder_add': { id: 4, title: '添加提醒', icon: '⏰', bgColor: '#F0F0FF', iconColor: '#7678ED' },
  'pet_diary': { id: 5, title: '宠物日记', icon: '📔', bgColor: '#FFF0F3', iconColor: '#E85D75' },
  'vet_search': { id: 6, title: '找医院', icon: '🏥', bgColor: '#F0EEFF', iconColor: '#3D348B' },
  'supply_mall': { id: 7, title: '用品商城', icon: '🛒', bgColor: '#E8F8FF', iconColor: '#118AB2' },
  'pet_social': { id: 8, title: '宠友圈', icon: '💬', bgColor: '#FFFDF0', iconColor: '#FFD166' }
};

在这里插入图片描述

深度解析

QUICK_ACTIONS 定义了8个快捷操作入口,采用相同的 Record 字典模式。与 REMINDER_TYPES 不同的是,这里多了一个 id 字段用于唯一标识每个操作,并在 UI 层通过 action.id === 4 判断是否为"添加提醒"操作来触发弹框。这个硬编码的 ID 判断虽然在演示中可行,但在生产环境中建议使用 action.key 或专门的 actionType 字段来提升可读性。

每个快捷操作的 bgColoriconColor 都经过精心搭配——浅色背景配深色图标形成舒适的视觉对比度。整个调色板覆盖了橙、黄、绿、紫、粉、深紫、蓝、金8种色相,既丰富又不杂乱。

const SETTINGS_LIST: Record<string, SettingItem> = {
  'family': { id: 1, title: '家庭管理', icon: '👨‍👩‍👧‍👦', subTitle: '邀请家人共同照顾宠物', hasArrow: true },
  'device': { id: 2, title: '智能设备', icon: '📱', subTitle: '已连接2台设备', hasArrow: true },
  'notify': { id: 3, title: '通知设置', icon: '🔔', subTitle: '声音和震动', hasArrow: true },
  'subscription': { id: 4, title: '会员中心', icon: '👑', subTitle: '查看会员权益', hasArrow: true },
  'help': { id: 5, title: '帮助与反馈', icon: '❓', subTitle: '在线客服 7x24', hasArrow: true },
  'about': { id: 6, title: '关于我们', icon: 'ℹ️', subTitle: 'v3.2.1', hasArrow: true }
};

在这里插入图片描述

深度解析

SETTINGS_LIST 的设计是一个经典的"设置页面数据驱动"范例。6个设置项都有 hasArrow: true,表明它们都带有右侧箭头指示"可跳转"。subTitle 字段展示了当前状态(如"已连接2台设备"、“声音和震动”),让用户无需点击就能了解设置现状。通过 Object.values(SETTINGS_LIST) 遍历即可自动生成完整的设置列表 UI,新增或删除设置项只需修改此配置对象。


五、Mock 数据层

5.1 宠物数据 MOCK_PETS

const MOCK_PETS: PetInfo[] = [
  { id: 1, name: '豆豆', breed: '金毛寻回犬', age: 3, weight: 28.5, gender: '公', avatar: '🐶',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2023-03-15', color: '#FF6B35', favorite: '网球', microchipId: 'CHIP-001' },
  { id: 2, name: '咪咪', breed: '英国短毛猫', age: 2, weight: 4.8, gender: '母', avatar: '🐱',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2024-06-20', color: '#7678ED', favorite: '逗猫棒', microchipId: 'CHIP-002' },
  { id: 3, name: '球球', breed: '柯基犬', age: 1, weight: 11.2, gender: '公', avatar: '🐕',
    vaccineStatus: '接种中', isSterilized: false, birthday: '2025-01-10', color: '#F7B801', favorite: '飞盘', microchipId: 'CHIP-003' },
  { id: 4, name: '雪球', breed: '布偶猫', age: 4, weight: 5.6, gender: '母', avatar: '😺',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2022-09-08', color: '#E85D75', favorite: '激光笔', microchipId: 'CHIP-004' },
  { id: 5, name: '大壮', breed: '拉布拉多', age: 5, weight: 32.0, gender: '公', avatar: '🐩',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2021-05-22', color: '#06D6A0', favorite: '游泳', microchipId: 'CHIP-005' }
];

在这里插入图片描述

深度解析

5只宠物的数据设计涵盖了犬类(金毛、柯基、拉布拉多)和猫类(英短、布偶)两个主要品种,年龄从1岁到5岁不等,体重从4.8kg到32kg跨度较大,体现了真实的多宠物家庭场景。

每只宠物都分配了独立的主题色 color,这些颜色在宠物切换卡片中用于高亮选中状态。microchipId 采用 “CHIP-XXX” 格式,模拟了真实的宠物芯片编号系统。favorite 字段(喜欢的玩具/活动)为每只宠物赋予了个性化特征。isSterilized 字段中只有球球为 false,这与它的 vaccineStatus: '接种中' 相呼应,暗示这是一只较年幼、养护尚未完成的宠物。

值得注意的是 avatar 使用 Emoji 字符而非图片 URL,这是一个精简的演示设计——在真实项目中,头像通常会使用网络图片或本地资源文件,通过 Image 组件的 src 属性加载。

5.2 健康记录 MOCK_HEALTH_RECORDS

const MOCK_HEALTH_RECORDS: HealthRecord[] = [
  { id: 1, petId: 1, date: '2026-07-20', type: '体检', description: '年度全面体检,各项指标正常',
    hospital: '宠爱国际动物医院', doctor: '王医生', cost: 580, result: '健康', nextDate: '2027-07-20' },
  // ... 共15条记录
];

在这里插入图片描述

深度解析

健康记录数据集包含15条记录,覆盖了5只宠物的多种就诊类型:体检、驱虫、疫苗、牙科、皮肤科、眼科、关节检查、血液检查。每条记录都关联了具体的 petId,使得可以通过过滤实现"按宠物筛选"功能。

数据中的 hospital 字段出现了4家不同的宠物医院(宠爱国际动物医院、瑞鹏宠物医院、美联众合宠物医院),模拟了真实的就诊场景。cost 字段从80元(狂犬疫苗)到680元(洁牙护理)不等,反映了不同诊疗项目的价格差异。nextDate 字段对于周期性项目(如年度体检)设定了下一次就诊日期,而一次性治疗项目(如耳道感染)则留空。

数据按时间倒序排列(最新的在前),符合用户查看"最近就诊记录"的阅读习惯。

5.3 日程提醒 MOCK_SCHEDULES

const MOCK_SCHEDULES: ScheduleReminder[] = [
  { id: 1, petId: 1, petName: '豆豆', type: 'feed', title: '喂食狗粮', time: '07:30', date: '2026-07-25',
    isCompleted: true, isRepeat: true, note: '皇家成犬粮200g', icon: '🍖', color: '#FF6B35' },
  // ... 共15条记录
];

深度解析

15条日程数据按照"时间线"排列,覆盖了一天中从早7:30到晚20:00的各个时段。isCompleted 字段只有前两条为 true,模拟了"部分完成"的真实状态。isRepeat 标记了喂食、遛弯等日常例行任务为重复项。

每条记录的 note 字段包含具体细节(如"皇家成犬粮200g"、“公园30分钟”),体现了"提醒不仅是时间点,更是行动指南"的设计理念。iconcolor 字段的数据驱动设计使得列表中每项都有独特的视觉标识。

日期方面,大部分日程为 ‘2026-07-25’(当天),少数如体外驱虫(07-26)、疫苗加强针(07-28)、关节复查(07-30)延后,模拟了"今日+近期"的日程视图。

5.4 社区帖子 MOCK_POSTS

const MOCK_POSTS: CommunityPost[] = [
  { id: 1, userName: '铲屎官小李', userAvatar: '👨', petName: 'lucky', petEmoji: '🐶',
    content: '今天带我家lucky去公园玩,遇到好多小伙伴,跑得可开心了!回家直接累趴🐾', imageCount: 3,
    likeCount: 128, commentCount: 23, time: '5分钟前', isLiked: false },
  // ... 共15条记录
];

深度解析

社区数据模拟了一个活跃的宠物社交网络。15条帖子来自不同类型的用户——普通宠物主(铲屎官小李、喵星人控)、专业账号(宠物医生小王、宠物训练师、宠物营养师)、特殊身份(流浪猫救助)以及各种品种爱好者。这种用户多样性使得社区内容更加丰富可信。

time 字段使用"X分钟/小时前"的相对时间格式,符合社交应用的惯用表达。likeCount 从128到1024不等,isLiked 的分布让部分帖子显示为"已点赞"状态(红色爱心),部分为"未点赞"(白色爱心)。imageCount 从1到6张不等,展示了不同用户发帖时的图片使用习惯。

5.5 其他 Mock 数据

MOCK_SUPPLIES(10条商品数据)涵盖主粮、药品、用品、玩具、保健品、出行、洗护、居住8个品类,purchaseCount 购买量数据反映了不同商品的热度差异。MOCK_EXPENSES(7个月数据)展示了月度支出的波动趋势,总支出从820元到1480元不等。MOCK_VACCINES(8条疫苗记录)记录了5只宠物的接种历史。MOCK_WEIGHTS(7条体重记录)仅跟踪了第一只宠物"豆豆"的体重变化趋势(27.8kg到28.6kg)。MOCK_DEWORM_RECORDS(6条驱虫记录)区分了体内/体外驱虫。


六、工具函数与枚举

function getGenderLabel(gender: string): string {
  return gender === '公' ? '♂️' : '♀️';
}
function getVaccineBadgeColor(status: string): string {
  return status === '已接种' ? '#06D6A0' : '#F7B801';
}

enum MainTab { HOME = 0, HEALTH = 1, SCHEDULE = 2, COMMUNITY = 3, PROFILE = 4 }

深度解析

两个工具函数分别实现了性别符号映射和疫苗状态颜色映射。getGenderLabel 将中文"公"/"母"转换为对应的性别符号 Emoji,getVaccineBadgeColor 将"已接种"映射为绿色(表示健康完成)、其他状态映射为黄色(表示需关注)。这种将视觉表现抽取为独立函数的做法,使得 UI 代码中不需要出现硬编码的颜色值。

MainTab 枚举是本应用页面导航的核心索引。采用数字枚举(从0开始自增)而非字符串枚举,这是因为在 ArkTS 中 @State currentTab: number 配合 if-else 条件渲染时,数字比较比字符串比较更高效。5个枚举值对应5个 Tab 页面:首页、健康、日程、社区、我的。


七、子组件:三个可复用的 @Component Struct

7.1 ExpenseBarView – 柱状图子组件

@Component
struct ExpenseBarView {
  @Prop label: string;
  @Prop value: number;
  @Prop maxValue: number;
  @Prop barColor: string;

  build() {
    Column() {
      Text(`${this.value}`)
        .fontSize(10)
        .fontColor('#666666')
        .margin({ bottom: 4 });

      Column()
        .width(28)
        .height(`${(this.value / this.maxValue) * 100}%`)
        .backgroundColor(this.barColor)
        .borderRadius(4);

      Text(this.label)
        .fontSize(10)
        .fontColor('#999999')
        .margin({ top: 4 });
    }
    .width(40)
    .height(140)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.End);
  }
}

深度解析

ExpenseBarView 是一个精心设计的迷你柱状图组件。它接收四个 @Prop 属性:label(X轴标签/月份)、value(数据值/金额)、maxValue(最大值/用于计算百分比)、barColor(柱体颜色)。@Prop 装饰器表示这是从父组件传入的单向数据流,子组件不能修改这些值。

组件内部的布局结构非常巧妙:外层 Column 高度固定为140,使用 justifyContent(FlexAlign.End) 让内容从底部向上排列。从下到上依次是:

  1. 数值文本(如 “510”):显示在柱体上方
  2. 柱体 Column:宽度固定28,高度通过模板字符串 `${(this.value / this.maxValue) * 100}%` 动态计算百分比。注意这里使用百分比高度而非像素值,实现了柱体高度随数据自动缩放
  3. 月份标签(如 “7月”):显示在柱体下方

这种"标签-柱体-数值"的自下而上排列,是一个经典的垂直柱状图实现方式。borderRadius(4) 为柱体添加了微圆角,使图表更具现代感。整个组件宽度仅40,高度140,非常紧凑,适合嵌入到卡片中。

百分比计算公式 (value / maxValue) * 100% 保证了柱体高度始终在0-100%之间(前提是 value <= maxValue)。父组件传入 maxValue: 1600 作为参考上限,确保所有月份的支出都能正确显示比例。

7.2 WeightDotView – 体重趋势视图

@Component
struct WeightDotView {
  @Prop weight: number;
  @Prop label: string;
  @Prop isLast: boolean;

  build() {
    Row() {
      Column() {
        Text(`${this.weight}kg`)
          .fontSize(11)
          .fontColor('#FF6B35')
          .fontWeight(FontWeight.Bold);

        Circle({ width: 10, height: 10 })
          .fill('#FF6B35');

        Text(this.label)
          .fontSize(11)
          .fontColor('#999999');
      }
      .alignItems(HorizontalAlign.Center);

      if (!this.isLast) {
        Divider()
          .vertical(false)
          .width(30)
          .height(1)
          .color('#FF6B35')
          .margin({ top: 14 });
      }
    }
    .alignItems(VerticalAlign.Center);
  }
}

深度解析

WeightDotView 是一个体重趋势时间线组件,每一个实例表示一个时间节点的体重数据。设计上采用了"数据点 + 连接线"的横向时间线模式。

核心结构是一个 Row(水平排列),内部包含:

  • 一个 Column(垂直排列)显示体重数值、圆形数据点和日期标签
  • 一个条件渲染的 Divider(分割线)作为连接线

isLast 布尔属性是设计的关键——如果是最后一个数据点,就不需要显示连接线。这个条件判断通过 if (!this.isLast) 实现,避免在时间线末尾出现多余的线条。连接线使用 Divider 组件而非自定义绘制,是 ArkTS 中一种简洁的实现方式。margin({ top: 14 }) 精确地将连接线对齐到圆形数据点的中心位置(考虑了上方文本的高度偏移)。

Circle({ width: 10, height: 10 }).fill('#FF6B35') 创建了一个直径10vp的实心圆,作为数据点的视觉标记。多个 WeightDotView 在父组件中被放入一个水平滚动的 Scroll > Row 容器中,形成可滑动查看的时间线效果。

7.3 VaccineBadgeView – 疫苗徽章视图

@Component
struct VaccineBadgeView {
  @Prop vaccineName: string;
  @Prop isCompleted: boolean;
  @Prop hospital: string;
  @Prop date: string;

  build() {
    Row() {
      Column() {
        Text(this.isCompleted ? '✓' : '○')
          .fontSize(18)
          .fontColor(this.isCompleted ? '#06D6A0' : '#F7B801');
      }
      .width(40)
      .height(40)
      .borderRadius(20)
      .backgroundColor(this.isCompleted ? '#E8FFF8' : '#FFFBE8')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center);

      Column() {
        Text(this.vaccineName)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333');

        Text(this.hospital)
          .fontSize(11)
          .fontColor('#999999')
          .margin({ top: 2 });

        Text(this.date)
          .fontSize(10)
          .fontColor('#BBBBBB')
          .margin({ top: 1 });
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
      .layoutWeight(1);
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FAFAFA')
    .borderRadius(12)
    .margin({ bottom: 8 });
  }
}

深度解析

VaccineBadgeView 是一个状态感知的疫苗记录卡片组件。它的视觉表现根据 isCompleted 属性动态变化:

左侧状态圆标:40x40 的圆形区域,完成状态显示绿色对勾(✓ + #06D6A0 + #E8FFF8 背景),未完成显示黄色空心圆(○ + #F7B801 + #FFFBE8 背景)。borderRadius(20) 恰好是宽度的一半,形成完美的圆形。颜色组合采用"深色图标 + 浅色背景"的经典搭配。

右侧信息区:使用 layoutWeight(1) 占据剩余空间,展示疫苗名称(14号加粗)、医院名称(11号灰色)、接种日期(10号浅灰)。三级文字的大小和颜色递减,形成了自然的视觉层次——最重要的疫苗名称最突出,医院名称次之,日期作为辅助信息最淡化。

外层容器:浅灰色背景(#FAFAFA)、12内边距、12圆角、8底部间距,整体呈现为一个独立的卡片单元。多个徽章在 ForEach 中垂直排列时,8px 的底部间距形成了舒适的卡片间距。

这种"图标 + 信息"的横向布局模式是移动端列表项的通用设计范式,通过 Row + layoutWeight 实现了弹性宽度的响应式布局。


八、全局弹框 Builder

8.1 AddReminderDialog – 新增提醒弹框

@Builder
function AddReminderDialog(
  show: boolean,
  selType: string,
  rmTime: string,
  rmNote: string,
  onClose: () => void,
  onSelType: (t: string) => void,
  onTime: (t: string) => void,
  onNote: (n: string) => void,
  onConfirm: () => void
) {
  if (show) {
    Stack() {
      Column() {
        Column() {
          // ... 弹框内容
        }
        .width('90%')
        .padding(24)
        .backgroundColor('#FFFFFF')
        .borderRadius(20);
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center);
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => { onClose(); });
  }
}

深度解析

这是一个定义在组件外部的全局 @Builder 函数,接收9个参数(1个显示标志 + 3个数据值 + 4个回调函数 + 1个确认回调)。这种参数设计模式在 ArkTS 中是跨组件通信的经典方式——通过回调函数将子组件(弹框)的用户操作传递回父组件(主组件),实现了数据的"向上传递"。

条件渲染机制:外层的 if (show) 是整个弹框的开关。当 showfalse 时,函数返回 undefined,不渲染任何 UI;当 showtrue 时,才创建完整的弹框视图树。这种条件渲染方式比设置 visibility: Visibility.Hidden 更高效,因为不渲染意味着不占用布局计算资源。

遮罩层实现:使用 Stack 堆叠容器,外层设置 position({ x: 0, y: 0 }) 覆盖整个屏幕,backgroundColor('rgba(0,0,0,0.5)') 创建半透明黑色遮罩。遮罩层绑定 onClick 关闭弹框,这是移动端弹框的标准交互模式——点击遮罩即可关闭。由于 Stack 的子组件按声明顺序从底到顶堆叠,内层的内容 Column 自然覆盖在遮罩之上。

类型选择器:使用 ForEach(Object.keys(REMINDER_TYPES), ...) 遍历所有提醒类型,每个类型渲染为一个60x70的圆角卡片。选中状态通过 selType === key 判断,选中时显示该类型的 bgColor 背景,未选中显示通用浅灰。文字颜色同理,选中为橙色,未选为灰色。这种"选中态/未选中态"的双色切换模式,实现了一个轻量级的单选组件。

表单输入:时间和备注各使用一个 TextInput 组件,通过 onChange 回调将用户输入实时传递给父组件。layoutWeight(1) 让输入框占据标签之外的剩余空间。backgroundColor('#F5F5F5') 的浅灰输入框背景配合8号圆角,营造出柔和的输入体验。

底部按钮:取消和确认按钮各占45%宽度(中间留10%间距),高度44px,圆角22px(形成胶囊形状)。取消按钮为灰色背景,确认按钮为主题橙色,形成了明确的"次要/主要"视觉区分。

8.2 DeleteConfirmDialog – 删除确认弹框

@Builder
function DeleteConfirmDialog(
  show: boolean,
  itemName: string,
  onCancel: () => void,
  onDelete: () => void
) {
  if (show) {
    Stack() {
      // ... 类似的遮罩+居中结构
      Column() {
        Text('⚠️').fontSize(48).margin({ bottom: 12 });
        Text('确认删除').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333333');
        Text(`确定要删除"${itemName}"吗?\n此操作不可撤销`)
          .fontSize(14).fontColor('#888888').textAlign(TextAlign.Center);
        // ... 取消 + 确认删除按钮
      }
    }
    .position({ x: 0, y: 0 })
    .backgroundColor('rgba(0,0,0,0.5)');
  }
}

深度解析

删除确认弹框采用了与前一个弹框完全相同的"遮罩 + 居中内容"结构模式,但内容更为精简——只有警告图标、标题、描述文字和两个操作按钮,体现了"操作越危险,UI 越简洁"的设计原则。

视觉上,48号的大尺寸警告 Emoji(⚠️)作为视觉焦点吸引用户注意;模板字符串 `确定要删除"${itemName}"吗?\n此操作不可撤销` 动态插入要删除的项目名称,使用 \n 换行分两行显示。“确认删除"按钮使用红色(#EF476F),红色在 UI 设计中普遍代表"危险操作”,让用户在点击前产生本能的警觉。

弹框宽度为80%(比新增提醒弹框的90%更窄),因为内容更少,更窄的弹框反而更聚焦。padding(28) 的内边距比新增提醒弹框的24更大,为稀疏的内容留出了更多呼吸空间。

8.3 EditPetDialog – 编辑宠物信息弹框

@Builder
function EditPetDialog(
  show: boolean,
  petTarget: PetInfo | null,
  petName: string,
  petBreed: string,
  petWeight: string,
  onClose: () => void,
  onName: (v: string) => void,
  onBreed: (v: string) => void,
  onWeight: (v: string) => void,
  onSave: () => void
) {
  if (show) {
    Stack() {
      Column() {
        // 标题
        Text('编辑宠物信息').fontSize(20).fontWeight(FontWeight.Bold);

        if (petTarget !== null) {
          Text(petTarget.avatar).fontSize(56).margin({ bottom: 16 });
        }

        // 名字、品种、体重三个输入字段
        // ... 类似 AddReminderDialog 的 Row + TextInput 结构

        // 取消 + 保存按钮
      }
    }
    .position({ x: 0, y: 0 })
    .backgroundColor('rgba(0,0,0,0.5)');
  }
}

深度解析

编辑宠物弹框在参数设计上引入了联合类型 PetInfo | null,通过 petTarget 传递当前编辑的宠物对象。当 petTarget 不为空时,显示宠物的头像 Emoji(56号超大尺寸),让用户明确知道正在编辑哪只宠物。这是弹框中唯一的条件渲染区域 if (petTarget !== null),作为防御性编程避免了空指针异常。

三个表单字段(名字、品种、体重)采用统一的"标签 + 输入框"行布局。标签固定60宽度,输入框通过 layoutWeight(1) 弹性填充。petWeight 使用字符串类型(而非数字)是因为 TextInputonChange 回调返回的就是字符串值,直接存储避免了类型转换的复杂度。保存时再由父组件负责将字符串转为数字。

三个表单字段各自有独立的 onChange 回调(onNameonBreedonWeight),这种"一输入一回调"的细粒度设计使得父组件可以精确追踪每个字段的变化,但也导致参数数量较多。在实际项目中,可以考虑将这些字段封装为一个表单状态对象来减少参数。


九、主入口组件 PetLifeButlerApp

9.1 状态定义

@Entry
@Component
struct PetLifeButlerApp {
  @State currentTab: number = MainTab.HOME;
  @State selectedPetIndex: number = 0;
  @State showAddReminder: boolean = false;
  @State showDeleteConfirm: boolean = false;
  @State showEditPet: boolean = false;
  @State addReminderType: string = 'feed';
  @State addReminderTime: string = '';
  @State addReminderNote: string = '';
  @State deleteTargetName: string = '';
  @State editPetNameField: string = '';
  @State editPetBreedField: string = '';
  @State editPetWeightField: string = '';
  @State editTargetPet: PetInfo | null = null;

  private tabTitles: string[] = ['首页', '健康', '日程', '社区', '我的'];
  private tabIcons: string[] = ['🏠', '💚', '📅', '💬', '👤'];
  private tabActiveColors: string[] = ['#FF6B35', '#06D6A0', '#7678ED', '#E85D75', '#3D348B'];

深度解析

@Entry 装饰器将此组件标记为应用入口页面,相当于 Android 中的 MainActivity 或 iOS 中的 RootViewController@Component 声明这是一个自定义组件。

@State 状态变量共有14个,可以分为三组:

  1. 导航状态currentTab(当前Tab索引)、selectedPetIndex(当前选中的宠物索引)
  2. 弹框控制:3个 show* 布尔值控制弹框显隐,配合对应的数据字段
  3. 表单数据:编辑和新增弹框的临时数据存储

@State 是 ArkTS 中最核心的状态管理装饰器。被它修饰的变量一旦发生变化,框架会自动调用组件的 build() 方法重新渲染 UI。这种"数据驱动视图"的模式使得开发者只需关注数据的变化,无需手动操作 DOM。

private 私有属性tabTitlestabIconstabActiveColors 三个数组分别存储 Tab 标题、图标和激活颜色。使用 private 修饰是因为它们不需要被外部访问,且不变化,不会触发重渲染。每个 Tab 都有独立的激活色(橙、绿、紫、粉、深紫),这种"一 Tab 一色"的设计让用户能通过颜色直觉地感知当前所在页面。

9.2 build() 主布局

build() {
  Stack() {
    Column() {
      this.TabContentBuilder();
      this.BottomTabBar();
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF8F0');

    AddReminderDialog(...);
    DeleteConfirmDialog(...);
    EditPetDialog(...);
  }
  .width('100%')
  .height('100%');
}

深度解析

主布局采用 Stack 堆叠容器,实现了"页面内容 + 弹框浮层"的层级关系。Stack 的子组件按声明顺序从底到顶排列:

底层Column 包含两个区域——上方是 TabContentBuilder()(各 Tab 页内容),下方是 BottomTabBar()(底部导航栏)。背景色 #FFF8F0 是一个带暖调的米白色,与"暖橙黄系"的整体设计基调一致。

浮层:三个弹框 Builder 函数作为 Stack 的直接子组件声明。它们都通过 position({ x: 0, y: 0 }) 绝对定位覆盖整个屏幕,但由于各自内部都有 if (show) 条件控制,只有 showtrue 的弹框才会渲染,其他返回空值不占空间。

这种"所有弹框都在 Stack 顶层"的架构模式,避免了弹框嵌套的问题——无论用户在哪个 Tab 页面点击操作,弹框都会显示在最顶层。多个弹框可以安全地共存于同一个 Stack 中,因为同一时刻最多只有一个 showtrue

9.3 TabContentBuilder – Tab 内容路由

@Builder
TabContentBuilder() {
  if (this.currentTab === MainTab.HOME) {
    this.HomeTabContent();
  } else if (this.currentTab === MainTab.HEALTH) {
    this.HealthTabContent();
  } else if (this.currentTab === MainTab.SCHEDULE) {
    this.ScheduleTabContent();
  } else if (this.currentTab === MainTab.COMMUNITY) {
    this.CommunityTabContent();
  } else {
    this.ProfileTabContent();
  }
}

深度解析

这个 Builder 是整个应用的"路由中枢",通过 if-else 链条根据 currentTab 的值决定渲染哪个 Tab 页面。这里使用的是条件渲染而非 Tabs 组件,这是一个有趣的技术选型:

条件渲染的优势

  • 所有 Tab 页面的内容都是一次性构建的函数调用,没有 Tabs 组件的滑动切换动画开销
  • 每个 Tab 页面可以独立控制自己的 Scroll 滚动行为
  • 布局更加灵活,不受 Tabs 组件的容器约束

条件渲染的劣势

  • 切换 Tab 时没有平滑的过渡动画
  • 每次 currentTab 变化都会销毁旧页面、创建新页面(而非像 Tabs 那样缓存页面状态)

在本应用中,由于每个 Tab 页面的内容都是基于 Mock 数据的静态渲染(没有用户输入状态需要保持),条件渲染的性能表现是足够的。else 分支兜底处理了 ProfileTabContent(),对应 MainTab.PROFILE 枚举值。


十、Tab1 首页:HomeTabContent 及子构建器

10.1 首页整体结构

@Builder
HomeTabContent() {
  Scroll() {
    Column() {
      this.HomeHeader();
      this.PetSwitchCards();
      this.TodaySummaryBar();
      this.TodayReminderSection();
      this.QuickActionGrid();
      this.HealthOverviewCard();
      Column().height(80);
    }
  }
  .width('100%')
  .layoutWeight(1)
  .scrollBar(BarState.Off);
}

深度解析

首页是一个典型的可滚动内容区域。Scroll 组件是最外层容器,.layoutWeight(1) 让它占据底部 Tab 栏之上的所有剩余空间。.scrollBar(BarState.Off) 隐藏滚动条,保持界面整洁。

内部 Column 按从上到下的顺序排列了6个区块:

  1. HomeHeader – 顶部渐变标题栏
  2. PetSwitchCards – 宠物切换卡片
  3. TodaySummaryBar – 今日三栏概览
  4. TodayReminderSection – 今日待办列表
  5. QuickActionGrid – 快捷操作网格
  6. HealthOverviewCard – 健康概览四宫格

底部 Column().height(80) 是一个空白占位区域,为最后一个卡片留出底部安全间距,防止被 Tab 栏遮挡。这种"底部留白"技巧在滚动列表设计中非常常用。

10.2 HomeHeader – 渐变标题栏

@Builder
HomeHeader() {
  Row() {
    Column() {
      Text('🐾 宠物生活管家')
        .fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
      Text(getGenderLabel(MOCK_PETS[this.selectedPetIndex].gender) +
        ' ' + MOCK_PETS[this.selectedPetIndex].breed)
        .fontSize(13).fontColor('#FFFFFFCC').margin({ top: 4 });
    }
    .alignItems(HorizontalAlign.Start);

    Row() {
      Text('🔔').fontSize(22);
      Text('⚙️').fontSize(22).margin({ left: 16 });
    }
  }
  .padding({ left: 20, right: 20, top: 44, bottom: 24 })
  .justifyContent(FlexAlign.SpaceBetween)
  .borderRadius({ bottomLeft: 24, bottomRight: 24 })
  .linearGradient({
    direction: GradientDirection.Bottom,
    colors: [['#FF6B35', 0], ['#F18701', 1]]
  });
}

深度解析

首页标题栏是一个左右分栏的 Row 布局,采用了线性渐变背景linearGradient),从橙色(#FF6B35)到金橙色(#F18701)由上到下过渡,奠定了"暖橙"的视觉基调。borderRadius({ bottomLeft: 24, bottomRight: 24 }) 只为底部两个角设置圆角,让标题栏与下方的白色卡片区域之间形成柔和的弧形过渡。

左侧显示应用名称和当前选中宠物的品种信息(动态响应 selectedPetIndex 的变化)。副标题使用 getGenderLabel() 工具函数获取性别符号,#FFFFFFCC 的颜色值中 CC 是十六进制的透明度(约80%不透明),使得副标题比标题略透明,形成视觉层次。top: 44 的内边距是为状态栏预留的安全区域。

右侧放置了通知铃铛和设置齿轮两个 Emoji 图标。目前它们仅作展示,没有绑定点击事件。在完整项目中,这里通常会添加 Badge(角标)组件来显示未读消息数量。

10.3 PetSwitchCards – 宠物切换卡片

@Builder
PetSwitchCards() {
  Column() {
    Row() {
      Text('我的宠物').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      Text(`${MOCK_PETS.length}`).fontSize(12).fontColor('#999999').margin({ left: 8 });
    }
    .width('100%').margin({ bottom: 12 });

    Scroll() {
      Row() {
        ForEach(MOCK_PETS, (pet: PetInfo, index: number) => {
          Column() {
            Text(pet.avatar).fontSize(40);
            Text(pet.name)
              .fontSize(14)
              .fontWeight(this.selectedPetIndex === index ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.selectedPetIndex === index ? '#FF6B35' : '#666666')
              .margin({ top: 6 });
            Text(`${pet.age}`).fontSize(11).fontColor('#999999');
          }
          .width(80).padding(12).borderRadius(16)
          .backgroundColor(this.selectedPetIndex === index ? '#FFF0E8' : '#FFFFFF')
          .margin({ right: 10 })
          .onClick(() => { this.selectPet(index); })
          .shadow(this.selectedPetIndex === index ?
            { radius: 12, color: '#FF6B3525', offsetX: 0, offsetY: 4 } :
            { radius: 2, color: '#00000008', offsetX: 0, offsetY: 1 });
        })
      }
      .padding({ left: 4 });
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off);
  }
  .width('100%')
  .padding(16)
  .margin({ top: 16, left: 16, right: 16 })
  .borderRadius(20)
  .backgroundColor('#FFFFFF')
  .shadow({ radius: 8, color: '#00000010', offsetX: 0, offsetY: 2 });
}

深度解析

宠物切换卡片是首页最有交互感的组件之一,实现了一个可水平滚动的宠物选择器。

外层卡片容器:白色背景、16内边距、20圆角、带阴影,标准卡片样式。水平内边距16由 margin({ left: 16, right: 16 }) 控制。

内层水平滚动区域:使用嵌套的 Scroll > Row > ForEach 三层结构。外层 Scroll 设置 .scrollable(ScrollDirection.Horizontal) 为水平滚动方向,隐藏滚动条。内层 Row 中的 ForEach 遍历所有宠物数据。

单只宠物卡片(80px宽):每张卡片包含三个元素——头像 Emoji(40号)、宠物名(14号,选中时加粗橙色)、年龄(11号灰色)。选中/未选中的视觉差异通过三重视觉手段表达:

  1. 背景色:选中为浅橙(#FFF0E8),未选为白色
  2. 文字样式:选中为加粗+橙色,未选为常规+灰色
  3. 阴影:选中为大半径+带色阴影(#FF6B3525,橙色25%透明度),未选为微小灰色阴影

shadow 属性中 color: '#FF6B3525' 使用了8位十六进制颜色值,最后两位 25 表示透明度(约15%)。这种带颜色的阴影为选中卡片赋予了"发光悬浮"的视觉效果,是 Material Design 中 Elevation 概念的 ArkTS 实现。

点击事件调用 this.selectPet(index) 修改 @State selectedPetIndex,触发整个首页 UI 的重新渲染——标题栏的宠物品种、今日概览的体重数据、健康概览的评分等信息都会随之更新,实现了"一只宠物选中,全局数据联动"的效果。

10.4 TodaySummaryBar – 今日三栏概览

@Builder
TodaySummaryBar() {
  Row() {
    Column() {
      Text('📅').fontSize(20);
      Text('今日提醒').fontSize(11).fontColor('#666666').margin({ top: 4 });
      Text('8项').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF6B35').margin({ top: 2 });
    }.layoutWeight(1);

    Divider().vertical(true).height(50).color('#EEEEEE');

    Column() {
      Text('⚖️').fontSize(20);
      Text('今日体重').fontSize(11).fontColor('#666666').margin({ top: 4 });
      Text(`${MOCK_PETS[this.selectedPetIndex].weight}kg`)
        .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#F7B801').margin({ top: 2 });
    }.layoutWeight(1);

    Divider().vertical(true).height(50).color('#EEEEEE');

    Column() {
      Text('💉').fontSize(20);
      Text('疫苗状态').fontSize(11).fontColor('#666666').margin({ top: 4 });
      Text(MOCK_PETS[this.selectedPetIndex].vaccineStatus)
        .fontSize(14).fontWeight(FontWeight.Bold)
        .fontColor(getVaccineBadgeColor(MOCK_PETS[this.selectedPetIndex].vaccineStatus))
        .margin({ top: 2 });
    }.layoutWeight(1);
  }
  .width('100%')
  .padding(16)
  .margin({ left: 16, right: 16, top: 12 })
  .borderRadius(16)
  .backgroundColor('#FFFFFF')
  .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
}

深度解析

今日概览栏是一个经典的三栏数据展示组件,使用 Row + layoutWeight(1) 实现等宽三栏布局。栏与栏之间使用垂直方向的 Divider 分隔,vertical(true) + height(50) 创建了一条50高的竖线。

三栏内容分别是:

  1. 今日提醒:固定值"8项",橙色强调色
  2. 今日体重:动态读取当前选中宠物的体重,金橙色
  3. 疫苗状态:动态读取当前选中宠物的疫苗状态,通过 getVaccineBadgeColor() 函数映射颜色

每一栏的布局结构相同:Emoji 图标(20号)+ 标签文字(11号灰色)+ 数值(16号加粗彩色)。这种"图标-标签-数值"的三行垂直布局在小尺寸卡片中非常紧凑有效。

体重和疫苗状态直接引用了 MOCK_PETS[this.selectedPetIndex],这意味着当用户在上方切换宠物时,这两个值会自动更新——这就是 @State 响应式系统的威力所在。

10.5 TodayReminderSection – 今日待办列表

@Builder
TodayReminderSection() {
  // ... 标题行

  ForEach(MOCK_SCHEDULES.slice(0, 8), (item: ScheduleReminder) => {
    Row() {
      Column() {
        Text(item.icon).fontSize(24);
      }
      .width(44).height(44).borderRadius(14)
      .backgroundColor(item.color + '20')
      .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);

      Column() {
        Text(item.title)
          .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333')
          .decoration({ type: item.isCompleted ?
            TextDecorationType.LineThrough : TextDecorationType.None });

        Text(`${item.petName} · ${item.note}`)
          .fontSize(11).fontColor('#999999').margin({ top: 2 });
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 });

      Column() {
        Text(item.time)
          .fontSize(13).fontWeight(FontWeight.Medium)
          .fontColor(item.isCompleted ? '#CCCCCC' : '#FF6B35');
        Text(item.isCompleted ? '已完成' : '待办')
          .fontSize(10)
          .fontColor(item.isCompleted ? '#06D6A0' : '#F7B801')
          .margin({ top: 2 });
      }.alignItems(HorizontalAlign.End);
    }
    .width('100%')
    .padding({ top: 10, bottom: 10 })
    .border({ width: 0.5, color: '#F5F5F5' });
  });
}

深度解析

今日待办列表使用了 MOCK_SCHEDULES.slice(0, 8) 截取前8条记录进行展示。slice 方法不会修改原数组,而是返回一个新数组,这在 ArkTS 中是安全的操作。

每条待办项是一个三栏 Row 布局:

左侧图标区:44x44 的正方形区域,14号圆角。背景色通过 item.color + '20' 动态生成——将颜色的 HEX 值追加 20 作为透明度后缀(如 #FF6B3520,橙色约12%透明度),形成"该类型颜色的极浅色背景"。这是一种非常实用的"主题色淡化"技巧,避免了为每种类型都预设浅色背景值。

中间信息区:标题文字根据 isCompleted 状态决定是否添加删除线(TextDecorationType.LineThrough),这是完成任务的标准视觉反馈。副标题使用模板字符串拼接宠物名和备注。

右侧状态区:时间颜色区分已完成(#CCCCCC 浅灰)和待办(#FF6B35 橙色)两种状态。状态标签"已完成"为绿色、"待办"为黄色,通过颜色直觉传达进度信息。

行与行之间使用 border({ width: 0.5, color: '#F5F5F5' }) 细线分隔,比 Divider 组件更轻量。

10.6 QuickActionGrid – 快捷操作网格

@Builder
QuickActionGrid() {
  Grid() {
    ForEach(Object.values(QUICK_ACTIONS), (action: QuickAction) => {
      GridItem() {
        Column() {
          Text(action.icon).fontSize(28);
          Text(action.title).fontSize(12).fontColor('#666666').margin({ top: 6 });
        }
        .width('100%').height(76).borderRadius(14)
        .backgroundColor(action.bgColor)
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .onClick(() => {
          if (action.id === 4) { this.openAddReminder(); }
        });
      }
    })
  }
  .columnsTemplate('1fr 1fr 1fr 1fr')
  .rowsGap(10)
  .columnsGap(10)
  .height(172);
}

深度解析

快捷操作使用 ArkTS 的 Grid 网格布局组件,.columnsTemplate('1fr 1fr 1fr 1fr') 定义了4列等宽布局,8个操作项自然形成2行4列的网格。.rowsGap(10).columnsGap(10) 分别设置行间距和列间距为10vp。网格总高度固定为172(76 * 2 + 10间距 + 10间距 = 172),精确计算了内容高度。

每个 GridItem 内的 Column 设置 height(76) 和14号圆角,背景色来自配置数据的 bgColoronClick 事件中只有 action.id === 4(添加提醒)有实际功能,其他操作暂未实现——在完整项目中,这里可以使用策略模式或路由表来分发不同的点击行为。

Object.values(QUICK_ACTIONS) 将字典的所有值提取为数组,供 ForEach 遍历。由于 Record 对象的属性遍历顺序在现代 JavaScript 引擎中是按插入顺序的,所以操作项的显示顺序与定义顺序一致。

10.7 HealthOverviewCard – 健康概览四宫格

@Builder
HealthOverviewCard() {
  Column() {
    Row() {
      Text('💚 健康概览').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
    }

    Row() {
      Column() {
        Text('体重趋势').fontSize(13).fontColor('#666666');
        Text(`${MOCK_PETS[this.selectedPetIndex].weight}kg`)
          .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF6B35');
        Text('较上月 +0.1kg').fontSize(11).fontColor('#06D6A0');
      }
      .width('48%').padding(14).borderRadius(14).backgroundColor('#FFF8F0');

      Column() {
        Text('距下次疫苗').fontSize(13).fontColor('#666666');
        Text('125天').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#7678ED');
        Text('2027-03-15').fontSize(11).fontColor('#999999');
      }
      .width('48%').padding(14).borderRadius(14).backgroundColor('#F5F5FF');
    }
    .width('100%').justifyContent(FlexAlign.SpaceBetween);

    Row() {
      // 本月就医 + 健康评分,结构同上
    }
    .width('100%').justifyContent(FlexAlign.SpaceBetween);
  }
}

深度解析

健康概览采用2x2的"四宫格"布局,通过两个并排的 Row 实现。每行两个等宽卡片,width('48%') 加上 SpaceBetween 布局,中间自然留出4%的间距。

每个小卡片的布局结构统一:灰色标签(13号)+ 大号数值(28号加粗彩色)+ 补充说明(11号)。这种"三行式"布局在数据卡片中极其常见——第一行说明是什么数据,第二行展示核心数值,第三行提供趋势或上下文。

四个卡片分别用不同的浅色背景区分:#FFF8F0(暖橙)、#F5F5FF(淡紫)、#FFF5F5(浅粉)、#F0FFF8(薄荷绿),既丰富了视觉层次,又与各自的数值颜色(橙、紫、粉、绿)形成呼应。

数值颜色选择也各有语义:体重用橙色(主题色)、疫苗用紫色(医疗色)、就医次数用粉色(警示色)、健康评分用绿色(健康色),实现了"数据色彩化"的信息传达。


十一、Tab2 健康:HealthTabContent 及子构建器

11.1 健康页整体结构与标题栏

@Builder
HealthTabContent() {
  Scroll() {
    Column() {
      this.HealthHeader();
      this.HealthRecordList();
      this.WeightTrendSection();
      this.VaccineRecordSection();
      this.DewormRecordSection();
      Column().height(80);
    }
  }
  .width('100%').layoutWeight(1).scrollBar(BarState.Off);
}

@Builder
HealthHeader() {
  Row() {
    Text('💚 健康管理').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
    Text('15条记录').fontSize(13).fontColor('#FFFFFFCC');
  }
  .padding({ left: 20, right: 20, top: 44, bottom: 20 })
  .justifyContent(FlexAlign.SpaceBetween)
  .borderRadius({ bottomLeft: 24, bottomRight: 24 })
  .linearGradient({
    direction: GradientDirection.Bottom,
    colors: [['#06D6A0', 0], ['#118AB2', 1]]
  });
}

深度解析

健康页面包含4个内容区块:健康记录列表、体重趋势、疫苗记录、驱虫记录。标题栏使用了绿色到蓝色的渐变(#06D6A0 -> #118AB2),与首页的橙色渐变形成鲜明对比,帮助用户通过颜色直觉区分不同页面。右侧显示"15条记录"作为数据统计摘要。

11.2 HealthRecordList – 健康记录列表

@Builder
HealthRecordList() {
  ForEach(MOCK_HEALTH_RECORDS, (record: HealthRecord) => {
    Row() {
      Column() {
        Text(this.getHealthIcon(record.type)).fontSize(22);
      }
      .width(44).height(44).borderRadius(14)
      .backgroundColor(this.getHealthBgColor(record.type))
      .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);

      Column() {
        Text(record.type).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333');
        Text(record.description)
          .fontSize(11).fontColor('#999999').margin({ top: 2 })
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis });
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 });

      Column() {
        Text(`¥${record.cost}`)
          .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#FF6B35');
        Text(record.date).fontSize(10).fontColor('#BBBBBB').margin({ top: 2 });
      }.alignItems(HorizontalAlign.End);
    }
    .width('100%')
    .padding({ top: 10, bottom: 10 })
    .border({ width: 0.5, color: '#F5F5F5' });
  });
}

深度解析

健康记录列表与首页待办列表结构类似,但有三处关键差异:

  1. 动态图标映射:使用 this.getHealthIcon(record.type)this.getHealthBgColor(record.type) 两个辅助方法,根据诊疗类型动态返回不同的 Emoji 图标和背景色。getHealthIcon 使用 switch-case 实现了8种类型的图标映射(体检🩺、疫苗💉、驱虫💊、牙科🦷、皮肤科🩹、眼科👁️、关节检查🦴、血液检查🩸),getHealthBgColor 为每种类型映射了协调的浅色背景。

  2. 费用展示:右侧列显示费用(模板字符串 `¥${record.cost}`)和日期,将财务信息与医疗信息并列展示,方便宠物主人追踪支出。

  3. 文本溢出处理description 字段设置 maxLines(1) 限制为单行显示,配合 textOverflow({ overflow: TextOverflow.Ellipsis }) 在文字过长时显示省略号。这是列表项中处理长文本的标准做法,保持列表项高度的一致性。

11.3 WeightTrendSection – 体重趋势

@Builder
WeightTrendSection() {
  Scroll() {
    Row() {
      ForEach(MOCK_WEIGHTS, (item: WeightRecord, index: number) => {
        WeightDotView({
          weight: item.weight,
          label: item.date.substring(5),
          isLast: index === MOCK_WEIGHTS.length - 1
        });
      })
    }
    .padding({ left: 8, right: 8 });
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off);

  Row() {
    Text('起始: 27.8kg').fontSize(11).fontColor('#999999');
    Text('当前: 28.6kg').fontSize(11).fontColor('#FF6B35').margin({ left: 16 });
    Text('变化: +0.8kg').fontSize(11).fontColor('#06D6A0').margin({ left: 16 });
  }
  .width('100%').margin({ top: 12 });
}

深度解析

体重趋势区域使用了前面定义的 WeightDotView 子组件。水平滚动的 Scroll > Row 容器中,7个月份的体重数据点依次排列,形成可横向滑动的时间线。

item.date.substring(5) 从日期字符串中截取"月-日"部分(如从"2026-01-15"中提取"01-15"),作为简短的 X 轴标签。isLast 通过比较 index === MOCK_WEIGHTS.length - 1 判断是否为最后一个节点。

底部统计行展示了起始体重(灰色)、当前体重(橙色)、总变化量(绿色),为用户提供快速的数据摘要。变化量前的 + 号暗示体重在增长——在真实项目中,可以根据正负值显示不同颜色(增长用橙色警示,下降用绿色表示健康)。

11.4 VaccineRecordSection 和 DewormRecordSection

疫苗记录区域使用 VaccineBadgeView 子组件渲染每条疫苗记录,驱虫记录区域则直接内联构建 UI——使用 🔴/🔵 Emoji 区分体内/体外驱虫,record.isInternal 布尔值控制颜色选择。每条驱虫记录的右侧显示日期和下次驱虫日期,形成"当前-下次"的时间对照。

这两个区域都是标准卡片样式(白色背景、16内边距、16圆角、微阴影),内部的列表项通过 borderRadius(12)margin({ bottom: 8 }) 形成圆角子卡片效果,在白色大卡片内部再嵌套灰色(#FAFAFA)小卡片,创造了两层视觉深度。


十二、Tab3 日程:ScheduleTabContent 及子构建器

12.1 日程页标题栏与筛选栏

@Builder
ScheduleHeader() {
  Row() {
    Text('📅 日程安排').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
    Row() {
      Text('+ 新增')
        .fontSize(14).fontColor('#FFFFFF')
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor('#FFFFFF30').borderRadius(16);
    }
    .onClick(() => { this.openAddReminder(); });
  }
  .linearGradient({
    direction: GradientDirection.Bottom,
    colors: [['#7678ED', 0], ['#3D348B', 1]]
  });
}

@Builder
ScheduleFilterBar() {
  Row() {
    Text('全部')
      .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .backgroundColor('#7678ED').borderRadius(14);

    Text('喂食').fontSize(13).fontColor('#999999')
      .backgroundColor('#F5F5F5').borderRadius(14).margin({ left: 8 });
    // ... 遛弯、医疗、护理
  }
}

深度解析

日程页标题栏采用紫色到深紫的渐变(#7678ED -> #3D348B),与"日程/时间管理"的主题色呼应。右侧的"+ 新增"按钮使用了 #FFFFFF30(白色约19%透明度)作为背景,在紫色渐变上形成半透明的"毛玻璃"效果。点击后调用 openAddReminder() 打开新增提醒弹框。

筛选栏是日程页独有的组件。当前选中项(“全部”)显示为白色文字+紫色背景的"填充态",其余项为灰色文字+浅灰背景的"默认态"。筛选项通过 padding + borderRadius(14) 形成胶囊形状的按钮样式。目前筛选功能仅做视觉展示,实际过滤逻辑需要在 ScheduleList 中根据选中状态过滤 MOCK_SCHEDULES 数组。

12.2 ScheduleList – 日程列表

@Builder
ScheduleList() {
  ForEach(MOCK_SCHEDULES, (item: ScheduleReminder) => {
    Row() {
      Column() {
        Text(item.icon).fontSize(26);
      }
      .width(50).height(50).borderRadius(16)
      .backgroundColor(item.color + '20');

      Column() {
        Text(item.title)
          .decoration({ type: item.isCompleted ?
            TextDecorationType.LineThrough : TextDecorationType.None });
        Text(item.note).fontSize(11).fontColor('#999999').margin({ top: 3 });
      }
      .layoutWeight(1).margin({ left: 12 });

      Column() {
        Text(item.time).fontSize(14).fontWeight(FontWeight.Bold).fontColor(item.color);
        Text(item.date).fontSize(10).fontColor('#BBBBBB').margin({ top: 3 });
        Row() {
          if (item.isRepeat) {
            Text('重复')
              .fontSize(9).fontColor('#7678ED')
              .backgroundColor('#F0F0FF').borderRadius(6);
          }
          if (item.isCompleted) {
            Text('✓').fontSize(9).fontColor('#06D6A0').margin({ left: 4 });
          }
        }
        .margin({ top: 3 });
      }.alignItems(HorizontalAlign.End);
    }
    .padding(14).margin({ left: 16, right: 16, top: 8 })
    .borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 4, color: '#00000008', offsetX: 0, offsetY: 1 });
  });
}

深度解析

与首页待办列表不同,日程列表中的每条记录是一个独立的卡片(而非列表行)。每个卡片有14内边距、16圆角、微阴影,卡片之间有8的上间距,形成"卡片瀑布"的视觉效果。这种设计让每条日程都感觉是独立的信息单元,视觉上更加透气。

右侧状态区域更丰富:除了时间和日期,还通过两个条件渲染的标签传达额外信息——"重复"标签(紫色小药丸)标识周期性任务,绿色对勾标识已完成状态。这两个标签在同一个 Row 中水平排列,if 条件确保只有符合条件时才渲染。


十三、Tab4 社区:CommunityTabContent 及子构建器

13.1 社区页标题栏

@Builder
CommunityHeader() {
  Row() {
    Text('💬 宠物社区').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
    Row() {
      Text('关注').fontSize(12).fontColor('#FFFFFF')
        .backgroundColor('#FFFFFF25').borderRadius(10).margin({ right: 8 });
      Text('热门').fontSize(12).fontColor('#FFFFFF')
        .backgroundColor('#FFFFFF40').borderRadius(10);
    }
  }
  .linearGradient({
    direction: GradientDirection.Bottom,
    colors: [['#E85D75', 0], ['#EF476F', 1]]
  });
}

深度解析

社区页标题栏使用粉色到玫红的渐变(#E85D75 -> #EF476F),传递出社交、温馨、活泼的氛围。右侧的"关注"和"热门"是两个切换标签,"热门"标签使用了更高的透明度(#FFFFFF40 约25% vs #FFFFFF25 约15%),暗示"热门"是当前激活的标签。目前这两个标签仅作展示。

13.2 CommunityPostList – 社区帖子列表

@Builder
CommunityPostList() {
  ForEach(MOCK_POSTS, (post: CommunityPost) => {
    Column() {
      // 用户信息行
      Row() {
        Text(post.userAvatar).fontSize(32);
        Column() {
          Text(post.userName).fontSize(14).fontWeight(FontWeight.Medium);
          Text(post.time).fontSize(11).fontColor('#BBBBBB').margin({ top: 2 });
        }
        .layoutWeight(1).margin({ left: 10 });
        Text('···').fontSize(18).fontColor('#CCCCCC');
      }

      // 宠物标签
      Row() {
        Text(post.petEmoji).fontSize(36);
        Text(post.petName).fontSize(13).fontColor('#999999').margin({ left: 6 });
      }
      .padding({ left: 8, right: 10, top: 4, bottom: 4 })
      .backgroundColor('#F8F8F8').borderRadius(10).margin({ top: 10 });

      // 正文内容
      Text(post.content)
        .fontSize(14).fontColor('#555555').lineHeight(22)
        .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis });

      // 图片占位
      if (post.imageCount > 0) {
        Row() {
          Text('🖼️').fontSize(16);
          Text(`${post.imageCount}张图片`)
            .fontSize(11).fontColor('#999999').margin({ left: 4 });
        }
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor('#F0F0F0').borderRadius(8).margin({ top: 8 });
      }

      // 分隔线
      Divider().height(0.5).color('#F0F0F0').margin({ top: 12, bottom: 10 });

      // 互动栏
      Row() {
        Row() {
          Text(post.isLiked ? '❤️' : '🤍').fontSize(16);
          Text(`${post.likeCount}`).fontSize(12).fontColor('#999999').margin({ left: 4 });
        }.margin({ right: 24 });
        Row() {
          Text('💬').fontSize(16);
          Text(`${post.commentCount}`).fontSize(12).fontColor('#999999').margin({ left: 4 });
        };
        Row() {
          Text('↗️').fontSize(16);
          Text('分享').fontSize(12).fontColor('#999999').margin({ left: 4 });
        }.margin({ left: 24 });
      }
    }
  });
}

深度解析

社区帖子是最复杂的列表项结构,一个帖子卡片包含5个区域:

  1. 用户信息行:头像 Emoji(32号)+ 用户名(14号加粗)+ 发布时间(11号浅灰)+ 更多操作按钮(···)

  2. 宠物标签:宠物 Emoji(36号)+ 宠物名,浅灰背景的圆角标签,作为帖子与特定宠物的关联标识。backgroundColor('#F8F8F8') 极浅灰色配合10号圆角,形成一个低调的标签样式。

  3. 正文内容:14号灰色文字,lineHeight(22) 设置了1.57倍的行高(22/14),提升了长文本的阅读舒适度。maxLines(3) 限制最多显示3行,超出部分显示省略号。这是信息流应用的标配设计——限制单帖占用空间,鼓励用户点击"展开全文"。

  4. 图片占位区:条件渲染 if (post.imageCount > 0),只有当帖子含图片时才显示。在真实项目中,这里通常是 Image 组件的网格展示(如1图大图、2图左右、3图以上九宫格),但当前用简化的文字占位替代。

  5. 互动栏:点赞(❤️/🤍 根据是否已点赞切换)、评论数、分享三个按钮水平排列。点赞按钮通过 post.isLiked ? '❤️' : '🤍' 实现了"已赞/未赞"的图标切换,红色爱心和白色爱心形成了鲜明的视觉对比。.margin({ right: 24 }).margin({ left: 24 }) 在三个按钮之间创建了等宽间距。

整个帖子是一个白色圆角卡片,卡片之间通过外层 margin({ top: 8 }) 形成统一间距。


十四、Tab5 我的:ProfileTabContent 及子构建器

14.1 个人中心标题栏

@Builder
ProfileHeader() {
  Column() {
    Row() {
      Text('🧑').fontSize(48);
      Column() {
        Text('宠物家长')
          .fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
        Text('已陪伴豆豆 1235天')
          .fontSize(13).fontColor('#FFFFFFCC').margin({ top: 4 });
      }
      .layoutWeight(1).margin({ left: 14 });
      Text('✏️').fontSize(20).fontColor('#FFFFFF');
    }

    Row() {
      Column() {
        Text('5').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
        Text('宠物').fontSize(11).fontColor('#FFFFFFCC');
      }.layoutWeight(1);

      Divider().vertical(true).height(36).color('#FFFFFF30');

      Column() {
        Text('15').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
        Text('健康记录').fontSize(11).fontColor('#FFFFFFCC');
      }.layoutWeight(1);

      Divider().vertical(true).height(36).color('#FFFFFF30');

      Column() {
        Text('¥8,130').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
        Text('年度支出').fontSize(11).fontColor('#FFFFFFCC');
      }.layoutWeight(1);
    }
    .width('100%').margin({ top: 20 });
  }
  .linearGradient({
    direction: GradientDirection.Bottom,
    colors: [['#3D348B', 0], ['#7678ED', 1]]
  });
}

深度解析

个人中心的标题栏是所有 Tab 页中最复杂的,采用 Column 垂直布局(而非其他页面的 Row),分为上下两个区域:

上方用户信息区:48号头像 + 用户名"宠物家长"(20号加粗)+ 陪伴天数(13号半透明白色)+ 编辑按钮(✏️)。陪伴天数"已陪伴豆豆 1235天"是一个极具情感化的文案,将抽象的数据转化为有温度的表达。

下方数据统计区:三栏等宽布局,展示宠物数量(5)、健康记录数(15)、年度支出(¥8,130)。数值使用22号加粗白色,标签使用11号半透明白色,与用户信息区形成对比。栏与栏之间用白色半透明竖线分隔(#FFFFFF30),在深色渐变背景上形成精致的分割效果。

标题栏渐变从深紫(#3D348B)到亮紫(#7678ED),与"我的"页面的个性化、神秘感主题色呼应。与其他页面一样,底部设置了24号圆角。

14.2 ProfilePetList – 宠物列表管理

@Builder
ProfilePetList() {
  ForEach(MOCK_PETS, (pet: PetInfo) => {
    Row() {
      Text(pet.avatar).fontSize(36);
      Column() {
        Text(pet.name).fontSize(15).fontWeight(FontWeight.Medium);
        Text(`${pet.breed} · ${pet.age}岁 · ${pet.weight}kg`)
          .fontSize(11).fontColor('#999999').margin({ top: 2 });
      }
      .layoutWeight(1).margin({ left: 12 });

      Row() {
        Text(getGenderLabel(pet.gender))
          .fontSize(14).fontColor(pet.gender === '公' ? '#7678ED' : '#E85D75');
        Text(pet.vaccineStatus === '已接种' ? '🟢' : '🟡')
          .fontSize(10).margin({ left: 8 });
      }
    }
    .onClick(() => { this.openEditPet(pet); });
  });
}

深度解析

宠物列表管理是"我的"页面的核心功能入口。每行显示宠物头像、名字、品种/年龄/体重组合信息、性别符号和疫苗状态指示灯。点击整行触发 openEditPet(pet),打开编辑宠物信息弹框。

性别符号使用三元表达式动态着色——公为紫色(#7678ED),母为粉色(#E85D75),实现了性别色彩的直觉传达。疫苗状态用绿色圆点(已接种)或黄色圆点(接种中/未接种)直观标识。

14.3 MonthlyExpenseSection – 月度支出图表

@Builder
MonthlyExpenseSection() {
  Row() {
    ForEach(MOCK_EXPENSES, (expense: MonthlyExpense) => {
      ExpenseBarView({
        label: expense.month,
        value: expense.total,
        maxValue: 1600,
        barColor: '#FF6B35'
      });
    })
  }
  .width('100%').height(150)
  .justifyContent(FlexAlign.SpaceAround);

  Row() {
    Row() {
      Circle({ width: 8, height: 8 }).fill('#FF6B35');
      Text('食品').fontSize(11).fontColor('#666666').margin({ left: 4 });
    }.margin({ right: 10 });
    // ... 医疗、美容、用品、其他
  }
  .width('100%').margin({ top: 12 });
}

深度解析

月度支出区域复用了 ExpenseBarView 子组件,通过 ForEach 遍历7个月的支出数据,生成7个柱状图。容器 Row 使用 justifyContent(FlexAlign.SpaceAround) 让柱状图均匀分布。maxValue: 1600 设为略高于最高月支出(1480)的值,确保柱体不会到达100%高度。

底部图例使用 Circle 组件(8号直径实心圆)配合文字标签,展示了5个支出类别的颜色标识。然而,当前所有柱体都使用同一颜色(#FF6B35),图例中的多色标识实际上并未在图表中体现。这是一个视觉设计与数据渲染不完全匹配的地方——在完整实现中,可以为每月支出堆叠5个分类柱体,或者使用5种颜色的分段柱状图。

14.4 SettingsSection – 设置列表

@Builder
SettingsSection() {
  ForEach(Object.values(SETTINGS_LIST), (setting: SettingItem) => {
    Row() {
      Text(setting.icon).fontSize(22);
      Column() {
        Text(setting.title).fontSize(14).fontColor('#333333');
        Text(setting.subTitle).fontSize(11).fontColor('#BBBBBB').margin({ top: 2 });
      }
      .layoutWeight(1).margin({ left: 12 });

      if (setting.hasArrow) {
        Text('›').fontSize(20).fontColor('#CCCCCC');
      }
    }
    .padding({ top: 12, bottom: 12 })
    .border({ width: 0.5, color: '#F5F5F5' });
  });

  Row() {
    Text('退出登录').fontSize(15).fontColor('#EF476F');
  }
  .height(48).justifyContent(FlexAlign.Center).margin({ top: 16 });
}

深度解析

设置列表是一个经典的 iOS 风格设置页面。每个设置项使用统一的行布局:左侧 Emoji 图标 + 中间标题和副标题 + 右侧箭头( 字符)。

Object.values(SETTINGS_LIST) 遍历字典值生成列表。hasArrow 布尔值通过 if 条件渲染控制箭头的显示——所有当前设置项都有箭头,但这个条件渲染机制为未来的扩展预留了空间(例如"当前版本"可以没有箭头,因为它不需要跳转)。

底部的"退出登录"按钮独立于设置列表之外,使用红色文字(#EF476F)作为危险操作的视觉警示。48高度居中显示,与其他设置项的行高形成区分。


十五、底部 Tab 导航栏

@Builder
BottomTabBar() {
  Row() {
    ForEach(
      [MainTab.HOME, MainTab.HEALTH, MainTab.SCHEDULE, MainTab.COMMUNITY, MainTab.PROFILE],
      (tab: number, index: number) => {
        Column() {
          Text(this.tabIcons[index]).fontSize(22);
          Text(this.tabTitles[index])
            .fontSize(10)
            .fontColor(this.currentTab === tab ? this.tabActiveColors[index] : '#999999')
            .fontWeight(this.currentTab === tab ? FontWeight.Bold : FontWeight.Normal)
            .margin({ top: 2 });
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 4 })
        .onClick(() => { this.currentTab = tab; });
      }
    )
  }
  .width('100%')
  .height(56)
  .backgroundColor('#FFFFFF')
  .padding({ bottom: 8 })
  .shadow({ radius: 16, color: '#00000010', offsetX: 0, offsetY: -4 });
}

深度解析

底部导航栏是整个应用的全局导航组件,使用 Row + ForEach 实现5个等宽 Tab 按钮。每个 Tab 按钮包含图标和文字两行,垂直居中排列。

激活/非激活状态切换:文字颜色和字重根据 this.currentTab === tab 动态切换——激活时使用对应 Tab 的专属颜色(tabActiveColors[index])并加粗,非激活时为灰色(#999999)常规字重。值得注意的是,图标始终保持不变(Emoji 不支持颜色变化),只有文字部分有状态变化。

点击事件onClick(() => { this.currentTab = tab; }) 直接修改 @State currentTab,触发 TabContentBuilder 的条件重新判断,实现页面切换。

阴影设计.shadow({ radius: 16, color: '#00000010', offsetY: -4 }) 使用了向上的阴影偏移(-4),在 Tab 栏顶部创建了一条柔和的"投影线",将 Tab 栏与上方内容区域视觉分离。这种"上方投影"的技巧比底部分割线更加自然。

高度设计:总高度56,其中 padding({ bottom: 8 }) 为底部安全区域预留空间(避免被系统导航手势遮挡),实际内容高度为48。


十六、辅助方法

private getHealthIcon(type: string): string {
  switch (type) {
    case '体检': return '🩺';
    case '疫苗': return '💉';
    case '驱虫': return '💊';
    case '牙科': return '🦷';
    case '皮肤科': return '🩹';
    case '眼科': return '👁️';
    case '关节检查': return '🦴';
    case '血液检查': return '🩸';
    default: return '📋';
  }
}

private getHealthBgColor(type: string): string {
  switch (type) {
    case '体检': return '#FFF0E8';
    case '疫苗': return '#E8F8FF';
    // ... 其他类型
    default: return '#F5F5F5';
  }
}

private selectPet(index: number): void {
  this.selectedPetIndex = index;
}

private openAddReminder(): void { /* 重置表单并显示弹框 */ }
private closeAddReminder(): void { this.showAddReminder = false; }
private confirmAddReminder(): void { this.showAddReminder = false; }
private cancelDelete(): void { this.showDeleteConfirm = false; }
private confirmDelete(): void { this.showDeleteConfirm = false; }
private openEditPet(pet: PetInfo): void { /* 填充表单数据并显示弹框 */ }
private closeEditPet(): void { this.showEditPet = false; }
private saveEditPet(): void { this.showEditPet = false; }

深度解析

辅助方法可以分为两类:

数据映射方法getHealthIcongetHealthBgColor):使用 switch-case 实现类型到视觉属性的映射。default 分支提供了兜底值(通用图标和灰色背景),确保遇到未知类型时不会崩溃。这种映射方法将分散的 UI 属性集中管理,修改某种类型的图标或颜色只需改一处。

弹框控制方法:每对弹框都有"打开/关闭/确认"三个方法。openAddReminder 在显示弹框前会重置表单数据(type 回到 ‘feed’,清空时间和备注),确保每次打开都是空白初始状态。openEditPet(pet) 则相反——它会预填充当前宠物的数据(名字、品种、体重),让用户在现有数据基础上修改。

值得注意的是,confirmAddReminderconfirmDeletesaveEditPet 目前的实现仅仅是关闭弹框,没有真正执行数据操作(如向数组添加记录、删除记录、更新宠物信息)。这是 Mock 数据演示的典型特征——UI 交互完整,但数据层是静态的。


十七、模块关键技术点总结对比

维度 首页 (Tab0) 健康 (Tab1) 日程 (Tab2) 社区 (Tab3) 我的 (Tab4)
模块名称 首页总览 健康管理 日程安排 宠物社区 个人中心
核心功能 宠物切换、今日概览、待办列表、快捷操作、健康摘要 健康记录列表、体重趋势追踪、疫苗记录、驱虫记录 全部日程列表、分类筛选栏、新增提醒弹框 帖子信息流、点赞评论分享互动 宠物管理编辑、月度支出图表、设置列表
渐变色主题 橙色 #FF6B35 -> #F18701 绿色 #06D6A0 -> #118AB2 紫色 #7678ED -> #3D348B 粉色 #E85D75 -> #EF476F 深紫 #3D348B -> #7678ED
状态管理要点 selectedPetIndex 驱动全局宠物数据联动 无独立状态,依赖静态 Mock 数据 showAddReminder + 3个表单字段控制弹框 无独立状态,帖子数据为只读 showEditPet + 3个表单字段 + editTargetPet 控制弹框
关键组件 Scroll + Grid(4列) + 水平滚动宠物卡片 WeightDotView(自定义) + VaccineBadgeView(自定义) + 水平滚动趋势线 筛选栏药丸按钮 + 独立卡片式日程项 + 弹框交互 帖子卡片(5层结构) + 条件图片区 + 点赞状态切换 ExpenseBarView(自定义) + 设置列表行 + 统计三栏
布局模式 垂直滚动 Column 内嵌多个卡片 Section 垂直滚动 Column + 嵌套水平 Scroll 垂直滚动列表 + 顶部固定筛选栏 垂直滚动信息流 垂直滚动 Column + 柱状图 + 设置列表
数据源 MOCK_PETS + MOCK_SCHEDULES(截取前8) MOCK_HEALTH_RECORDS + MOCK_WEIGHTS + MOCK_VACCINES + MOCK_DEWORM_RECORDS MOCK_SCHEDULES(全部15条) MOCK_POSTS(15条) MOCK_PETS + MOCK_EXPENSES + SETTINGS_LIST
设计模式 数据驱动选中态 + 联动刷新 子组件封装复用 + 类型到视觉属性映射 回调函数式弹框通信 + 条件渲染筛选态 社交卡片复合结构 + 状态图标条件切换 配置驱动列表生成 + 图表组件化
交互特性 宠物切换点击 + 快捷操作点击(1项有效) 无直接交互(纯展示) 新增提醒弹框(完整表单交互) 帖子展示(无点赞/评论交互) 编辑宠物弹框(完整表单交互)
子 Builder 数量 7个 (HomeHeader, PetSwitchCards, TodaySummaryBar, TodayReminderSection, QuickActionGrid, HealthOverviewCard, + HomeTabContent) 6个 (HealthHeader, HealthRecordList, WeightTrendSection, VaccineRecordSection, DewormRecordSection, + HealthTabContent) 3个 (ScheduleHeader, ScheduleFilterBar, ScheduleList, + ScheduleTabContent) 2个 (CommunityHeader, CommunityPostList, + CommunityTabContent) 5个 (ProfileHeader, ProfilePetList, MonthlyExpenseSection, SettingsSection, + ProfileTabContent)

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

/* 宠物生活管家App - 暖橙黄系彩色卡片风格 */

// ========== 接口定义 (12个) ==========
interface PetInfo { id: number; name: string; breed: string; age: number; weight: number; gender: string; avatar: string; vaccineStatus: string; isSterilized: boolean; birthday: string; color: string; favorite: string; microchipId: string; }
interface HealthRecord { id: number; petId: number; date: string; type: string; description: string; hospital: string; doctor: string; cost: number; result: string; nextDate: string; }
interface ScheduleReminder { id: number; petId: number; petName: string; type: string; title: string; time: string; date: string; isCompleted: boolean; isRepeat: boolean; note: string; icon: string; color: string; }
interface CommunityPost { id: number; userName: string; userAvatar: string; petName: string; petEmoji: string; content: string; imageCount: number; likeCount: number; commentCount: number; time: string; isLiked: boolean; }
interface PetSupply { id: number; name: string; category: string; price: number; brand: string; rating: number; image: string; stockStatus: string; purchaseCount: number; }
interface MonthlyExpense { month: string; food: number; medical: number; grooming: number; supplies: number; other: number; total: number; }
interface VaccineRecord { id: number; petId: number; vaccineName: string; date: string; nextDate: string; hospital: string; batchNumber: string; isCompleted: boolean; }
interface WeightRecord { id: number; petId: number; weight: number; date: string; note: string; }
interface DewormRecord { id: number; petId: number; type: string; medicine: string; date: string; nextDate: string; isInternal: boolean; }
interface QuickAction { id: number; title: string; icon: string; bgColor: string; iconColor: string; }
interface SettingItem { id: number; title: string; icon: string; subTitle: string; hasArrow: boolean; }
interface ReminderTypeOption { label: string; icon: string; color: string; bgColor: string; }

// ========== @Observed 数据模型 ==========
@Observed
class PetModel {
  id: number; name: string; breed: string; age: number;
  weight: number; gender: string; avatar: string; vaccineStatus: string;
  isSterilized: boolean; birthday: string; color: string; favorite: string; microchipId: string;
  constructor(info: PetInfo) {
    this.id = info.id; this.name = info.name; this.breed = info.breed;
    this.age = info.age; this.weight = info.weight; this.gender = info.gender;
    this.avatar = info.avatar; this.vaccineStatus = info.vaccineStatus;
    this.isSterilized = info.isSterilized; this.birthday = info.birthday;
    this.color = info.color; this.favorite = info.favorite; this.microchipId = info.microchipId;
  }
}

@Observed
class ScheduleModel {
  id: number; petId: number; petName: string; type: string; title: string;
  time: string; date: string; isCompleted: boolean; isRepeat: boolean; note: string; icon: string; color: string;
  constructor(item: ScheduleReminder) {
    this.id = item.id; this.petId = item.petId; this.petName = item.petName;
    this.type = item.type; this.title = item.title; this.time = item.time;
    this.date = item.date; this.isCompleted = item.isCompleted; this.isRepeat = item.isRepeat;
    this.note = item.note; this.icon = item.icon; this.color = item.color;
  }
}

// ========== 配置对象 ==========
const REMINDER_TYPES: Record<string, ReminderTypeOption> = {
  'feed': { label: '喂食', icon: '🍖', color: '#FF6B35', bgColor: '#FFF0E8' },
  'walk': { label: '遛弯', icon: '🐕', color: '#F7B801', bgColor: '#FFFBE8' },
  'bath': { label: '洗澡', icon: '🛁', color: '#7678ED', bgColor: '#F0F0FF' },
  'groom': { label: '美容', icon: '✂️', color: '#E85D75', bgColor: '#FFF0F3' },
  'vet': { label: '看医生', icon: '🏥', color: '#3D348B', bgColor: '#F0EEFF' },
  'deworm': { label: '驱虫', icon: '💊', color: '#06D6A0', bgColor: '#E8FFF8' },
  'vaccine': { label: '疫苗', icon: '💉', color: '#118AB2', bgColor: '#E8F8FF' },
  'nail': { label: '剪指甲', icon: '✋', color: '#EF476F', bgColor: '#FFF0F5' },
  'play': { label: '玩耍', icon: '🎾', color: '#FFD166', bgColor: '#FFFDF0' },
  'other': { label: '其他', icon: '📋', color: '#8D99AE', bgColor: '#F5F6FA' }
};

const QUICK_ACTIONS: Record<string, QuickAction> = {
  'feed_log': { id: 1, title: '喂食记录', icon: '🍖', bgColor: '#FFF0E8', iconColor: '#FF6B35' },
  'walk_log': { id: 2, title: '遛弯打卡', icon: '🐕', bgColor: '#FFFBE8', iconColor: '#F7B801' },
  'health_check': { id: 3, title: '健康检查', icon: '💚', bgColor: '#E8FFF8', iconColor: '#06D6A0' },
  'reminder_add': { id: 4, title: '添加提醒', icon: '⏰', bgColor: '#F0F0FF', iconColor: '#7678ED' },
  'pet_diary': { id: 5, title: '宠物日记', icon: '📔', bgColor: '#FFF0F3', iconColor: '#E85D75' },
  'vet_search': { id: 6, title: '找医院', icon: '🏥', bgColor: '#F0EEFF', iconColor: '#3D348B' },
  'supply_mall': { id: 7, title: '用品商城', icon: '🛒', bgColor: '#E8F8FF', iconColor: '#118AB2' },
  'pet_social': { id: 8, title: '宠友圈', icon: '💬', bgColor: '#FFFDF0', iconColor: '#FFD166' }
};

const SETTINGS_LIST: Record<string, SettingItem> = {
  'family': { id: 1, title: '家庭管理', icon: '👨‍👩‍👧‍👦', subTitle: '邀请家人共同照顾宠物', hasArrow: true },
  'device': { id: 2, title: '智能设备', icon: '📱', subTitle: '已连接2台设备', hasArrow: true },
  'notify': { id: 3, title: '通知设置', icon: '🔔', subTitle: '声音和震动', hasArrow: true },
  'subscription': { id: 4, title: '会员中心', icon: '👑', subTitle: '查看会员权益', hasArrow: true },
  'help': { id: 5, title: '帮助与反馈', icon: '❓', subTitle: '在线客服 7x24', hasArrow: true },
  'about': { id: 6, title: '关于我们', icon: 'ℹ️', subTitle: 'v3.2.1', hasArrow: true }
};

// ========== Mock 数据数组 ==========

const MOCK_PETS: PetInfo[] = [
  { id: 1, name: '豆豆', breed: '金毛寻回犬', age: 3, weight: 28.5, gender: '公', avatar: '🐶',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2023-03-15', color: '#FF6B35', favorite: '网球', microchipId: 'CHIP-001' },
  { id: 2, name: '咪咪', breed: '英国短毛猫', age: 2, weight: 4.8, gender: '母', avatar: '🐱',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2024-06-20', color: '#7678ED', favorite: '逗猫棒', microchipId: 'CHIP-002' },
  { id: 3, name: '球球', breed: '柯基犬', age: 1, weight: 11.2, gender: '公', avatar: '🐕',
    vaccineStatus: '接种中', isSterilized: false, birthday: '2025-01-10', color: '#F7B801', favorite: '飞盘', microchipId: 'CHIP-003' },
  { id: 4, name: '雪球', breed: '布偶猫', age: 4, weight: 5.6, gender: '母', avatar: '😺',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2022-09-08', color: '#E85D75', favorite: '激光笔', microchipId: 'CHIP-004' },
  { id: 5, name: '大壮', breed: '拉布拉多', age: 5, weight: 32.0, gender: '公', avatar: '🐩',
    vaccineStatus: '已接种', isSterilized: true, birthday: '2021-05-22', color: '#06D6A0', favorite: '游泳', microchipId: 'CHIP-005' }
];

const MOCK_HEALTH_RECORDS: HealthRecord[] = [
  { id: 1, petId: 1, date: '2026-07-20', type: '体检', description: '年度全面体检,各项指标正常',
    hospital: '宠爱国际动物医院', doctor: '王医生', cost: 580, result: '健康', nextDate: '2027-07-20' },
  { id: 2, petId: 1, date: '2026-06-15', type: '驱虫', description: '体内外驱虫,使用拜耳驱虫药',
    hospital: '瑞鹏宠物医院', doctor: '李医生', cost: 120, result: '正常', nextDate: '2026-09-15' },
  { id: 3, petId: 2, date: '2026-07-18', type: '疫苗', description: '猫三联加强针接种',
    hospital: '宠爱国际动物医院', doctor: '张医生', cost: 320, result: '已接种', nextDate: '2027-07-18' },
  { id: 4, petId: 2, date: '2026-05-28', type: '体检', description: '半年度体检,牙齿有轻微牙结石',
    hospital: '美联众合宠物医院', doctor: '赵医生', cost: 450, result: '需注意口腔', nextDate: '2026-11-28' },
  { id: 5, petId: 3, date: '2026-07-10', type: '疫苗', description: '狂犬疫苗接种',
    hospital: '瑞鹏宠物医院', doctor: '李医生', cost: 80, result: '已接种', nextDate: '2027-07-10' },
  { id: 6, petId: 3, date: '2026-06-22', type: '体检', description: '成长发育检查,骨骼发育良好',
    hospital: '宠爱国际动物医院', doctor: '王医生', cost: 350, result: '发育正常', nextDate: '2026-12-22' },
  { id: 7, petId: 4, date: '2026-07-05', type: '牙科', description: '洁牙护理,清除牙结石',
    hospital: '美联众合宠物医院', doctor: '赵医生', cost: 680, result: '牙齿清洁完成', nextDate: '2027-01-05' },
  { id: 8, petId: 4, date: '2026-05-12', type: '体检', description: '年度体检,肝肾功能正常',
    hospital: '宠爱国际动物医院', doctor: '张医生', cost: 550, result: '健康', nextDate: '2027-05-12' },
  { id: 9, petId: 5, date: '2026-07-15', type: '关节检查', description: '髋关节评估,轻度关节磨损',
    hospital: '瑞鹏宠物医院', doctor: '李医生', cost: 420, result: '需补充关节营养素', nextDate: '2026-10-15' },
  { id: 10, petId: 5, date: '2026-06-08', type: '血液检查', description: '血常规及生化检查,指标均正常',
    hospital: '宠爱国际动物医院', doctor: '王医生', cost: 380, result: '正常', nextDate: '2027-06-08' },
  { id: 11, petId: 1, date: '2026-04-20', type: '皮肤科', description: '耳道感染治疗,使用耳肤灵',
    hospital: '美联众合宠物医院', doctor: '赵医生', cost: 260, result: '已痊愈', nextDate: '' },
  { id: 12, petId: 2, date: '2026-03-15', type: '驱虫', description: '体内驱虫,使用海乐妙',
    hospital: '瑞鹏宠物医院', doctor: '李医生', cost: 100, result: '正常', nextDate: '2026-06-15' },
  { id: 13, petId: 3, date: '2026-02-28', type: '疫苗', description: '犬六联疫苗接种',
    hospital: '宠爱国际动物医院', doctor: '王医生', cost: 280, result: '已接种', nextDate: '2027-02-28' },
  { id: 14, petId: 4, date: '2026-01-10', type: '眼科', description: '泪痕检查,泪腺功能正常',
    hospital: '美联众合宠物医院', doctor: '张医生', cost: 200, result: '正常', nextDate: '' },
  { id: 15, petId: 5, date: '2025-12-05', type: '体检', description: '年度体检,心脏听诊正常',
    hospital: '瑞鹏宠物医院', doctor: '李医生', cost: 520, result: '健康', nextDate: '2026-12-05' }
];

const MOCK_SCHEDULES: ScheduleReminder[] = [
  { id: 1, petId: 1, petName: '豆豆', type: 'feed', title: '喂食狗粮', time: '07:30', date: '2026-07-25',
    isCompleted: true, isRepeat: true, note: '皇家成犬粮200g', icon: '🍖', color: '#FF6B35' },
  { id: 2, petId: 2, petName: '咪咪', type: 'feed', title: '喂食猫粮', time: '08:00', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '渴望六种鱼50g', icon: '🍖', color: '#FF6B35' },
  { id: 3, petId: 1, petName: '豆豆', type: 'walk', title: '晨间遛弯', time: '08:30', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '公园30分钟', icon: '🐕', color: '#F7B801' },
  { id: 4, petId: 3, petName: '球球', type: 'feed', title: '喂食幼犬粮', time: '08:30', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '皇家幼犬粮150g', icon: '🍖', color: '#FF6B35' },
  { id: 5, petId: 5, petName: '大壮', type: 'feed', title: '喂食成犬粮', time: '08:00', date: '2026-07-25',
    isCompleted: true, isRepeat: true, note: '冠能大型犬粮350g', icon: '🍖', color: '#FF6B35' },
  { id: 6, petId: 2, petName: '咪咪', type: 'play', title: '互动玩耍', time: '10:00', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '逗猫棒15分钟', icon: '🎾', color: '#FFD166' },
  { id: 7, petId: 1, petName: '豆豆', type: 'feed', title: '午餐喂食', time: '12:30', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '皇家成犬粮150g+鸡胸肉', icon: '🍖', color: '#FF6B35' },
  { id: 8, petId: 4, petName: '雪球', type: 'feed', title: '喂食猫粮', time: '12:30', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '渴望六种鱼40g', icon: '🍖', color: '#FF6B35' },
  { id: 9, petId: 3, petName: '球球', type: 'bath', title: '洗澡护理', time: '14:00', date: '2026-07-25',
    isCompleted: false, isRepeat: false, note: '使用宠物专用香波', icon: '🛁', color: '#7678ED' },
  { id: 10, petId: 5, petName: '大壮', type: 'walk', title: '下午遛弯', time: '16:30', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '江边散步1小时', icon: '🐕', color: '#F7B801' },
  { id: 11, petId: 1, petName: '豆豆', type: 'feed', title: '晚餐喂食', time: '18:30', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '皇家成犬粮200g', icon: '🍖', color: '#FF6B35' },
  { id: 12, petId: 2, petName: '咪咪', type: 'groom', title: '梳毛护理', time: '19:00', date: '2026-07-25',
    isCompleted: false, isRepeat: true, note: '针梳梳理10分钟', icon: '✂️', color: '#E85D75' },
  { id: 13, petId: 4, petName: '雪球', type: 'deworm', title: '体外驱虫', time: '20:00', date: '2026-07-26',
    isCompleted: false, isRepeat: false, note: '大宠爱滴剂', icon: '💊', color: '#06D6A0' },
  { id: 14, petId: 3, petName: '球球', type: 'vaccine', title: '犬用疫苗加强针', time: '09:30', date: '2026-07-28',
    isCompleted: false, isRepeat: false, note: '瑞鹏宠物医院预约', icon: '💉', color: '#118AB2' },
  { id: 15, petId: 5, petName: '大壮', type: 'vet', title: '关节复查', time: '10:00', date: '2026-07-30',
    isCompleted: false, isRepeat: false, note: '宠爱国际动物医院', icon: '🏥', color: '#3D348B' }
];

const MOCK_POSTS: CommunityPost[] = [
  { id: 1, userName: '铲屎官小李', userAvatar: '👨', petName: 'lucky', petEmoji: '🐶',
    content: '今天带我家lucky去公园玩,遇到好多小伙伴,跑得可开心了!回家直接累趴🐾', imageCount: 3,
    likeCount: 128, commentCount: 23, time: '5分钟前', isLiked: false },
  { id: 2, userName: '喵星人控', userAvatar: '👩', petName: '团子', petEmoji: '😸',
    content: '团子今天第一次学会用猫砂盆,老母亲甚是欣慰啊!记录一下这历史性的一刻🎉', imageCount: 1,
    likeCount: 256, commentCount: 45, time: '18分钟前', isLiked: true },
  { id: 3, userName: '金毛爱好者', userAvatar: '🧑', petName: '坦克', petEmoji: '🐕',
    content: '金毛真的是治愈系第一名!每天回家看到坦克摇着尾巴迎接我,一天的疲惫都没了💛', imageCount: 2,
    likeCount: 389, commentCount: 67, time: '32分钟前', isLiked: false },
  { id: 4, userName: '布偶猫舍', userAvatar: '👩‍🦰', petName: '棉花糖', petEmoji: '🐱',
    content: '棉花糖的新窝到了,颜值超高!各位猫奴可以入手,链接在评论区👇', imageCount: 4,
    likeCount: 512, commentCount: 89, time: '1小时前', isLiked: false },
  { id: 5, userName: '宠物医生小王', userAvatar: '👨‍⚕️', petName: '招财', petEmoji: '🐈',
    content: '科普时间:夏季宠物防暑降温小技巧,千万不要把宠物单独留在车内!🚗❌', imageCount: 5,
    likeCount: 856, commentCount: 132, time: '2小时前', isLiked: true },
  { id: 6, userName: '柯基小短腿', userAvatar: '👩‍🦱', petName: '火腿', petEmoji: '🦊',
    content: '火腿的蜜桃臀越来越翘了哈哈,每天遛弯回头率超高!柯基就是可爱天花板🏆', imageCount: 2,
    likeCount: 445, commentCount: 78, time: '3小时前', isLiked: false },
  { id: 7, userName: '二哈饲养员', userAvatar: '👨‍🦱', petName: '拆迁', petEmoji: '🐺',
    content: '今天回家发现沙发又被拆了...哈士奇的拆家能力真的不是盖的,求推荐耐咬玩具🙏', imageCount: 1,
    likeCount: 667, commentCount: 203, time: '3小时前', isLiked: false },
  { id: 8, userName: '英短爱好者', userAvatar: '👩‍🦳', petName: '煤球', petEmoji: '🐈‍⬛',
    content: '煤球的日常:吃、睡、面无表情地看着我...这样的猫生我也想要😴', imageCount: 3,
    likeCount: 234, commentCount: 41, time: '4小时前', isLiked: false },
  { id: 9, userName: '拉布拉多妈', userAvatar: '👩', petName: '豆包', petEmoji: '🐕‍🦺',
    content: '豆包今天去游泳了!拉布拉多果然是水犬,一下水就不肯上来,玩了一个多小时🏊', imageCount: 4,
    likeCount: 321, commentCount: 56, time: '5小时前', isLiked: true },
  { id: 10, userName: '宠物训练师', userAvatar: '🧔', petName: '子弹', petEmoji: '🐕',
    content: '分享一个狗狗拒食训练的方法,对防止狗狗在外面乱吃东西非常有效!专业训练技巧📚', imageCount: 6,
    likeCount: 920, commentCount: 178, time: '6小时前', isLiked: false },
  { id: 11, userName: '猫奴日常', userAvatar: '👩‍🦲', petName: '奶茶', petEmoji: '😻',
    content: '奶茶今天居然主动来蹭我了!养了两年终于...老奴感动落泪😭养猫人的快乐就是这么简单', imageCount: 1,
    likeCount: 543, commentCount: 95, time: '7小时前', isLiked: false },
  { id: 12, userName: '边牧爸爸', userAvatar: '👨‍🦰', petName: '学霸', petEmoji: '🐑',
    content: '学霸今天学会了开关灯,边牧的智商真的碾压其他狗狗!视频在评论区💡', imageCount: 1,
    likeCount: 789, commentCount: 145, time: '8小时前', isLiked: true },
  { id: 13, userName: '流浪猫救助', userAvatar: '👩‍⚕️', petName: '小橘', petEmoji: '🐱',
    content: '救助的小橘猫今天拆线了,恢复得非常好!感谢大家的关心和捐助,小橘找到领养人了🎊', imageCount: 5,
    likeCount: 1024, commentCount: 234, time: '9小时前', isLiked: false },
  { id: 14, userName: '柴犬控', userAvatar: '👨', petName: '小柴', petEmoji: '🐶',
    content: '小柴今天学会握手了!虽然只成功了三次但已经很棒了!柴犬的倔强也是没谁了😂', imageCount: 2,
    likeCount: 356, commentCount: 62, time: '10小时前', isLiked: false },
  { id: 15, userName: '宠物营养师', userAvatar: '👩‍🍳', petName: '胖虎', petEmoji: '🐱',
    content: '宠物减肥食谱分享!胖虎三个月成功减重2kg,健康饮食+运动是关键🥗', imageCount: 4,
    likeCount: 645, commentCount: 112, time: '11小时前', isLiked: false }
];

const MOCK_SUPPLIES: PetSupply[] = [
  { id: 1, name: '皇家成犬粮', category: '主粮', price: 258, brand: '皇家', rating: 4.8, image: '🦴', stockStatus: '有货', purchaseCount: 12560 },
  { id: 2, name: '渴望六种鱼猫粮', category: '主粮', price: 368, brand: '渴望', rating: 4.9, image: '🐟', stockStatus: '有货', purchaseCount: 8920 },
  { id: 3, name: '大宠爱驱虫滴剂', category: '药品', price: 89, brand: '硕腾', rating: 4.7, image: '💧', stockStatus: '有货', purchaseCount: 23400 },
  { id: 4, name: '宠物智能饮水机', category: '用品', price: 199, brand: '小佩', rating: 4.6, image: '⛲', stockStatus: '有货', purchaseCount: 15600 },
  { id: 5, name: '耐磨飞盘玩具', category: '玩具', price: 35, brand: 'Chuckit', rating: 4.8, image: '🥏', stockStatus: '有货', purchaseCount: 18900 },
  { id: 6, name: '猫抓板沙发', category: '玩具', price: 68, brand: '田田猫', rating: 4.5, image: '📦', stockStatus: '有货', purchaseCount: 32400 },
  { id: 7, name: '宠物营养膏', category: '保健品', price: 128, brand: '卫仕', rating: 4.7, image: '🧴', stockStatus: '有货', purchaseCount: 11200 },
  { id: 8, name: '折叠宠物推车', category: '出行', price: 399, brand: 'ibiyaya', rating: 4.4, image: '🛒', stockStatus: '有货', purchaseCount: 5600 },
  { id: 9, name: '宠物专用沐浴露', category: '洗护', price: 79, brand: '雪貂留香', rating: 4.6, image: '🧴', stockStatus: '有货', purchaseCount: 28900 },
  { id: 10, name: '柔软宠物窝垫', category: '居住', price: 158, brand: '嬉皮狗', rating: 4.5, image: '🛏️', stockStatus: '有货', purchaseCount: 19600 }
];

const MOCK_EXPENSES: MonthlyExpense[] = [
  { month: '1月', food: 480, medical: 320, grooming: 0, supplies: 150, other: 60, total: 1010 },
  { month: '2月', food: 520, medical: 0, grooming: 200, supplies: 80, other: 40, total: 840 },
  { month: '3月', food: 460, medical: 580, grooming: 0, supplies: 260, other: 100, total: 1400 },
  { month: '4月', food: 500, medical: 120, grooming: 150, supplies: 0, other: 50, total: 820 },
  { month: '5月', food: 530, medical: 450, grooming: 0, supplies: 350, other: 80, total: 1410 },
  { month: '6月', food: 490, medical: 380, grooming: 180, supplies: 0, other: 120, total: 1170 },
  { month: '7月', food: 510, medical: 260, grooming: 200, supplies: 420, other: 90, total: 1480 }
];

const MOCK_VACCINES: VaccineRecord[] = [
  { id: 1, petId: 1, vaccineName: '狂犬疫苗', date: '2026-03-15', nextDate: '2027-03-15',
    hospital: '宠爱国际动物医院', batchNumber: 'RV-001', isCompleted: true },
  { id: 2, petId: 1, vaccineName: '犬六联疫苗', date: '2026-01-20', nextDate: '2027-01-20',
    hospital: '瑞鹏宠物医院', batchNumber: 'DH-002', isCompleted: true },
  { id: 3, petId: 2, vaccineName: '猫三联疫苗', date: '2026-07-18', nextDate: '2027-07-18',
    hospital: '宠爱国际动物医院', batchNumber: 'FV-003', isCompleted: true },
  { id: 4, petId: 2, vaccineName: '狂犬疫苗', date: '2026-04-10', nextDate: '2027-04-10',
    hospital: '美联众合宠物医院', batchNumber: 'RV-004', isCompleted: true },
  { id: 5, petId: 3, vaccineName: '狂犬疫苗', date: '2026-07-10', nextDate: '2027-07-10',
    hospital: '瑞鹏宠物医院', batchNumber: 'RV-005', isCompleted: true },
  { id: 6, petId: 3, vaccineName: '犬六联疫苗', date: '2026-02-28', nextDate: '2027-02-28',
    hospital: '宠爱国际动物医院', batchNumber: 'DH-006', isCompleted: true },
  { id: 7, petId: 4, vaccineName: '猫三联疫苗', date: '2026-05-15', nextDate: '2027-05-15',
    hospital: '美联众合宠物医院', batchNumber: 'FV-007', isCompleted: true },
  { id: 8, petId: 5, vaccineName: '狂犬疫苗', date: '2026-06-20', nextDate: '2027-06-20',
    hospital: '宠爱国际动物医院', batchNumber: 'RV-008', isCompleted: true }
];

const MOCK_WEIGHTS: WeightRecord[] = [
  { id: 1, petId: 1, weight: 27.8, date: '2026-01-15', note: '正常范围' },
  { id: 2, petId: 1, weight: 28.0, date: '2026-02-15', note: '微增' },
  { id: 3, petId: 1, weight: 28.3, date: '2026-03-15', note: '正常' },
  { id: 4, petId: 1, weight: 28.1, date: '2026-04-15', note: '正常' },
  { id: 5, petId: 1, weight: 28.5, date: '2026-05-15', note: '正常' },
  { id: 6, petId: 1, weight: 28.4, date: '2026-06-15', note: '正常' },
  { id: 7, petId: 1, weight: 28.6, date: '2026-07-15', note: '正常' }
];

const MOCK_DEWORM_RECORDS: DewormRecord[] = [
  { id: 1, petId: 1, type: '体内驱虫', medicine: '拜耳', date: '2026-06-15', nextDate: '2026-09-15', isInternal: true },
  { id: 2, petId: 1, type: '体外驱虫', medicine: '大宠爱', date: '2026-07-01', nextDate: '2026-08-01', isInternal: false },
  { id: 3, petId: 2, type: '体内驱虫', medicine: '海乐妙', date: '2026-03-15', nextDate: '2026-09-15', isInternal: true },
  { id: 4, petId: 2, type: '体外驱虫', medicine: '大宠爱', date: '2026-06-20', nextDate: '2026-07-20', isInternal: false },
  { id: 5, petId: 3, type: '体内驱虫', medicine: '拜耳', date: '2026-05-10', nextDate: '2026-08-10', isInternal: true },
  { id: 6, petId: 4, type: '体外驱虫', medicine: '大宠爱', date: '2026-07-05', nextDate: '2026-08-05', isInternal: false }
];

// ========== 工具函数 ==========
function getGenderLabel(gender: string): string {
  return gender === '公' ? '♂️' : '♀️';
}
function getVaccineBadgeColor(status: string): string {
  return status === '已接种' ? '#06D6A0' : '#F7B801';
}

// ========== Tab 枚举 ==========
enum MainTab { HOME = 0, HEALTH = 1, SCHEDULE = 2, COMMUNITY = 3, PROFILE = 4 }

// ========== 图表子组件 ==========

@Component
struct ExpenseBarView {
  @Prop label: string;
  @Prop value: number;
  @Prop maxValue: number;
  @Prop barColor: string;

  build() {
    Column() {
      Text(`${this.value}`)
        .fontSize(10)
        .fontColor('#666666')
        .margin({ bottom: 4 });

      Column()
        .width(28)
        .height(`${(this.value / this.maxValue) * 100}%`)
        .backgroundColor(this.barColor)
        .borderRadius(4);

      Text(this.label)
        .fontSize(10)
        .fontColor('#999999')
        .margin({ top: 4 });
    }
    .width(40)
    .height(140)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.End);
  }
}

@Component
struct WeightDotView {
  @Prop weight: number;
  @Prop label: string;
  @Prop isLast: boolean;

  build() {
    Row() {
      Column() {
        Text(`${this.weight}kg`)
          .fontSize(11)
          .fontColor('#FF6B35')
          .fontWeight(FontWeight.Bold);

        Circle({ width: 10, height: 10 })
          .fill('#FF6B35');

        Text(this.label)
          .fontSize(9)
          .fontColor('#999999');
      }
      .alignItems(HorizontalAlign.Center);

      if (!this.isLast) {
        Divider()
          .vertical(false)
          .width(30)
          .height(1)
          .color('#FF6B35')
          .margin({ top: 14 });
      }
    }
    .alignItems(VerticalAlign.Center);
  }
}

@Component
struct VaccineBadgeView {
  @Prop vaccineName: string;
  @Prop isCompleted: boolean;
  @Prop hospital: string;
  @Prop date: string;

  build() {
    Row() {
      Column() {
        Text(this.isCompleted ? '✓' : '○')
          .fontSize(18)
          .fontColor(this.isCompleted ? '#06D6A0' : '#F7B801');
      }
      .width(40)
      .height(40)
      .borderRadius(20)
      .backgroundColor(this.isCompleted ? '#E8FFF8' : '#FFFBE8')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center);

      Column() {
        Text(this.vaccineName)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333');

        Text(this.hospital)
          .fontSize(11)
          .fontColor('#999999')
          .margin({ top: 2 });

        Text(this.date)
          .fontSize(10)
          .fontColor('#BBBBBB')
          .margin({ top: 1 });
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
      .layoutWeight(1);
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FAFAFA')
    .borderRadius(12)
    .margin({ bottom: 8 });
  }
}

// ========== 弹框 Builder (条件渲染 + position 定位) ==========

@Builder
function AddReminderDialog(
  show: boolean,
  selType: string,
  rmTime: string,
  rmNote: string,
  onClose: () => void,
  onSelType: (t: string) => void,
  onTime: (t: string) => void,
  onNote: (n: string) => void,
  onConfirm: () => void
) {
  if (show) {
    Stack() {
      Column() {
        Column() {
          Text('新增提醒')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
            .margin({ bottom: 20 });

          Text('选择类型')
            .fontSize(14)
            .fontColor('#666666')
            .alignSelf(ItemAlign.Start)
            .margin({ bottom: 10 });

          Row() {
            ForEach(Object.keys(REMINDER_TYPES), (key: string) => {
              Column() {
                Text(REMINDER_TYPES[key].icon)
                  .fontSize(24)
                  .margin({ bottom: 4 });

                Text(REMINDER_TYPES[key].label)
                  .fontSize(10)
                  .fontColor(selType === key ? '#FF6B35' : '#999999');
              }
              .width(60)
              .height(70)
              .borderRadius(12)
              .backgroundColor(selType === key ? REMINDER_TYPES[key].bgColor : '#F5F5F5')
              .onClick(() => { onSelType(key); })
              .margin({ right: 8 });
            })
          }
          .width('100%')
          .margin({ bottom: 20 });

          Row() {
            Text('时间')
              .fontSize(14)
              .fontColor('#666666')
              .width(50);

            TextInput({ text: rmTime, placeholder: '请选择时间' })
              .height(40)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .layoutWeight(1)
              .onChange((value: string) => { onTime(value); });
          }
          .width('100%')
          .margin({ bottom: 12 });

          Row() {
            Text('备注')
              .fontSize(14)
              .fontColor('#666666')
              .width(50);

            TextInput({ text: rmNote, placeholder: '添加备注' })
              .height(40)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .layoutWeight(1)
              .onChange((value: string) => { onNote(value); });
          }
          .width('100%')
          .margin({ bottom: 24 });

          Row() {
            Button('取消')
              .width('45%')
              .height(44)
              .backgroundColor('#F5F5F5')
              .fontColor('#666666')
              .borderRadius(22)
              .onClick(() => { onClose(); });

            Button('确认添加')
              .width('45%')
              .height(44)
              .backgroundColor('#FF6B35')
              .fontColor('#FFFFFF')
              .borderRadius(22)
              .onClick(() => { onConfirm(); });
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween);
        }
        .width('90%')
        .padding(24)
        .backgroundColor('#FFFFFF')
        .borderRadius(20);
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center);
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => { onClose(); });
  }
}

@Builder
function DeleteConfirmDialog(
  show: boolean,
  itemName: string,
  onCancel: () => void,
  onDelete: () => void
) {
  if (show) {
    Stack() {
      Column() {
        Column() {
          Text('⚠️')
            .fontSize(48)
            .margin({ bottom: 12 });

          Text('确认删除')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
            .margin({ bottom: 8 });

          Text(`确定要删除"${itemName}"吗?\n此操作不可撤销`)
            .fontSize(14)
            .fontColor('#888888')
            .textAlign(TextAlign.Center)
            .margin({ bottom: 24 });

          Row() {
            Button('取消')
              .width('45%')
              .height(44)
              .backgroundColor('#F5F5F5')
              .fontColor('#666666')
              .borderRadius(22)
              .onClick(() => { onCancel(); });

            Button('确认删除')
              .width('45%')
              .height(44)
              .backgroundColor('#EF476F')
              .fontColor('#FFFFFF')
              .borderRadius(22)
              .onClick(() => { onDelete(); });
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween);
        }
        .width('80%')
        .padding(28)
        .backgroundColor('#FFFFFF')
        .borderRadius(20);
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center);
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => { onCancel(); });
  }
}

@Builder
function EditPetDialog(
  show: boolean,
  petTarget: PetInfo | null,
  petName: string,
  petBreed: string,
  petWeight: string,
  onClose: () => void,
  onName: (v: string) => void,
  onBreed: (v: string) => void,
  onWeight: (v: string) => void,
  onSave: () => void
) {
  if (show) {
    Stack() {
      Column() {
        Column() {
          Text('编辑宠物信息')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
            .margin({ bottom: 20 });

          if (petTarget !== null) {
            Text(petTarget.avatar)
              .fontSize(56)
              .margin({ bottom: 16 });
          }

          Row() {
            Text('名字')
              .fontSize(14)
              .fontColor('#666666')
              .width(60);

            TextInput({ text: petName, placeholder: '宠物名字' })
              .height(40)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .layoutWeight(1)
              .onChange((value: string) => { onName(value); });
          }
          .width('100%')
          .margin({ bottom: 12 });

          Row() {
            Text('品种')
              .fontSize(14)
              .fontColor('#666666')
              .width(60);

            TextInput({ text: petBreed, placeholder: '宠物品种' })
              .height(40)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .layoutWeight(1)
              .onChange((value: string) => { onBreed(value); });
          }
          .width('100%')
          .margin({ bottom: 12 });

          Row() {
            Text('体重')
              .fontSize(14)
              .fontColor('#666666')
              .width(60);

            TextInput({ text: petWeight, placeholder: '体重(kg)' })
              .height(40)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .layoutWeight(1)
              .onChange((value: string) => { onWeight(value); });
          }
          .width('100%')
          .margin({ bottom: 24 });

          Row() {
            Button('取消')
              .width('45%')
              .height(44)
              .backgroundColor('#F5F5F5')
              .fontColor('#666666')
              .borderRadius(22)
              .onClick(() => { onClose(); });

            Button('保存')
              .width('45%')
              .height(44)
              .backgroundColor('#FF6B35')
              .fontColor('#FFFFFF')
              .borderRadius(22)
              .onClick(() => { onSave(); });
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween);
        }
        .width('85%')
        .padding(24)
        .backgroundColor('#FFFFFF')
        .borderRadius(20);
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center);
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .backgroundColor('rgba(0,0,0,0.5)')
    .onClick(() => { onClose(); });
  }
}

// ========== @Entry @Component 主入口 ==========

@Entry
@Component
struct PetLifeButlerApp {
  @State currentTab: number = MainTab.HOME;
  @State selectedPetIndex: number = 0;
  @State showAddReminder: boolean = false;
  @State showDeleteConfirm: boolean = false;
  @State showEditPet: boolean = false;
  @State addReminderType: string = 'feed';
  @State addReminderTime: string = '';
  @State addReminderNote: string = '';
  @State deleteTargetName: string = '';
  @State editPetNameField: string = '';
  @State editPetBreedField: string = '';
  @State editPetWeightField: string = '';
  @State editTargetPet: PetInfo | null = null;

  private tabTitles: string[] = ['首页', '健康', '日程', '社区', '我的'];
  private tabIcons: string[] = ['🏠', '💚', '📅', '💬', '👤'];
  private tabActiveColors: string[] = ['#FF6B35', '#06D6A0', '#7678ED', '#E85D75', '#3D348B'];

  build() {
    Stack() {
      Column() {
        this.TabContentBuilder();
        this.BottomTabBar();
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#FFF8F0');

      AddReminderDialog(
        this.showAddReminder, this.addReminderType, this.addReminderTime, this.addReminderNote,
        () => { this.closeAddReminder(); },
        (t: string) => { this.addReminderType = t; },
        (t: string) => { this.addReminderTime = t; },
        (n: string) => { this.addReminderNote = n; },
        () => { this.confirmAddReminder(); }
      );

      DeleteConfirmDialog(
        this.showDeleteConfirm, this.deleteTargetName,
        () => { this.cancelDelete(); },
        () => { this.confirmDelete(); }
      );

      EditPetDialog(
        this.showEditPet, this.editTargetPet,
        this.editPetNameField, this.editPetBreedField, this.editPetWeightField,
        () => { this.closeEditPet(); },
        (v: string) => { this.editPetNameField = v; },
        (v: string) => { this.editPetBreedField = v; },
        (v: string) => { this.editPetWeightField = v; },
        () => { this.saveEditPet(); }
      );
    }
    .width('100%')
    .height('100%');
  }

  @Builder
  TabContentBuilder() {
    if (this.currentTab === MainTab.HOME) {
      this.HomeTabContent();
    } else if (this.currentTab === MainTab.HEALTH) {
      this.HealthTabContent();
    } else if (this.currentTab === MainTab.SCHEDULE) {
      this.ScheduleTabContent();
    } else if (this.currentTab === MainTab.COMMUNITY) {
      this.CommunityTabContent();
    } else {
      this.ProfileTabContent();
    }
  }

  // ==================== Tab1: 首页 ====================

  @Builder
  HomeTabContent() {
    Scroll() {
      Column() {
        this.HomeHeader();
        this.PetSwitchCards();
        this.TodaySummaryBar();
        this.TodayReminderSection();
        this.QuickActionGrid();
        this.HealthOverviewCard();

        Column().height(80);
      }
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off);
  }

  @Builder
  HomeHeader() {
    Row() {
      Column() {
        Text('🐾 宠物生活管家')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF');

        Text(getGenderLabel(MOCK_PETS[this.selectedPetIndex].gender) +
          ' ' + MOCK_PETS[this.selectedPetIndex].breed)
          .fontSize(13)
          .fontColor('#FFFFFFCC')
          .margin({ top: 4 });
      }
      .alignItems(HorizontalAlign.Start);

      Row() {
        Text('🔔').fontSize(22);
        Text('⚙️').fontSize(22).margin({ left: 16 });
      }
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 44, bottom: 24 })
    .justifyContent(FlexAlign.SpaceBetween)
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#FF6B35', 0], ['#F18701', 1]]
    });
  }

  @Builder
  PetSwitchCards() {
    Column() {
      Row() {
        Text('我的宠物').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
        Text(`${MOCK_PETS.length}`).fontSize(12).fontColor('#999999').margin({ left: 8 });
      }
      .width('100%')
      .margin({ bottom: 12 });

      Scroll() {
        Row() {
          ForEach(MOCK_PETS, (pet: PetInfo, index: number) => {
            Column() {
              Text(pet.avatar).fontSize(40);

              Text(pet.name)
                .fontSize(14)
                .fontWeight(this.selectedPetIndex === index ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.selectedPetIndex === index ? '#FF6B35' : '#666666')
                .margin({ top: 6 });

              Text(`${pet.age}`).fontSize(11).fontColor('#999999');
            }
            .width(80)
            .padding(12)
            .borderRadius(16)
            .backgroundColor(this.selectedPetIndex === index ? '#FFF0E8' : '#FFFFFF')
            .margin({ right: 10 })
            .onClick(() => { this.selectPet(index); })
            .shadow(this.selectedPetIndex === index ?
              { radius: 12, color: '#FF6B3525', offsetX: 0, offsetY: 4 } :
              { radius: 2, color: '#00000008', offsetX: 0, offsetY: 1 });
          })
        }
        .padding({ left: 4 });
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off);
    }
    .width('100%')
    .padding(16)
    .margin({ top: 16, left: 16, right: 16 })
    .borderRadius(20)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 8, color: '#00000010', offsetX: 0, offsetY: 2 });
  }

  @Builder
  TodaySummaryBar() {
    Row() {
      Column() {
        Text('📅').fontSize(20);
        Text('今日提醒').fontSize(11).fontColor('#666666').margin({ top: 4 });
        Text('8项').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF6B35').margin({ top: 2 });
      }.layoutWeight(1);

      Divider().vertical(true).height(50).color('#EEEEEE');

      Column() {
        Text('⚖️').fontSize(20);
        Text('今日体重').fontSize(11).fontColor('#666666').margin({ top: 4 });
        Text(`${MOCK_PETS[this.selectedPetIndex].weight}kg`)
          .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#F7B801').margin({ top: 2 });
      }.layoutWeight(1);

      Divider().vertical(true).height(50).color('#EEEEEE');

      Column() {
        Text('💉').fontSize(20);
        Text('疫苗状态').fontSize(11).fontColor('#666666').margin({ top: 4 });
        Text(MOCK_PETS[this.selectedPetIndex].vaccineStatus)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(getVaccineBadgeColor(MOCK_PETS[this.selectedPetIndex].vaccineStatus))
          .margin({ top: 2 });
      }.layoutWeight(1);
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  TodayReminderSection() {
    Column() {
      Row() {
        Text('📋 今日待办').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
        Text('8项')
          .fontSize(12)
          .fontColor('#FF6B35')
          .backgroundColor('#FFF0E8')
          .borderRadius(10)
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          .margin({ left: 8 });
      }
      .width('100%')
      .margin({ bottom: 12 });

      ForEach(MOCK_SCHEDULES.slice(0, 8), (item: ScheduleReminder) => {
        Row() {
          Column() {
            Text(item.icon).fontSize(24);
          }
          .width(44).height(44).borderRadius(14)
          .backgroundColor(item.color + '20')
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);

          Column() {
            Text(item.title)
              .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333')
              .decoration({ type: item.isCompleted ?
                TextDecorationType.LineThrough : TextDecorationType.None });

            Text(`${item.petName} · ${item.note}`)
              .fontSize(11).fontColor('#999999').margin({ top: 2 });
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 });

          Column() {
            Text(item.time)
              .fontSize(13).fontWeight(FontWeight.Medium)
              .fontColor(item.isCompleted ? '#CCCCCC' : '#FF6B35');

            Text(item.isCompleted ? '已完成' : '待办')
              .fontSize(10)
              .fontColor(item.isCompleted ? '#06D6A0' : '#F7B801')
              .margin({ top: 2 });
          }.alignItems(HorizontalAlign.End);
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        .border({ width: 0.5, color: '#F5F5F5' });
      });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  QuickActionGrid() {
    Column() {
      Row() {
        Text('快捷操作').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      }
      .width('100%')
      .margin({ bottom: 12 });

      Grid() {
        ForEach(Object.values(QUICK_ACTIONS), (action: QuickAction) => {
          GridItem() {
            Column() {
              Text(action.icon).fontSize(28);
              Text(action.title).fontSize(12).fontColor('#666666').margin({ top: 6 });
            }
            .width('100%')
            .height(76)
            .borderRadius(14)
            .backgroundColor(action.bgColor)
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
            .onClick(() => {
              if (action.id === 4) { this.openAddReminder(); }
            });
          }
        })
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')
      .rowsGap(10)
      .columnsGap(10)
      .height(172);
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  HealthOverviewCard() {
    Column() {
      Row() {
        Text('💚 健康概览').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      }
      .width('100%')
      .margin({ bottom: 12 });

      Row() {
        Column() {
          Text('体重趋势').fontSize(13).fontColor('#666666');
          Text(`${MOCK_PETS[this.selectedPetIndex].weight}kg`)
            .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF6B35').margin({ top: 4 });
          Text('较上月 +0.1kg').fontSize(11).fontColor('#06D6A0');
        }
        .width('48%').padding(14).borderRadius(14).backgroundColor('#FFF8F0');

        Column() {
          Text('距下次疫苗').fontSize(13).fontColor('#666666');
          Text('125天')
            .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#7678ED').margin({ top: 4 });
          Text('2027-03-15').fontSize(11).fontColor('#999999');
        }
        .width('48%').padding(14).borderRadius(14).backgroundColor('#F5F5FF');
      }
      .width('100%').justifyContent(FlexAlign.SpaceBetween).margin({ bottom: 10 });

      Row() {
        Column() {
          Text('本月就医').fontSize(13).fontColor('#666666');
          Text('1次')
            .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#E85D75').margin({ top: 4 });
          Text('花费 ¥260').fontSize(11).fontColor('#999999');
        }
        .width('48%').padding(14).borderRadius(14).backgroundColor('#FFF5F5');

        Column() {
          Text('健康评分').fontSize(13).fontColor('#666666');
          Text('95')
            .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#06D6A0').margin({ top: 4 });
          Text('优秀').fontSize(11).fontColor('#06D6A0');
        }
        .width('48%').padding(14).borderRadius(14).backgroundColor('#F0FFF8');
      }
      .width('100%').justifyContent(FlexAlign.SpaceBetween);
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  // ==================== Tab2: 健康 ====================

  @Builder
  HealthTabContent() {
    Scroll() {
      Column() {
        this.HealthHeader();
        this.HealthRecordList();
        this.WeightTrendSection();
        this.VaccineRecordSection();
        this.DewormRecordSection();

        Column().height(80);
      }
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off);
  }

  @Builder
  HealthHeader() {
    Row() {
      Text('💚 健康管理').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
      Text('15条记录').fontSize(13).fontColor('#FFFFFFCC');
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 44, bottom: 20 })
    .justifyContent(FlexAlign.SpaceBetween)
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#06D6A0', 0], ['#118AB2', 1]]
    });
  }

  @Builder
  HealthRecordList() {
    Column() {
      Row() {
        Text('📋 健康记录').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
        Text('共15条').fontSize(12).fontColor('#999999').margin({ left: 8 });
      }
      .width('100%')
      .margin({ bottom: 12 });

      ForEach(MOCK_HEALTH_RECORDS, (record: HealthRecord) => {
        Row() {
          Column() {
            Text(this.getHealthIcon(record.type)).fontSize(22);
          }
          .width(44).height(44).borderRadius(14)
          .backgroundColor(this.getHealthBgColor(record.type))
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);

          Column() {
            Text(record.type).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333');

            Text(record.description)
              .fontSize(11).fontColor('#999999').margin({ top: 2 })
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis });
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 });

          Column() {
            Text(`¥${record.cost}`)
              .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#FF6B35');

            Text(record.date).fontSize(10).fontColor('#BBBBBB').margin({ top: 2 });
          }.alignItems(HorizontalAlign.End);
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        .border({ width: 0.5, color: '#F5F5F5' });
      });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 16 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  WeightTrendSection() {
    Column() {
      Row() {
        Text('⚖️ 体重趋势').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      }
      .width('100%')
      .margin({ bottom: 16 });

      Scroll() {
        Row() {
          ForEach(MOCK_WEIGHTS, (item: WeightRecord, index: number) => {
            WeightDotView({
              weight: item.weight,
              label: item.date.substring(5),
              isLast: index === MOCK_WEIGHTS.length - 1
            });
          })
        }
        .padding({ left: 8, right: 8 });
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off);

      Row() {
        Text('起始: 27.8kg').fontSize(11).fontColor('#999999');
        Text('当前: 28.6kg').fontSize(11).fontColor('#FF6B35').margin({ left: 16 });
        Text('变化: +0.8kg').fontSize(11).fontColor('#06D6A0').margin({ left: 16 });
      }
      .width('100%')
      .margin({ top: 12 });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  VaccineRecordSection() {
    Column() {
      Row() {
        Text('💉 疫苗记录').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
        Text('8条').fontSize(12).fontColor('#999999').margin({ left: 8 });
      }
      .width('100%')
      .margin({ bottom: 12 });

      ForEach(MOCK_VACCINES, (vaccine: VaccineRecord) => {
        VaccineBadgeView({
          vaccineName: vaccine.vaccineName,
          isCompleted: vaccine.isCompleted,
          hospital: vaccine.hospital,
          date: vaccine.date
        });
      });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  DewormRecordSection() {
    Column() {
      Row() {
        Text('💊 驱虫记录').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      }
      .width('100%')
      .margin({ bottom: 12 });

      ForEach(MOCK_DEWORM_RECORDS, (record: DewormRecord) => {
        Row() {
          Text(record.isInternal ? '🔴' : '🔵').fontSize(16);

          Column() {
            Text(record.type).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333');
            Text(record.medicine).fontSize(11).fontColor('#999999').margin({ top: 2 });
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 });

          Column() {
            Text(record.date).fontSize(12).fontColor('#666666');
            Text(`下次: ${record.nextDate}`).fontSize(10).fontColor('#FF6B35').margin({ top: 2 });
          }.alignItems(HorizontalAlign.End);
        }
        .width('100%')
        .padding(12)
        .backgroundColor('#FAFAFA')
        .borderRadius(12)
        .margin({ bottom: 8 });
      });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  // ==================== Tab3: 日程 ====================

  @Builder
  ScheduleTabContent() {
    Scroll() {
      Column() {
        this.ScheduleHeader();
        this.ScheduleFilterBar();
        this.ScheduleList();

        Column().height(80);
      }
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off);
  }

  @Builder
  ScheduleHeader() {
    Row() {
      Text('📅 日程安排').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');

      Row() {
        Text('+ 新增')
          .fontSize(14).fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .backgroundColor('#FFFFFF30').borderRadius(16);
      }
      .onClick(() => { this.openAddReminder(); });
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 44, bottom: 20 })
    .justifyContent(FlexAlign.SpaceBetween)
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#7678ED', 0], ['#3D348B', 1]]
    });
  }

  @Builder
  ScheduleFilterBar() {
    Row() {
      Text('全部')
        .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor('#7678ED').borderRadius(14);

      Text('喂食')
        .fontSize(13).fontColor('#999999')
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor('#F5F5F5').borderRadius(14).margin({ left: 8 });

      Text('遛弯')
        .fontSize(13).fontColor('#999999')
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor('#F5F5F5').borderRadius(14).margin({ left: 8 });

      Text('医疗')
        .fontSize(13).fontColor('#999999')
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor('#F5F5F5').borderRadius(14).margin({ left: 8 });

      Text('护理')
        .fontSize(13).fontColor('#999999')
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor('#F5F5F5').borderRadius(14).margin({ left: 8 });
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 4 });
  }

  @Builder
  ScheduleList() {
    Column() {
      ForEach(MOCK_SCHEDULES, (item: ScheduleReminder) => {
        Row() {
          Column() {
            Text(item.icon).fontSize(26);
          }
          .width(50).height(50).borderRadius(16)
          .backgroundColor(item.color + '20')
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);

          Column() {
            Text(item.title)
              .fontSize(15).fontWeight(FontWeight.Medium).fontColor('#333333')
              .decoration({ type: item.isCompleted ?
                TextDecorationType.LineThrough : TextDecorationType.None });

            Text(item.note).fontSize(11).fontColor('#999999').margin({ top: 3 });
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 });

          Column() {
            Text(item.time).fontSize(14).fontWeight(FontWeight.Bold).fontColor(item.color);
            Text(item.date).fontSize(10).fontColor('#BBBBBB').margin({ top: 3 });

            Row() {
              if (item.isRepeat) {
                Text('重复')
                  .fontSize(9).fontColor('#7678ED')
                  .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                  .backgroundColor('#F0F0FF').borderRadius(6);
              }
              if (item.isCompleted) {
                Text('✓')
                  .fontSize(9).fontColor('#06D6A0').margin({ left: 4 });
              }
            }
            .margin({ top: 3 });
          }.alignItems(HorizontalAlign.End);
        }
        .width('100%')
        .padding(14)
        .margin({ left: 16, right: 16, top: 8 })
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .shadow({ radius: 4, color: '#00000008', offsetX: 0, offsetY: 1 });
      });
    }
  }

  // ==================== Tab4: 社区 ====================

  @Builder
  CommunityTabContent() {
    Scroll() {
      Column() {
        this.CommunityHeader();
        this.CommunityPostList();

        Column().height(80);
      }
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off);
  }

  @Builder
  CommunityHeader() {
    Row() {
      Text('💬 宠物社区').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');

      Row() {
        Text('关注')
          .fontSize(12).fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('#FFFFFF25').borderRadius(10).margin({ right: 8 });

        Text('热门')
          .fontSize(12).fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('#FFFFFF40').borderRadius(10);
      }
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 44, bottom: 20 })
    .justifyContent(FlexAlign.SpaceBetween)
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#E85D75', 0], ['#EF476F', 1]]
    });
  }

  @Builder
  CommunityPostList() {
    Column() {
      ForEach(MOCK_POSTS, (post: CommunityPost) => {
        Column() {
          Row() {
            Text(post.userAvatar).fontSize(32);

            Column() {
              Text(post.userName)
                .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333');

              Text(post.time).fontSize(11).fontColor('#BBBBBB').margin({ top: 2 });
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 });

            Text('···').fontSize(18).fontColor('#CCCCCC');
          }
          .width('100%');

          Row() {
            Text(post.petEmoji).fontSize(36);
            Text(post.petName).fontSize(13).fontColor('#999999').margin({ left: 6 });
          }
          .padding({ left: 8, right: 10, top: 4, bottom: 4 })
          .backgroundColor('#F8F8F8').borderRadius(10).margin({ top: 10 });

          Text(post.content)
            .fontSize(14).fontColor('#555555').lineHeight(22).margin({ top: 10 })
            .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis });

          if (post.imageCount > 0) {
            Row() {
              Text('🖼️').fontSize(16);
              Text(`${post.imageCount}张图片`)
                .fontSize(11).fontColor('#999999').margin({ left: 4 });
            }
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#F0F0F0').borderRadius(8).margin({ top: 8 });
          }

          Divider().height(0.5).color('#F0F0F0').margin({ top: 12, bottom: 10 });

          Row() {
            Row() {
              Text(post.isLiked ? '❤️' : '🤍').fontSize(16);
              Text(`${post.likeCount}`).fontSize(12).fontColor('#999999').margin({ left: 4 });
            }.margin({ right: 24 });

            Row() {
              Text('💬').fontSize(16);
              Text(`${post.commentCount}`).fontSize(12).fontColor('#999999').margin({ left: 4 });
            };

            Row() {
              Text('↗️').fontSize(16);
              Text('分享').fontSize(12).fontColor('#999999').margin({ left: 4 });
            }.margin({ left: 24 });
          }
          .width('100%');
        }
        .width('100%')
        .padding(16)
        .margin({ left: 16, right: 16, top: 8 })
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .shadow({ radius: 4, color: '#00000008', offsetX: 0, offsetY: 1 });
      });
    }
  }

  // ==================== Tab5: 我的 ====================

  @Builder
  ProfileTabContent() {
    Scroll() {
      Column() {
        this.ProfileHeader();
        this.ProfilePetList();
        this.MonthlyExpenseSection();
        this.SettingsSection();

        Column().height(80);
      }
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off);
  }

  @Builder
  ProfileHeader() {
    Column() {
      Row() {
        Text('🧑').fontSize(48);

        Column() {
          Text('宠物家长')
            .fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');

          Text('已陪伴豆豆 1235天')
            .fontSize(13).fontColor('#FFFFFFCC').margin({ top: 4 });
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 14 });

        Text('✏️').fontSize(20).fontColor('#FFFFFF');
      }
      .width('100%');

      Row() {
        Column() {
          Text('5').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
          Text('宠物').fontSize(11).fontColor('#FFFFFFCC');
        }.layoutWeight(1);

        Divider().vertical(true).height(36).color('#FFFFFF30');

        Column() {
          Text('15').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
          Text('健康记录').fontSize(11).fontColor('#FFFFFFCC');
        }.layoutWeight(1);

        Divider().vertical(true).height(36).color('#FFFFFF30');

        Column() {
          Text('¥8,130').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF');
          Text('年度支出').fontSize(11).fontColor('#FFFFFFCC');
        }.layoutWeight(1);
      }
      .width('100%')
      .margin({ top: 20 });
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 44, bottom: 24 })
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#3D348B', 0], ['#7678ED', 1]]
    });
  }

  @Builder
  ProfilePetList() {
    Column() {
      Row() {
        Text('🐾 我的宠物').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      }
      .width('100%')
      .margin({ bottom: 12 });

      ForEach(MOCK_PETS, (pet: PetInfo) => {
        Row() {
          Text(pet.avatar).fontSize(36);

          Column() {
            Text(pet.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#333333');
            Text(`${pet.breed} · ${pet.age}岁 · ${pet.weight}kg`)
              .fontSize(11).fontColor('#999999').margin({ top: 2 });
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 });

          Row() {
            Text(getGenderLabel(pet.gender))
              .fontSize(14)
              .fontColor(pet.gender === '公' ? '#7678ED' : '#E85D75');

            Text(pet.vaccineStatus === '已接种' ? '🟢' : '🟡')
              .fontSize(10).margin({ left: 8 });
          }
        }
        .width('100%')
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FAFAFA')
        .margin({ bottom: 8 })
        .onClick(() => { this.openEditPet(pet); });
      });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 16 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  MonthlyExpenseSection() {
    Column() {
      Row() {
        Text('💰 月度支出').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
        Text('近7个月').fontSize(12).fontColor('#999999').margin({ left: 8 });
      }
      .width('100%')
      .margin({ bottom: 16 });

      Row() {
        ForEach(MOCK_EXPENSES, (expense: MonthlyExpense) => {
          ExpenseBarView({
            label: expense.month,
            value: expense.total,
            maxValue: 1600,
            barColor: '#FF6B35'
          });
        })
      }
      .width('100%')
      .height(150)
      .justifyContent(FlexAlign.SpaceAround);

      Row() {
        Row() {
          Circle({ width: 8, height: 8 }).fill('#FF6B35');
          Text('食品').fontSize(11).fontColor('#666666').margin({ left: 4 });
        }.margin({ right: 10 });

        Row() {
          Circle({ width: 8, height: 8 }).fill('#7678ED');
          Text('医疗').fontSize(11).fontColor('#666666').margin({ left: 4 });
        }.margin({ right: 10 });

        Row() {
          Circle({ width: 8, height: 8 }).fill('#E85D75');
          Text('美容').fontSize(11).fontColor('#666666').margin({ left: 4 });
        }.margin({ right: 10 });

        Row() {
          Circle({ width: 8, height: 8 }).fill('#F7B801');
          Text('用品').fontSize(11).fontColor('#666666').margin({ left: 4 });
        };

        Row() {
          Circle({ width: 8, height: 8 }).fill('#06D6A0');
          Text('其他').fontSize(11).fontColor('#666666').margin({ left: 4 });
        }.margin({ left: 10 });
      }
      .width('100%')
      .margin({ top: 12 });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  @Builder
  SettingsSection() {
    Column() {
      Row() {
        Text('⚙️ 设置').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333');
      }
      .width('100%')
      .margin({ bottom: 12 });

      ForEach(Object.values(SETTINGS_LIST), (setting: SettingItem) => {
        Row() {
          Text(setting.icon).fontSize(22);

          Column() {
            Text(setting.title).fontSize(14).fontColor('#333333');
            Text(setting.subTitle).fontSize(11).fontColor('#BBBBBB').margin({ top: 2 });
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 });

          if (setting.hasArrow) {
            Text('›').fontSize(20).fontColor('#CCCCCC');
          }
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        .border({ width: 0.5, color: '#F5F5F5' });
      });

      Row() {
        Text('退出登录').fontSize(15).fontColor('#EF476F');
      }
      .width('100%')
      .height(48)
      .justifyContent(FlexAlign.Center)
      .margin({ top: 16 });
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, top: 12 })
    .borderRadius(16)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: '#00000008', offsetX: 0, offsetY: 1 });
  }

  // ==================== 底部Tab ====================

  @Builder
  BottomTabBar() {
    Row() {
      ForEach(
        [MainTab.HOME, MainTab.HEALTH, MainTab.SCHEDULE, MainTab.COMMUNITY, MainTab.PROFILE],
        (tab: number, index: number) => {
          Column() {
            Text(this.tabIcons[index]).fontSize(22);

            Text(this.tabTitles[index])
              .fontSize(10)
              .fontColor(this.currentTab === tab ? this.tabActiveColors[index] : '#999999')
              .fontWeight(this.currentTab === tab ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 });
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 8, bottom: 4 })
          .onClick(() => { this.currentTab = tab; });
        }
      )
    }
    .width('100%')
    .height(56)
    .backgroundColor('#FFFFFF')
    .padding({ bottom: 8 })
    .shadow({ radius: 16, color: '#00000010', offsetX: 0, offsetY: -4 });
  }

  // ==================== 辅助方法 ====================

  private getHealthIcon(type: string): string {
    switch (type) {
      case '体检': return '🩺';
      case '疫苗': return '💉';
      case '驱虫': return '💊';
      case '牙科': return '🦷';
      case '皮肤科': return '🩹';
      case '眼科': return '👁️';
      case '关节检查': return '🦴';
      case '血液检查': return '🩸';
      default: return '📋';
    }
  }

  private getHealthBgColor(type: string): string {
    switch (type) {
      case '体检': return '#FFF0E8';
      case '疫苗': return '#E8F8FF';
      case '驱虫': return '#E8FFF8';
      case '牙科': return '#FFF8E8';
      case '皮肤科': return '#FFF0F3';
      case '眼科': return '#F0F8FF';
      case '关节检查': return '#FFF8F0';
      case '血液检查': return '#FFE8E8';
      default: return '#F5F5F5';
    }
  }

  private selectPet(index: number): void {
    this.selectedPetIndex = index;
  }

  private openAddReminder(): void {
    this.showAddReminder = true;
    this.addReminderType = 'feed';
    this.addReminderTime = '';
    this.addReminderNote = '';
  }

  private closeAddReminder(): void {
    this.showAddReminder = false;
  }

  private confirmAddReminder(): void {
    this.showAddReminder = false;
  }

  private cancelDelete(): void {
    this.showDeleteConfirm = false;
  }

  private confirmDelete(): void {
    this.showDeleteConfirm = false;
  }

  private openEditPet(pet: PetInfo): void {
    this.editTargetPet = pet;
    this.editPetNameField = pet.name;
    this.editPetBreedField = pet.breed;
    this.editPetWeightField = pet.weight.toString();
    this.showEditPet = true;
  }

  private closeEditPet(): void {
    this.showEditPet = false;
  }

  private saveEditPet(): void {
    this.showEditPet = false;
  }
}


十八、总结与架构思考

通过对本应用全部1824行源码的逐段分析,我们可以提炼出以下关键的技术实践和架构设计思路:

在这里插入图片描述

  1. 数据驱动 UI 的极致实践:从配置字典到 Mock 数据,几乎所有视觉表现都由数据决定。颜色、图标、标签文案、背景色都存储在数据对象中,UI 层只负责读取和渲染。这种"把样式当数据"的做法虽然增加了数据定义的工作量,但换来了极高的可维护性和可扩展性。

  2. 子组件封装的粒度把控:三个 @Component 子组件(柱状图、体重点、疫苗徽章)都是在多个页面或多个位置可能复用的 UI 单元。其他一次性的 UI 片段则使用 @Builder 内联构建,避免了过度抽象。

  3. 全局弹框的回调通信机制:通过函数参数传递回调,实现了全局 Builder 与组件实例方法之间的双向通信。虽然参数数量较多,但这种显式传递的方式在代码可读性和调试方面优于隐式的事件总线或全局状态。

  4. 条件渲染 vs Tabs 组件的取舍:本应用选择了 if-else 条件渲染而非 Tabs 组件,这是一种有意识的简化——牺牲了切换动画,换取了更灵活的布局控制和更简单的实现。

  5. 色彩体系的一致性设计:5个 Tab 页各有专属的渐变色主题,同时全局共享暖橙黄系的视觉基调。每种提醒类型、诊疗类型、快捷操作都有配套的色卡,形成了统一且丰富的彩色卡片风格。

希望这篇逐段源码解析能够帮助你深入理解 HarmonyOS ArkTS 的声明式 UI 开发范式,从接口定义到组件封装,从状态管理到弹框交互,全面掌握移动端应用开发的实战技巧。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐