# HarmonyOS ArkTS 实战解析:宠物健康追踪应用源码逐段深度剖析
引言:走进宠物健康管理的移动化世界
在当今"它经济"蓬勃发展的时代,越来越多的家庭将宠物视为重要的家庭成员。无论是金毛寻回犬的年度体检、英国短毛猫的疫苗接种,还是荷兰垂耳兔的日常体重监测,宠物主人都需要一套系统化的工具来记录、追踪和管理爱宠的健康数据。传统的纸质档案容易丢失、难以检索,而市面上的通用记事类应用又缺乏针对宠物医疗场景的专业字段(如体温、兽医、诊所、下次就诊时间等)。正是在这样的背景下,一款专注于宠物健康追踪的鸿蒙原生应用应运而生。

本应用基于华为 HarmonyOS 的 ArkTS 声明式开发范式构建,采用了 ArkUI 框架的核心特性。ArkTS 是在 TypeScript 基础上扩展而来的编程语言,专门为鸿蒙的声明式 UI 框架量身定制。它保留了 TypeScript 的类型系统优势,同时引入了 @Entry、@Component、@State、@Builder、@Observed 等装饰器,使开发者能够以接近自然思维的方式描述界面结构。整个应用采用单页面(Single Page)架构,通过底部导航栏在四个功能模块之间切换,并通过模态弹窗(Modal Dialog)实现数据的增删改操作,形成了一个完整的 CRUD 闭环。
从技术栈角度看,本应用融合了多项 ArkUI 关键能力:使用 @State 实现组件内部的状态驱动渲染;使用 @Observed 让数据模型类具备可观察性;使用 @Builder 将复杂 UI 拆解为可复用的构建单元;使用 Stack 布局实现模态层叠覆盖;使用 Scroll 容器保证长列表内容的可滚动性;通过枚举(enum)管理 Tab 页状态以增强代码可读性。应用的视觉设计采用珊瑚粉(#E91E63)为主色调,搭配粉色系渐变背景(#FCE4EC),营造出温馨、可爱的视觉风格,与宠物主题高度契合。
下面,我们将按照源码的实际组织顺序,逐段剖析每个核心模块的设计思路、实现细节和技术要点。
一、数据接口定义:构建类型安全的数据契约
interface PetData {
id: number
name: string
species: string
breed: string
age: number
weight: number
birthday: string
emoji: string
color: string
avatarBg: string
}
interface HealthRecord {
id: number
petId: number
type: string
date: string
weight: number
temperature: number
vetName: string
clinic: string
cost: number
notes: string
nextVisit: string
isCompleted: boolean
}

源码开头首先定义了两个核心数据接口 PetData 和 HealthRecord,这体现了 ArkTS 开发中"类型先行"的良好实践。PetData 接口描述了宠物的基本档案信息,包含标识符 id、名称 name、物种 species(如"狗"“猫”“兔”)、品种 breed(如"金毛寻回犬")、年龄 age、体重 weight、生日 birthday,以及三个用于视觉呈现的字段——表情符号 emoji、主题色 color 和头像背景色 avatarBg。这里特别值得注意的是将 emoji 和颜色字段直接纳入数据模型的设计思路:这样做可以让每只宠物拥有独立的视觉标识,在列表渲染时无需额外的映射逻辑,数据与视图天然绑定。
HealthRecord 接口则定义了健康记录的完整结构。petId 作为外键与 PetData.id 建立关联,实现了"一对多"的数据关系——一只宠物可以拥有多条健康记录。记录类型 type 使用字符串而非枚举,虽然牺牲了一定的类型安全,但换来了数据来源的灵活性。记录中包含了医疗场景所需的关键字段:就诊日期 date、当次体重 weight、体温 temperature、主治兽医 vetName、就诊诊所 clinic、费用 cost、备注 notes,以及用于提醒功能的下次复诊时间 nextVisit 和完成状态 isCompleted。这种字段设计全面覆盖了宠物就医的实际信息维度。
interface AlertItem {
emoji: string
title: string
detail: string
urgency: string
bgColor: string
borderColor: string
}
interface MetricItem {
label: string
value: string
icon: string
color: string
}
interface ExpenseBarItem {
label: string
value: number
color: string
}
interface WeightPoint {
month: string
weight: number
}

紧接着定义了四个辅助性数据接口。AlertItem 用于健康提醒卡片,包含表情、标题、详情、紧急程度(“高”“中”“低”)以及背景色和边框色——颜色信息直接嵌入数据,使得不同紧急程度的提醒可以呈现差异化的视觉风格。MetricItem 是一个通用的指标展示单元,包含标签、数值、图标和颜色四个字段,适用于各种数据卡片场景。ExpenseBarItem 专门用于统计页面的费用条形图,包含标签、数值和颜色。WeightPoint 则定义了体重监测的时间序列数据点,包含月份和体重值,为折线图类可视化预留了数据结构。这些接口的提前定义,为后续的 @State 状态声明和 @Builder 渲染逻辑提供了类型约束基础,是整个应用类型安全体系的第一道防线。
二、可观察模型类:@Observed 装饰器与响应式数据
@Observed
class PetModel {
id: number
name: string
species: string
breed: string
age: number
weight: number
birthday: string
emoji: string
color: string
avatarBg: string
constructor(id: number, name: string, species: string, breed: string,
age: number, weight: number, birthday: string,
emoji: string, color: string, avatarBg: string) {
this.id = id
this.name = name
this.species = species
this.breed = breed
this.age = age
this.weight = weight
this.birthday = birthday
this.emoji = emoji
this.color = color
this.avatarBg = avatarBg
}
}

这里定义了 PetModel 类,并使用了 @Observed 装饰器。这是 ArkUI 状态管理框架中一个非常重要的装饰器。@Observed 的作用是让一个类成为"可观察对象"——当该类的实例属性发生变化时,框架能够自动追踪这些变化并触发依赖该数据的 UI 组件重新渲染。与之配合的是 @ObjectLink 装饰器(用于子组件接收可观察对象),虽然本应用中 PetModel 主要存储在 @State 数组中使用,但 @Observed 的存在使得将来如果需要在子组件中精细化管理单个宠物的状态变化,可以无缝扩展。
构造函数的设计采用了全参数传入的方式,要求在实例化时提供所有字段的值。这种设计虽然参数较多(共10个参数),但保证了对象创建后数据的完整性,避免了"半初始化"对象的存在。构造函数体内逐行赋值的写法虽然略显冗长,但在 ArkTS 中是标准且清晰的做法。可以看到,类的字段定义与前文 PetData 接口完全一致——这种"接口定义形状、类实现行为"的模式,既利用了接口的灵活组合性,又借助类获得了构造能力和可观察性。
@Observed
class HealthRecModel {
id: number
petId: number
type: string
date: string
weight: number
temperature: number
vetName: string
clinic: string
cost: number
notes: string
nextVisit: string
isCompleted: boolean
constructor(id: number, petId: number, type: string, date: string,
weight: number, temperature: number, vetName: string,
clinic: string, cost: number, notes: string,
nextVisit: string, isCompleted: boolean) {
this.id = id
this.petId = petId
this.type = type
this.date = date
this.weight = weight
this.temperature = temperature
this.vetName = vetName
this.clinic = clinic
this.cost = cost
this.notes = notes
this.nextVisit = nextVisit
this.isCompleted = isCompleted
}
}

HealthRecModel 同样使用了 @Observed 装饰器,其结构与 HealthRecord 接口对应。这里有一个值得思考的设计细节:isCompleted 字段使用了 boolean 基础类型而非字符串,这在进行完成状态判断时(如后续 if (record.isCompleted))更加直观和高效。cost 使用 number 类型,方便后续的金额汇总计算。两个模型类共同构成了应用的核心数据层,所有 UI 渲染和业务逻辑都围绕这两个模型展开。
三、枚举与页面状态管理
enum PetTab {
PETS = 0,
HEALTH = 1,
STATS = 2,
PROFILE = 3
}

PetTab 枚举定义了应用的四个功能模块标识。使用枚举而非魔法数字(magic number)是工程实践中的基本准则。这里为每个枚举值显式指定了数字(0到3),虽然 TypeScript 的枚举默认就是从0开始递增,但显式赋值能提高代码的可读性和可维护性——当其他开发者阅读到 if (this.activeTab === PetTab.PETS) 时,能立即理解其含义,而不需要去猜测数字 0 代表什么。这四个枚举值分别对应:宠物列表页(PETS)、健康记录页(HEALTH)、数据统计页(STATS)和个人中心页(PROFILE),构成了应用的主体导航结构。
四、主组件状态声明:@Entry 与 @State 体系
@Entry
@Component
struct PetHealthPage {
@State activeTab: PetTab = PetTab.PETS
@State pets: PetModel[] = []
@State healthRecords: HealthRecModel[] = []
@State showAddPet: boolean = false
@State showEditPet: boolean = false
@State showDeletePet: boolean = false
@State showAddRecord: boolean = false
@State selectedPetIdx: number = -1
@State selectedRecIdx: number = -1
@State alerts: AlertItem[] = []
@State expenses: ExpenseBarItem[] = []
@State maxExpense: number = 12000
@State expandedPets: boolean[] = [false, false, false, false, false, false]

PetHealthPage 是应用的入口组件,由 @Entry 和 @Component 两个装饰器共同标注。@Entry 表示这是一个页面级组件,会被框架注册为路由入口;@Component 表示这是一个自定义组件,可以被复用。组件内部使用 struct 关键字声明,这是 ArkTS 的特有语法——struct 在这里承担了类的组织作用,但语义上更偏向于"声明式描述"。
@State 是 ArkUI 中最核心的状态管理装饰器。被 @State 修饰的变量一旦发生变化,框架会自动重新执行 build() 方法中依赖该变量的部分,实现"数据驱动视图"的响应式更新。这里声明的状态变量可以分为几组:
第一组是导航与数据状态:activeTab 记录当前激活的 Tab 页,初始值为 PetTab.PETS;pets 和 healthRecords 分别存储宠物列表和健康记录列表,初始为空数组,将在 aboutToAppear 中填充。第二组是弹窗控制状态:showAddPet、showEditPet、showDeletePet、showAddRecord 四个布尔值分别控制四种模态弹窗的显示与隐藏,初始均为 false。第三组是选中索引状态:selectedPetIdx 和 selectedRecIdx 记录当前操作的宠物或记录在数组中的索引,初始为 -1 表示未选中。第四组是统计数据状态:alerts 存储健康提醒列表,expenses 存储费用分类统计,maxExpense 作为费用条形图的最大值基准(设为 12000 元)。第五组是展开折叠状态:expandedPets 是一个布尔数组,长度为6(对应6只宠物),记录每只宠物卡片是否处于展开状态。
@State formName: string = ''
@State formSpecies: string = ''
@State formBreed: string = ''
@State formAge: number = 0
@State formWeight: number = 0
@State formBirthday: string = ''
@State formType: string = '体检'
@State formDate: string = ''
@State formTemp: number = 0
@State formVet: string = ''
@State formClinic: string = ''
@State formCost: number = 0
@State formNotes: string = ''
这部分声明的是表单状态变量,以 form 为前缀,用于在各模态弹窗的输入框中双向绑定数据。formType 默认值为 '体检',因为"体检"是最常见的记录类型。这些表单状态的设计思路是将所有弹窗的输入数据统一管理在主组件中,而非分散到各弹窗内部——这样做的好处是数据流清晰可控,坏处是状态变量较多。在实际企业级开发中,可以考虑将这些表单状态封装到一个单独的 @Observed 类中以降低主组件的状态复杂度,但本应用采用扁平化管理的方式更为直观。
五、生命周期与数据初始化:aboutToAppear
aboutToAppear(): void {
this.pets = [
new PetModel(1, '旺财', '狗', '金毛寻回犬', 3, 28.5, '2022-05-12', '🐕', '#E91E63', '#FCE4EC'),
new PetModel(2, '咪咪', '猫', '英国短毛猫', 2, 4.8, '2023-08-20', '🐱', '#AD1457', '#F8BBD0'),
new PetModel(3, '雪球', '兔', '荷兰垂耳兔', 1.5, 2.1, '2024-02-14', '🐰', '#C2185B', '#FCE4EC'),
new PetModel(4, '布丁', '狗', '柯基犬', 4, 12.3, '2021-03-08', '🐶', '#880E4F', '#F8BBD0'),
new PetModel(5, '橘子', '猫', '橘猫', 5, 7.2, '2020-11-25', '🐱', '#E91E63', '#FCE4EC'),
new PetModel(6, '坚果', '仓鼠', '金丝熊', 0.5, 0.15, '2025-01-05', '🐹', '#AD1457', '#F8BBD0')
]
aboutToAppear 是 ArkUI 组件的生命周期回调函数,在组件实例创建后、build() 方法执行前被调用。它是进行数据初始化的理想位置——此时组件的 @State 变量已经完成初始赋值,但 UI 尚未渲染,因此在这里修改状态不会触发额外的重绘开销。
这里初始化了6只宠物的模拟数据,覆盖了狗、猫、兔、仓鼠四种常见宠物类型。每只宠物的 color 和 avatarBg 都使用了粉色系的不同深浅(#E91E63、#AD1457、#C2185B、#880E4F 等),保持了应用整体视觉风格的一致性。体重数据从0.15kg(仓鼠)到28.5kg(金毛),跨度极大,这为后续的体重监测可视化提供了丰富的数据样本。值得注意的是,所有数据均为硬编码的模拟数据(mock data),在实际产品中,这些数据应当来自网络请求或本地数据库(如 HarmonyOS 的 relationalStore),但作为演示和教学用途,硬编码数据能够确保应用在无网络环境下也能完整运行。
this.healthRecords = [
new HealthRecModel(1, 1, '体检', '2024-01-10', 27.8, 38.5, '李医生', '爱宠动物医院', 380, '常规体检一切正常', '', true),
new HealthRecModel(2, 1, '疫苗', '2024-03-15', 28.0, 38.3, '王医生', '爱宠动物医院', 250, '狂犬疫苗加强针', '2025-03-15', true),
new HealthRecModel(3, 1, '驱虫', '2024-06-20', 28.3, 38.6, '李医生', '爱宠动物医院', 120, '体内外驱虫', '2024-09-20', true),
new HealthRecModel(4, 1, '生病', '2024-09-05', 28.1, 39.2, '张医生', '瑞鹏宠物医院', 850, '腹泻,服用益生菌后好转', '', true),
new HealthRecModel(5, 1, '美容', '2024-11-10', 28.5, 38.4, '赵美容师', '萌宠美容院', 280, '洗澡+修剪指甲+耳道清洁', '', true),
// ... 其余记录省略展示,共22条
]
健康记录的初始化共包含22条数据,分布在6只宠物之间。每条记录都包含了完整的医疗信息:记录类型(体检/疫苗/驱虫/生病/美容五种)、日期、体重、体温、兽医姓名、诊所名称、费用、备注、下次复诊时间和完成状态。数据设计上,“旺财”(id=1)拥有最多的5条记录,而"坚果"(id=6)作为新宠仅有2条记录,这种不均匀分布更接近真实场景。部分记录的 nextVisit 字段为空字符串(如体检和美容类记录),表示无需复诊;而疫苗和驱虫类记录则填写了明确的复诊日期,用于驱动前文的健康提醒功能。
this.alerts = [
{ emoji: '⚠️', title: '旺财狂犬疫苗即将到期', detail: '上次接种: 2024-03-15,需在30天内补种', urgency: '高', bgColor: '#FFF3E0', borderColor: '#FF9800' },
{ emoji: '🔔', title: '布丁年度体检时间到', detail: '上次体检: 2024-01-20,建议每年体检一次', urgency: '中', bgColor: '#E3F2FD', borderColor: '#2196F3' },
{ emoji: '⚠️', title: '橘子驱虫逾期未做', detail: '上次驱虫: 2024-08-18,已超过建议间隔', urgency: '高', bgColor: '#FFEBEE', borderColor: '#F44336' },
{ emoji: '📅', title: '坚果新宠复查', detail: '新宠到家满一个月,建议复查一次', urgency: '低', bgColor: '#F3E5F5', borderColor: '#9C27B0' }
]
this.expenses = [
{ label: '体检', value: 2150, color: '#E91E63' },
{ label: '疫苗', value: 1160, color: '#C2185B' },
{ label: '驱虫', value: 480, color: '#AD1457' },
{ label: '生病', value: 2050, color: '#880E4F' },
{ label: '美容', value: 890, color: '#D81B60' },
{ label: '其他', value: 320, color: '#F06292' }
]
}
最后初始化了提醒列表和费用统计数据。提醒列表中的4条提醒分别对应不同的紧急程度,并且每条都配有与紧急程度匹配的颜色方案:高紧急度使用橙色(#FF9800)或红色(#F44336)背景,中紧急度使用蓝色(#2196F3),低紧急度使用紫色(#9C27B0)。这种"颜色即语义"的设计让用户一眼就能识别提醒的重要程度。费用统计则按6个类别汇总,其中"生病"和"体检"费用最高(分别为2050元和2150元),这与现实中宠物医疗的花费分布相符——大病治疗和定期体检往往是最大的支出项。
六、数据访问辅助方法
getPetName(petId: number): string {
for (let p of this.pets) {
if (p.id === petId) { return p.name }
}
return '未知'
}
getPetRecords(petId: number): HealthRecModel[] {
const records: HealthRecModel[] = []
for (let r of this.healthRecords) {
if (r.petId === petId) { records.push(r) }
}
return records
}
这两个方法是典型的"查询服务"方法。getPetName 根据宠物 ID 查询名称,采用线性遍历的方式在 pets 数组中查找匹配项,找不到时返回 '未知' 作为兜底值。这种兜底处理很重要——当健康记录中的 petId 引用了一个已不存在的宠物(比如宠物被删除后其记录仍残留),界面不会因为找不到名称而崩溃或显示空白。getPetRecords 则根据宠物 ID 筛选出该宠物的所有健康记录,返回一个新的数组。这里使用 for...of 循环配合 push 方式构建结果数组,是 ArkTS 中安全且清晰的写法。
getRecTypeIcon(type: string): string {
if (type === '体检') { return '🩺' }
if (type === '疫苗') { return '💉' }
if (type === '驱虫') { return '💊' }
if (type === '生病') { return '🤒' }
if (type === '美容') { return '✂️' }
return '📋'
}
getTotalCost(): number {
let total = 0
for (let r of this.healthRecords) { total += r.cost }
return total
}
formatAmount(n: number): string {
return '¥' + n.toLocaleString()
}
getRecTypeIcon 是一个类型到图标的映射函数,将记录类型的中文文字转换为对应的 Emoji 图标。使用连续的 if 语句而非 switch 或对象映射,虽然写法较为朴素,但在 ArkTS 环境中这种方式运行效率高且易于理解。最后的 return '📋' 作为默认值,处理了未匹配到任何已知类型的情况。getTotalCost 遍历所有健康记录累加费用,用于统计页面展示年度总花费。formatAmount 使用 JavaScript 原生的 toLocaleString() 方法将数字格式化为带千位分隔符的字符串并添加人民币符号,例如将 7050 格式化为 ¥7,050,提升了金额的阅读体验。
togglePet(idx: number): void {
const newExpanded: boolean[] = []
for (let i = 0; i < this.expandedPets.length; i++) {
if (i === idx) {
newExpanded[i] = !this.expandedPets[i]
} else {
newExpanded[i] = this.expandedPets[i]
}
}
this.expandedPets = newExpanded
}
togglePet 方法实现了宠物卡片的展开/折叠切换。这里有一个非常重要的 ArkTS 状态管理细节:没有直接修改 this.expandedPets[idx],而是创建了一个全新的数组 newExpanded,遍历原数组将所有值复制过来,仅修改目标索引的值,最后将新数组整体赋值给 this.expandedPets。这种"不可变更新"(immutable update)模式是 ArkUI 中触发 @State 响应式更新的关键——直接修改数组元素的属性或通过索引赋值,框架可能无法检测到变化,而替换整个数组引用则能确保状态变更被正确捕获,从而触发依赖该状态的 UI 重新渲染。
七、业务操作方法
doAddPet(): void {
this.showAddPet = false
}
doEditPet(): void {
this.showEditPet = false
}
doDeletePet(): void {
if (this.selectedPetIdx >= 0 && this.selectedPetIdx < this.pets.length) {
this.pets.splice(this.selectedPetIdx, 1)
}
this.showDeletePet = false
this.selectedPetIdx = -1
}
doAddRecord(): void {
this.showAddRecord = false
}
这四个方法分别对应四种操作的实际执行逻辑。doAddPet、doEditPet 和 doAddRecord 目前仅实现了关闭弹窗的功能(将对应的 show 状态设为 false),实际的持久化逻辑(如将表单数据写入数组或数据库)留待扩展——这是一个合理的 MVP(最小可行产品)策略,先搭建 UI 框架和交互流程,再逐步补充数据持久化能力。
doDeletePet 是唯一实现了完整业务逻辑的方法。它首先进行边界检查(selectedPetIdx >= 0 && selectedPetIdx < this.pets.length),确保索引有效后才调用 splice 删除对应位置的宠物。这种防御性编程非常重要,避免了因索引越界导致的运行时异常。删除完成后,依次关闭弹窗并重置选中索引。值得注意的是,splice 方法会直接修改原数组,在 ArkUI 中这种数组变异操作通常能被 @State 正确捕获,因为框架对数组的 push、pop、splice 等方法进行了劫持监听。
openEditPet(idx: number): void {
this.selectedPetIdx = idx
this.formName = this.pets[idx].name
this.formSpecies = this.pets[idx].species
this.formBreed = this.pets[idx].breed
this.formAge = this.pets[idx].age
this.formWeight = this.pets[idx].weight
this.formBirthday = this.pets[idx].birthday
this.showEditPet = true
}
openDeletePet(idx: number): void {
this.selectedPetIdx = idx
this.showDeletePet = true
}
openAddRecord(idx: number): void {
this.selectedPetIdx = idx
this.showAddRecord = true
}
这三个 open 方法是打开弹窗的入口。openEditPet 的设计尤为精妙:它在打开编辑弹窗之前,先将目标宠物的现有数据"回填"到表单状态变量中(formName、formSpecies 等),这样编辑弹窗的输入框会自动显示当前宠物的信息,用户只需修改需要变更的字段即可。这种"先填充后展示"的模式是编辑表单的标准实践。openDeletePet 和 openAddRecord 则相对简单,仅记录选中索引并打开对应弹窗。
八、底部导航构建器
@Builder bottomNav() {
Row() {
this.bottomTabItem('🐾', '宠物', PetTab.PETS)
this.bottomTabItem('💊', '健康', PetTab.STATS)
this.bottomTabItem('📊', '统计', PetTab.STATS)
this.bottomTabItem('👤', '我的', PetTab.PROFILE)
}
.width('100%').height(56)
.backgroundColor('#FFFFFF')
.padding({ bottom: 4 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
bottomNav 是底部导航栏的构建器,使用 @Builder 装饰器定义。@Builder 是 ArkUI 中用于封装可复用 UI 片段的装饰器,类似于其他框架中的"渲染函数"或"模板组件"。它接收参数并返回 UI 描述,但不具备独立的状态管理能力(与 @Component 不同)。这里使用 Row 容器横向排列四个 bottomTabItem,每个 tab 项通过参数传入图标、标签和对应的 PetTab 枚举值。容器高度设为56vp(虚拟像素),背景为白色,底部内边距4vp。shadow 属性配置了向上偏移(offsetY: -2)的阴影,营造出导航栏悬浮于内容上方的视觉层次感。阴影颜色 #1A000000 中的 1A 是十六进制的透明度(约10%),确保阴影柔和而不突兀。
@Builder bottomTabItem(icon: string, label: string, tab: PetTab) {
Column() {
Text(icon).fontSize(22)
.opacity(this.activeTab === tab ? 1.0 : 0.4)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? '#E91E63' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column().width(16).height(3)
.backgroundColor('#E91E63').borderRadius(2).margin({ top: 3 })
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 4 })
.onClick(() => { this.activeTab = tab })
}
bottomTabItem 是单个导航项的构建器,展示了 ArkUI 中条件渲染和样式动态化的典型用法。通过三元运算符 this.activeTab === tab ? ... : ...,根据当前激活的 Tab 动态设置图标透明度(选中1.0/未选0.4)、标签颜色(选中珊瑚粉/未选灰色)和字重(选中粗体/未选常规)。最精妙的是底部的指示条:使用 if (this.activeTab === tab) 条件渲染一个宽16高3的圆角小色块,仅在选中状态下出现,为用户提供了清晰的视觉反馈。layoutWeight(1) 确保四个 tab 项均分导航栏宽度。onClick 回调通过箭头函数修改 this.activeTab,由于该变量是 @State 修饰的,修改后会自动触发整个底部导航栏的重新渲染。
九、模态弹窗系统
@Builder modalBg(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.45)').onClick(onClose)
}
modalBg 是一个高度复用的模态背景构建器,它接收一个 onClose 回调函数作为参数。这个背景层铺满整个屏幕,使用半透明黑色(rgba(0,0,0,0.45),即45%不透明度的黑色)覆盖在底层内容之上,营造出"聚焦弹窗"的视觉效果。点击背景区域会触发 onClose 回调关闭弹窗——这是移动端弹窗交互的标准模式,用户点击遮罩层即可dismiss,无需刻意寻找关闭按钮。将背景层抽离为独立 Builder 的好处是:所有弹窗(添加、编辑、删除、添加记录)都可以复用同一套背景逻辑,保持一致性并减少重复代码。
@Builder addPetModal() {
Column() {
this.modalBg(() => { this.showAddPet = false })
Column() {
Scroll() {
Column() {
Text('添加宠物').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 16 })
Text('宠物名字').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '取个好听的名字' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formName = v })
// ... 其余输入框类似结构
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(999)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
addPetModal 是添加宠物弹窗的完整构建逻辑。结构上分为两层:外层 Column 铺满屏幕并使用 zIndex(999) 确保浮于所有内容之上,通过 justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 让内层弹窗居中显示;内层是一个白色圆角卡片(borderRadius(18)),宽度90%,最大高度85%(通过 constraintSize 的 maxHeight 限制)。
弹窗内容使用 Scroll 包裹,这是处理表单内容可能超出屏幕高度的标准方案——当输入框较多时(本例有6个输入字段),用户可以上下滚动查看和填写。每个输入字段都遵循统一的模式:先是一个标签 Text(左对齐),然后是 TextInput 输入框,背景设为浅灰色(#F5F5F5)以区分于白色卡片背景。onChange 回调将输入值实时同步到对应的 @State 表单变量中,实现了数据的双向绑定。数字类型的输入框(年龄、体重)额外添加了 .type(InputType.Number),调起数字键盘,提升输入效率。底部是"取消"和"添加"两个按钮,使用 Row 布局,中间用 Column().layoutWeight(1) 撑开空间实现两端对齐。
@Builder editPetModal() {
Column() {
this.modalBg(() => { this.showEditPet = false })
Column() {
Scroll() {
Column() {
Text('编辑宠物信息').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 16 })
Text('宠物名字').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formName })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formName = v })
// ... 其余输入框使用 text: this.formXxx 回填
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1000)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
editPetModal 与 addPetModal 结构高度相似,但有一个关键区别:TextInput 使用了 text: this.formName 而非 placeholder。这是编辑场景的核心需求——输入框需要显示现有的数据值而非占位提示。由于 openEditPet 方法已经在打开弹窗前将宠物数据回填到表单状态中,这里的 text 参数会绑定到对应的 @State 变量,实现初始值的自动显示。zIndex 设为1000,高于添加弹窗的999,确保两者不会同时出现时编辑弹窗在最上层。
@Builder deletePetModal() {
Column() {
this.modalBg(() => { this.showDeletePet = false })
Column() {
Text('确认删除').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 8 })
Text('删除宠物将同时清空其所有健康记录,确认删除?').fontSize(14)
.fontColor('#666666').textAlign(TextAlign.Center)
.padding({ left: 16, right: 16 }).margin({ bottom: 20 })
Row() {
Text('取消').fontSize(15).fontColor('#666666')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.showDeletePet = false })
Column().width(16)
Text('删除').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E53935').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.doDeletePet() })
}
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
.padding(24).alignItems(HorizontalAlign.Center)
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1001)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
deletePetModal 是一个确认对话框,设计上比表单弹窗更简洁——宽度仅80%,没有 Scroll 包裹(内容少,无需滚动)。弹窗包含标题"确认删除"、说明文字和两个按钮。说明文字明确告知用户删除操作的后果(“将同时清空其所有健康记录”),这是良好的交互设计实践——破坏性操作必须让用户充分知晓影响范围。按钮设计上,"取消"使用灰色背景(中性色调),"删除"使用红色背景(#E53935,警示色调),通过颜色强化了操作的严重性。两个按钮之间用 Column().width(16) 作为间距分隔。
@Builder addRecordModal() {
Column() {
this.modalBg(() => { this.showAddRecord = false })
Column() {
Scroll() {
Column() {
Text('添加健康记录').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 16 })
Text(this.formName + ' — 记录类型').fontSize(13).fontColor('#E91E63').margin({ bottom: 8 }).alignSelf(ItemAlign.Start)
Row() {
this.recordTypeChip('体检', '🩺')
this.recordTypeChip('疫苗', '💉')
this.recordTypeChip('驱虫', '💊')
this.recordTypeChip('生病', '🤒')
this.recordTypeChip('美容', '✂️')
}.width('100%').margin({ bottom: 12 })
// ... 日期、体重、体温、兽医、诊所、费用、备注等输入框
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('92%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '88%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1002)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
addRecordModal 是所有弹窗中字段最多的一个,包含记录类型选择器(5个 Chip)、日期、体重、体温、兽医姓名、诊所、费用、备注共8个输入项。弹窗宽度设为92%(比其他弹窗更宽),最大高度88%(比其他弹窗更高),以容纳更多内容。顶部显示当前操作的宠物名称(this.formName),让用户清楚知道正在为哪只宠物添加记录。记录类型使用 Chip 组件(通过 recordTypeChip 构建器实现)而非下拉选择,提供了更直观的图形化选择体验。
@Builder recordTypeChip(label: string, emoji: string) {
Row() {
Text(emoji).fontSize(13).margin({ right: 3 })
Text(label).fontSize(12)
}
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.formType === label ? '#E91E63' : '#F5F5F5')
.borderRadius(14).margin({ right: 6 })
.onClick(() => { this.formType = label })
}
recordTypeChip 是记录类型选择器的单个 Chip 构建器。每个 Chip 包含一个 Emoji 图标和一个文字标签,横向排列。背景色根据 this.formType 是否等于当前 label 动态切换:选中时为珊瑚粉(#E91E63)配白色文字,未选中时为浅灰色(#F5F5F5)。圆角设为14,呈现出药丸形状。onClick 将 formType 设置为当前 Chip 对应的类型文字,由于 formType 是 @State 变量,修改后所有 Chip 会自动重新渲染以更新选中状态。这种"单选 Chip 组"是移动端表单中非常流行的交互模式,比传统的下拉选择框操作效率更高。
十、宠物卡片与提醒卡片构建
@Builder alertCard(alert: AlertItem) {
Row() {
Text(alert.emoji).fontSize(22).margin({ right: 10 })
Column() {
Text(alert.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(alert.detail).fontSize(11).fontColor('#888888').margin({ top: 2 }).maxLines(2)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(alert.urgency).fontSize(10).fontColor('#FFFFFF')
.backgroundColor(alert.borderColor).borderRadius(6)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
.width('100%').backgroundColor(alert.bgColor)
.border({ width: 1, color: alert.borderColor }).borderRadius(10)
.padding(12).margin({ bottom: 8 })
}
alertCard 构建健康提醒卡片。卡片整体为 Row 布局:左侧是22号字体的 Emoji 图标,中间是标题和详情的纵向排列(详情限制最多2行 maxLines(2),避免过长文本撑爆卡片),右侧是紧急程度标签。背景色和边框色直接取自 alert 数据对象中的 bgColor 和 borderColor 字段——这正是前文将颜色信息嵌入数据模型的设计所带来的便利,每个提醒卡片无需任何条件判断就能自动呈现与紧急程度匹配的视觉风格。边框宽度1,圆角10,整体呈现出柔和但醒目的卡片外观。
@Builder petCard(pet: PetModel, idx: number) {
Column() {
Row() {
Column() {
Text(pet.emoji).fontSize(40)
}
.width(64).height(64).backgroundColor(pet.avatarBg).borderRadius(32)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(pet.name).fontSize(17).fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
Text(pet.breed).fontSize(11).fontColor('#999999')
.backgroundColor('#F5F5F5').borderRadius(6)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}.width('100%')
Row() {
Text(pet.species + ' · ' + pet.age.toString() + '岁').fontSize(12).fontColor('#888888')
Text(pet.weight.toString() + 'kg').fontSize(12).fontColor('#E91E63')
.backgroundColor('#FCE4EC').borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 8 })
Text('🎂' + pet.birthday).fontSize(11).fontColor('#ADADAD').margin({ left: 8 })
}.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}.width('100%')
if (this.expandedPets[idx]) {
Column() {
// 分割线、指标行、近期记录、操作按钮
}
}
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(16)
.padding(14).margin({ bottom: 10 })
.shadow({ radius: 6, color: '#15000000', offsetY: 2 })
.onClick(() => { this.togglePet(idx) })
}
petCard 是应用中最复杂的构建器之一,负责渲染单只宠物的完整卡片。卡片分为折叠态和展开态两部分,通过 if (this.expandedPets[idx]) 条件控制。
折叠态(始终显示)部分采用 Row 横向布局:左侧是一个64x64的圆形头像区域(borderRadius(32) 即半径32,等于宽高的一半,形成正圆),背景色取自 pet.avatarBg,中央放置40号字体的宠物 Emoji。右侧是信息区域,第一行显示宠物名称(粗体)和品种标签(灰色背景小色块),中间用 Column().layoutWeight(1) 撑开实现两端对齐;第二行显示物种与年龄、体重(珊瑚粉背景标签)和生日,信息密度适中。
展开态部分在折叠态信息下方追加显示:一条粉色分割线、三个指标块(体重/年龄/记录数)、一条近期健康记录、以及三个操作按钮(编辑/添加记录/删除)。操作按钮采用边框样式(编辑和删除)和填充样式(添加记录)的混搭,通过颜色区分功能——珊瑚粉代表编辑和添加操作,红色代表删除操作。整个卡片的 onClick 绑定 togglePet(idx),点击卡片任意位置即可切换展开/折叠状态。
@Builder petMetric(label: string, value: string, icon: string, color: string) {
Column() {
Text(icon).fontSize(18)
Text(value).fontSize(13).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 4 })
Text(label).fontSize(10).fontColor('#999999').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFF5F8').borderRadius(10).padding({ top: 10, bottom: 10 })
}
@Builder petRecordItem(record: HealthRecModel) {
Row() {
Text(this.getRecTypeIcon(record.type)).fontSize(18).margin({ right: 8 })
Column() {
Text(record.type + ' · ' + record.date).fontSize(12).fontColor('#333333')
Text(record.notes).fontSize(10).fontColor('#999999').maxLines(1).margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.formatAmount(record.cost)).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#E91E63')
}
.width('100%').backgroundColor('#FFF5F8').borderRadius(8)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
}
petMetric 和 petRecordItem 是 petCard 展开态中使用的两个子构建器。petMetric 渲染一个指标块,包含图标、数值和标签三层信息,使用 layoutWeight(1) 实现等宽分布,背景为淡粉色(#FFF5F8)。petRecordItem 渲染单条健康记录摘要,左侧是类型图标,中间是类型与日期的组合文字以及备注(限制1行),右侧是格式化后的费用金额。两者都使用了淡粉色背景,与卡片整体风格保持协调。
十一、宠物 Tab 页:petsTab
@Builder petsTab() {
Scroll() {
Column() {
Text('健康提醒').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.padding({ left: 16, top: 16, bottom: 4 }).alignSelf(ItemAlign.Start)
Column() {
this.alertCard(this.alerts[0])
this.alertCard(this.alerts[1])
this.alertCard(this.alerts[2])
this.alertCard(this.alerts[3])
}
.padding({ left: 12, right: 12 })
Row() {
Text('我的宠物').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
Text('+ 添加').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(16)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.showAddPet = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
Column() {
this.petCard(this.pets[0], 0)
this.petCard(this.pets[1], 1)
this.petCard(this.pets[2], 2)
this.petCard(this.pets[3], 3)
this.petCard(this.pets[4], 4)
this.petCard(this.pets[5], 5)
}
.padding({ left: 12, right: 12 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
petsTab 是应用的默认首页(宠物 Tab),整体使用 Scroll 包裹以支持内容滚动,scrollBar(BarState.Off) 隐藏了滚动条,保持界面简洁。页面内容自上而下分为三个区块:
第一区块是"健康提醒"区域,标题左对齐,下方纵向排列4个提醒卡片。第二区块是"我的宠物"区域标题行,标题左侧,"+ 添加"按钮右侧(通过 Column().layoutWeight(1) 撑开实现两端对齐),点击按钮设置 showAddPet = true 触发添加宠物弹窗。第三区块是6张宠物卡片的纵向列表。页面底部留有20vp的空白间距,避免最后一张卡片紧贴底部导航栏。
这里有一个值得注意的实现细节:宠物卡片是通过索引逐一调用 this.petCard(this.pets[0], 0) 的方式渲染的,而非使用 ForEach 循环。这种写法虽然冗长,但在 ArkUI 中能确保每张卡片的状态(如展开/折叠)与索引精确对应,避免了 ForEach 在某些场景下因 key 生成策略导致的渲染异常。对于固定数量的列表,这种"展开式"渲染是完全合理的。
十二、健康记录 Tab 页:healthTab
@Builder healthTab() {
Scroll() {
Column() {
Row() {
Text('健康记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
Text('+ 新记录').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(16)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.showAddRecord = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
Column() {
Text('2024年').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#AD1457').margin({ bottom: 8 })
this.healthRecordCard(this.healthRecords[0])
// ... 共22条记录卡片
this.healthRecordCard(this.healthRecords[21])
}
.padding({ left: 12, right: 12, bottom: 16 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
healthTab 展示所有健康记录的时间线列表。页面顶部是标题行和"+ 新记录"按钮,点击触发 showAddRecord = true 打开添加记录弹窗。主体内容区显示"2024年"年份标题(使用深粉色 #AD1457 强调),下方纵向排列全部22条健康记录卡片。这里将所有记录统一显示在"2024年"分组下,简化了分组逻辑——在实际产品中,可以按月份或季度进一步细分,但本应用以年份为粒度已经足够清晰。
@Builder healthRecordCard(record: HealthRecModel) {
Row() {
Column() {
Text(this.getRecTypeIcon(record.type)).fontSize(24)
}
.width(44).height(44).backgroundColor('#FCE4EC').borderRadius(22)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(this.getPetName(record.petId) + ' · ' + record.type).fontSize(14)
.fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
if (record.isCompleted) {
Text('已完成').fontSize(9).fontColor('#4CAF50')
.backgroundColor('#E8F5E9').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
} else {
Text('待处理').fontSize(9).fontColor('#FF9800')
.backgroundColor('#FFF3E0').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
}.width('100%')
Row() {
Text(record.date).fontSize(11).fontColor('#888888')
Text(record.vetName).fontSize(11).fontColor('#AD1457').margin({ left: 8 })
Text(record.clinic).fontSize(11).fontColor('#AAAAAA').margin({ left: 4 })
}.margin({ top: 2 })
Row() {
Text('⚖️' + record.weight.toString() + 'kg').fontSize(10).fontColor('#888888')
Text('🌡️' + record.temperature.toString() + '°C').fontSize(10).fontColor('#888888').margin({ left: 8 })
Text(this.formatAmount(record.cost)).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor('#E91E63').margin({ left: 8 })
}.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding(12).margin({ bottom: 8 })
.shadow({ radius: 3, color: '#10000000', offsetY: 1 })
}
healthRecordCard 是单条健康记录的卡片构建器,信息密度较高。卡片左侧是一个44x44的圆形图标区域(浅粉色背景 #FCE4EC),中央放置记录类型对应的 Emoji。右侧信息区分为三行:
第一行是宠物名称与记录类型的组合标题,以及完成状态标签。完成状态使用 if...else 条件渲染:"已完成"显示为绿色(#4CAF50)配浅绿背景(#E8F5E9),“待处理"显示为橙色(#FF9800)配浅橙背景(#FFF3E0)。这种颜色编码让用户能快速扫描出哪些记录还需要跟进。第二行是日期、兽医姓名(深粉色强调)和诊所名称。第三行是体重(带⚖️图标)、体温(带🌡️图标)和格式化费用(珊瑚粉粗体)。三行信息层次分明,从"谁做了什么"到"何时何地"再到"具体数据”,符合用户的阅读习惯。
十三、统计 Tab 页:statsTab
@Builder statsTab() {
Scroll() {
Column() {
Row() {
Column() {
Text(this.getTotalCost().toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('年度总花费(元)').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
Column() {
Text(this.healthRecords.length.toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#AD1457')
Text('健康记录').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 8 })
Column() {
Text(this.pets.length.toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('毛孩子').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 8 })
}
.width('100%').padding({ left: 12, right: 12, top: 12 })
// ... 后续区块
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
statsTab 是数据统计页面,顶部是三个并排的核心指标卡片,使用 Row 横向排列,每个 Column 通过 layoutWeight(1) 等分宽度。三个指标分别是:年度总花费(调用 getTotalCost() 实时计算)、健康记录总数(healthRecords.length)和宠物数量(pets.length)。数值使用28号粗体字体,颜色交替使用珊瑚粉和深粉色,下方是11号灰色标签。这种"大数字+小标签"的卡片设计是数据仪表盘的经典模式,让关键数据一目了然。
Column() {
Text('花费分类').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#333333').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
this.expenseBar(this.expenses[0])
this.expenseBar(this.expenses[1])
this.expenseBar(this.expenses[2])
this.expenseBar(this.expenses[3])
this.expenseBar(this.expenses[4])
this.expenseBar(this.expenses[5])
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, top: 12, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
花费分类区块使用 expenseBar 构建器渲染6个横向条形图。整个区块包裹在白色圆角卡片中,标题"花费分类"左对齐,下方是6条费用条形图。
@Builder expenseBar(item: ExpenseBarItem) {
Column() {
Row() {
Text(item.label).fontSize(12).fontColor('#666666').width(40)
Row() {
Column()
.width((item.value * 100 / this.maxExpense).toString() + '%')
.height(16).backgroundColor(item.color)
.borderRadius({ topLeft: 8, bottomLeft: 8 })
if (item.value < this.maxExpense) {
Column().layoutWeight(1)
}
}
.layoutWeight(1).height(16).backgroundColor('#F5F5F5').borderRadius(8)
Text('¥' + item.value.toString()).fontSize(11).fontColor('#333333').width(60).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
}
.margin({ bottom: 10 })
}
expenseBar 是横向条形图的构建器,设计巧妙。每行包含三部分:左侧40vp宽的标签文字、中间的条形图区域、右侧60vp宽的金额数字。条形图区域是一个 Row,背景为浅灰色(#F5F5F5)作为轨道,内部有一个着色的 Column 作为数据条。数据条的宽度通过百分比计算:(item.value * 100 / this.maxExpense).toString() + '%',即当前值占最大值的百分比。这里有一个重要的细节:当 item.value < this.maxExpense 时,数据条后面追加一个 Column().layoutWeight(1) 来填充剩余空间——这是为了让数据条保持精确的百分比宽度而非被 layoutWeight 拉伸。数据条左侧圆角(topLeft: 8, bottomLeft: 8)使其呈现为圆头形状,视觉效果更加精致。
Column() {
Text('各宠物健康概览').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#333333').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
this.petOverviewBar(this.pets[0])
// ... 6条宠物概览条
}
各宠物健康概览区块使用 petOverviewBar 展示每只宠物的记录数量对比。
@Builder petOverviewBar(pet: PetModel) {
Row() {
Text(pet.emoji).fontSize(20).margin({ right: 8 })
Text(pet.name).fontSize(13).fontColor('#333333').width(40)
Row() {
Column()
.width((this.getPetRecords(pet.id).length * 100 / 5).toString() + '%')
.height(12).backgroundColor(pet.color)
.borderRadius({ topLeft: 6, bottomLeft: 6 })
Column().layoutWeight(1)
}
.layoutWeight(1).height(12).backgroundColor('#F5F5F5').borderRadius(6)
Text(this.getPetRecords(pet.id).length.toString() + '条').fontSize(10).fontColor('#999999').width(30).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
}
petOverviewBar 与 expenseBar 结构类似,但数据条宽度以记录数除以5(假设5条为满格基准)计算百分比。这里每只宠物使用自己专属的 pet.color 作为数据条颜色,实现了视觉上的宠物身份关联。右侧显示具体记录条数(如"5条")。
Column() {
Text('体重监测').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#333333').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
Row() {
Column() {
Text('28.5').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('旺财(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
// ... 其余5只宠物的体重数据
}
.width('100%')
}
体重监测区块以3x2的网格布局展示6只宠物的当前体重,每个网格单元显示22号粗体数字和10号灰色标签。数字颜色使用不同的粉色系深浅,与前文宠物卡片的颜色保持一致。虽然这里展示的是静态数值而非趋势图,但数据接口中预留的 WeightPoint 接口表明,未来可以扩展为折线图展示体重变化趋势。
十四、个人中心 Tab 页:profileTab
@Builder profileTab() {
Scroll() {
Column() {
Column() {
Column().width(72).height(72).backgroundColor('#F8BBD0').borderRadius(36)
Row() {
Column().width(72).height(72).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Text('🐾').fontSize(32)
}
Column()
Text('宠物家长').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333').margin({ top: 12 })
Text('用心守护每一个毛孩子的健康').fontSize(13).fontColor('#999999').margin({ top: 4 })
}
.width('100%').alignItems(HorizontalAlign.Center).padding({ top: 30, bottom: 20 })
profileTab 是个人中心页面。顶部是用户头像和基本信息区域:一个72x72的圆形头像(浅粉色背景 #F8BBD0),中央放置🐾图标。这里使用了一个稍显特殊的布局技巧——先放一个空白的圆形 Column 作为背景,再用一个 Row 包裹同样尺寸的 Column 放置图标,通过这种"叠放"方式实现背景色与图标的位置组合。下方是"宠物家长"标题和"用心守护每一个毛孩子的健康"的个性签名,文字居中对齐,营造出温暖的品牌氛围。
Row() {
Column() {
Text(this.pets.length.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('宠物').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.healthRecords.length.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('记录').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.getTotalCost().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('花费(元)').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 16, bottom: 16 }).margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
个人信息下方是三列统计数据卡片(宠物数、记录数、总花费),结构与统计页的顶部指标类似但尺寸更小(24号字体)。数据通过 @State 变量实时计算,确保与其它页面的数据保持同步。
Column() {
this.profileMenuRow('🐕', '我的宠物档案', '管理宠物信息')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('📋', '健康记录归档', this.healthRecords.length.toString() + '条记录')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('💉', '疫苗接种提醒', '查看接种计划')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('🏥', '医院收藏', '3家常去诊所')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('⚙️', '设置', '提醒与偏好')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
下方是设置菜单列表,包含5个菜单项,每两项之间用1vp高的粉色分割线(#FDE0EB)分隔。菜单项涵盖宠物档案管理、健康记录归档、疫苗接种提醒、医院收藏和设置等功能入口。部分菜单项的副标题使用了动态数据(如健康记录归档显示 this.healthRecords.length.toString() + '条记录'),让信息保持实时性。
@Builder profileMenuRow(icon: string, title: string, sub: string) {
Row() {
Text(icon).fontSize(18).margin({ right: 12 })
Column() {
Text(title).fontSize(15).fontColor('#333333')
Text(sub).fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('>').fontSize(14).fontColor('#CCCCCC')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
profileMenuRow 是单个菜单项的构建器,采用标准的"左图标+中标题副标题+右箭头"布局。右侧的">"符号作为导航指示,提示用户该项可点击进入下一级页面。整体内边距为水平16、垂直14,保证了足够的点击区域和视觉留白。
十五、主构建方法:build
build() {
Stack() {
Column() {
Row() {
Text('宠物健康').fontSize(22).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column().layoutWeight(1)
Text('🐾').fontSize(24)
}
.width('100%').height(52).backgroundColor('#E91E63')
.padding({ left: 16, right: 16 })
if (this.activeTab === PetTab.PETS) {
this.petsTab()
} else if (this.activeTab === PetTab.HEALTH) {
this.healthTab()
} else if (this.activeTab === PetTab.STATS) {
this.statsTab()
} else {
this.profileTab()
}
this.bottomNav()
}
.width('100%').height('100%')
.backgroundColor('#FCE4EC')
if (this.showAddPet) { this.addPetModal() }
if (this.showEditPet) { this.editPetModal() }
if (this.showDeletePet) { this.deletePetModal() }
if (this.showAddRecord) { this.addAddRecordModal() }
}
.width('100%').height('100%')
}
build 方法是整个组件的核心,定义了页面的最终渲染结构。最外层使用 Stack 布局——这是 ArkUI 中的层叠布局容器,子元素默认居中堆叠,后声明的子元素覆盖在先声明的之上。这里 Stack 的作用是实现模态弹窗的层叠覆盖效果。
Stack 内部第一层是一个 Column,包含三部分:顶部标题栏(52vp高,珊瑚粉背景,白色"宠物健康"标题和🐾图标)、中间的 Tab 内容区(通过 if...else if...else 条件渲染当前激活的 Tab 页构建器)、底部导航栏。整个 Column 的背景设为浅粉色 #FCE4EC,作为应用的底色。
Stack 内部第二层是四个模态弹窗的条件渲染:if (this.showAddPet)、if (this.showEditPet)、if (this.showDeletePet)、if (this.showAddRecord)。只有当对应的 show 状态为 true 时,弹窗才会被渲染到 Stack 中并覆盖在底层内容之上。由于 Stack 的层叠特性,后声明的弹窗(如 addRecordModal)会覆盖在先声明的弹窗(如 addPetModal)之上——这与各弹窗的 zIndex 设置(999到1002)共同确保了弹窗的层级顺序正确。
这种"主内容+条件弹窗"的 Stack 布局模式是 ArkUI 中实现模态交互的标准方案,相比使用系统级的 Dialog 组件,这种方式提供了更高的样式定制自由度,且所有弹窗的状态都由组件内部的 @State 管理,数据流清晰可控。
十六、各模块关键技术点总结对比
下面通过表格对本应用四个 Tab 页(模块)的关键技术点进行横向对比总结:
| 对比维度 | 宠物 Tab(PETS) | 健康 Tab(HEALTH) | 统计 Tab(STATS) | 个人中心 Tab(PROFILE) |
|---|---|---|---|---|
| 核心功能 | 宠物档案展示、健康提醒、宠物增删改 | 健康记录时间线展示与添加 | 费用与记录数据可视化统计 | 用户信息与功能入口聚合 |
| 状态管理要点 | expandedPets 数组控制卡片展开折叠;showAddPet/showEditPet/showDeletePet 控制弹窗 |
showAddRecord 控制记录弹窗;healthRecords 数组驱动列表渲染 |
expenses/maxExpense 驱动条形图;getTotalCost() 实时计算汇总 |
pets.length/healthRecords.length 实时统计展示 |
| 关键组件 | petCard、alertCard、petMetric、petRecordItem、recordTypeChip |
healthRecordCard |
expenseBar、petOverviewBar、指标卡片 |
profileMenuRow、统计卡片 |
| 数据来源 | pets 数组、alerts 数组、expandedPets 状态 |
healthRecords 数组 |
expenses 数组 + getTotalCost()/getPetRecords() 计算结果 |
pets/healthRecords 的 length 与汇总值 |
| 布局模式 | Scroll + Column 纵向列表;Row 横向信息行;条件展开 if |
Scroll + Column 纵向时间线;Row 卡片内三行信息 |
Scroll + Column;Row 三等分指标;横向条形图 |
Scroll + Column;居中头像区 + Row 三等分统计 + 菜单列表 |
| 设计模式 | 卡片折叠/展开模式(手风琴);Chip 单选模式 | 时间线列表模式;状态标签条件渲染 | 仪表盘卡片模式;百分比条形图可视化 | 设置列表模式;分割线分隔 |
| 交互特点 | 点击卡片切换展开;弹窗表单回填编辑 | 按年份分组展示;完成状态颜色编码 | 实时数据计算驱动可视化;多维度统计 | 菜单项导航入口;动态数据副标题 |
| 视觉风格 | 白色卡片 + 粉色点缀;圆角16 + 阴影 | 白色卡片 + 浅粉图标背景;状态色标签 | 白色统计卡片 + 彩色条形图;大数字强调 | 居中头像 + 白色菜单卡片 + 粉色分割线 |
| 弹窗关联 | 添加/编辑/删除宠物弹窗(zIndex 999-1001) | 添加记录弹窗(zIndex 1002) | 无弹窗 | 无弹窗 |
| 复用构建器 | modalBg、petCard、petMetric、petRecordItem |
modalBg、healthRecordCard、recordTypeChip |
expenseBar、petOverviewBar |
profileMenuRow |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// 宠物健康追踪 — 珊瑚 #E91E63 | 粉色 #AD1457 | 背景 #FCE4EC
interface PetData {
id: number
name: string
species: string
breed: string
age: number
weight: number
birthday: string
emoji: string
color: string
avatarBg: string
}
interface HealthRecord {
id: number
petId: number
type: string
date: string
weight: number
temperature: number
vetName: string
clinic: string
cost: number
notes: string
nextVisit: string
isCompleted: boolean
}
interface AlertItem {
emoji: string
title: string
detail: string
urgency: string
bgColor: string
borderColor: string
}
interface MetricItem {
label: string
value: string
icon: string
color: string
}
interface ExpenseBarItem {
label: string
value: number
color: string
}
interface WeightPoint {
month: string
weight: number
}
@Observed
class PetModel {
id: number
name: string
species: string
breed: string
age: number
weight: number
birthday: string
emoji: string
color: string
avatarBg: string
constructor(id: number, name: string, species: string, breed: string,
age: number, weight: number, birthday: string,
emoji: string, color: string, avatarBg: string) {
this.id = id
this.name = name
this.species = species
this.breed = breed
this.age = age
this.weight = weight
this.birthday = birthday
this.emoji = emoji
this.color = color
this.avatarBg = avatarBg
}
}
@Observed
class HealthRecModel {
id: number
petId: number
type: string
date: string
weight: number
temperature: number
vetName: string
clinic: string
cost: number
notes: string
nextVisit: string
isCompleted: boolean
constructor(id: number, petId: number, type: string, date: string,
weight: number, temperature: number, vetName: string,
clinic: string, cost: number, notes: string,
nextVisit: string, isCompleted: boolean) {
this.id = id
this.petId = petId
this.type = type
this.date = date
this.weight = weight
this.temperature = temperature
this.vetName = vetName
this.clinic = clinic
this.cost = cost
this.notes = notes
this.nextVisit = nextVisit
this.isCompleted = isCompleted
}
}
enum PetTab {
PETS = 0,
HEALTH = 1,
STATS = 2,
PROFILE = 3
}
@Entry
@Component
struct PetHealthPage {
@State activeTab: PetTab = PetTab.PETS
@State pets: PetModel[] = []
@State healthRecords: HealthRecModel[] = []
@State showAddPet: boolean = false
@State showEditPet: boolean = false
@State showDeletePet: boolean = false
@State showAddRecord: boolean = false
@State selectedPetIdx: number = -1
@State selectedRecIdx: number = -1
@State alerts: AlertItem[] = []
@State expenses: ExpenseBarItem[] = []
@State maxExpense: number = 12000
@State expandedPets: boolean[] = [false, false, false, false, false, false]
@State formName: string = ''
@State formSpecies: string = ''
@State formBreed: string = ''
@State formAge: number = 0
@State formWeight: number = 0
@State formBirthday: string = ''
@State formType: string = '体检'
@State formDate: string = ''
@State formTemp: number = 0
@State formVet: string = ''
@State formClinic: string = ''
@State formCost: number = 0
@State formNotes: string = ''
aboutToAppear(): void {
this.pets = [
new PetModel(1, '旺财', '狗', '金毛寻回犬', 3, 28.5, '2022-05-12', '🐕', '#E91E63', '#FCE4EC'),
new PetModel(2, '咪咪', '猫', '英国短毛猫', 2, 4.8, '2023-08-20', '🐱', '#AD1457', '#F8BBD0'),
new PetModel(3, '雪球', '兔', '荷兰垂耳兔', 1.5, 2.1, '2024-02-14', '🐰', '#C2185B', '#FCE4EC'),
new PetModel(4, '布丁', '狗', '柯基犬', 4, 12.3, '2021-03-08', '🐶', '#880E4F', '#F8BBD0'),
new PetModel(5, '橘子', '猫', '橘猫', 5, 7.2, '2020-11-25', '🐱', '#E91E63', '#FCE4EC'),
new PetModel(6, '坚果', '仓鼠', '金丝熊', 0.5, 0.15, '2025-01-05', '🐹', '#AD1457', '#F8BBD0')
]
this.healthRecords = [
new HealthRecModel(1, 1, '体检', '2024-01-10', 27.8, 38.5, '李医生', '爱宠动物医院', 380, '常规体检一切正常', '', true),
new HealthRecModel(2, 1, '疫苗', '2024-03-15', 28.0, 38.3, '王医生', '爱宠动物医院', 250, '狂犬疫苗加强针', '2025-03-15', true),
new HealthRecModel(3, 1, '驱虫', '2024-06-20', 28.3, 38.6, '李医生', '爱宠动物医院', 120, '体内外驱虫', '2024-09-20', true),
new HealthRecModel(4, 1, '生病', '2024-09-05', 28.1, 39.2, '张医生', '瑞鹏宠物医院', 850, '腹泻,服用益生菌后好转', '', true),
new HealthRecModel(5, 1, '美容', '2024-11-10', 28.5, 38.4, '赵美容师', '萌宠美容院', 280, '洗澡+修剪指甲+耳道清洁', '', true),
new HealthRecModel(6, 2, '体检', '2024-02-08', 4.5, 38.8, '陈医生', '猫咪专科医院', 450, '血常规正常,心脏听诊正常', '', true),
new HealthRecModel(7, 2, '疫苗', '2024-04-22', 4.6, 38.5, '陈医生', '猫咪专科医院', 220, '猫三联疫苗', '2025-04-22', true),
new HealthRecModel(8, 2, '驱虫', '2024-07-15', 4.7, 38.7, '陈医生', '猫咪专科医院', 100, '体内驱虫', '2024-10-15', true),
new HealthRecModel(9, 2, '美容', '2024-10-08', 4.8, 38.5, '孙美容师', '萌宠美容院', 180, '洗澡+剪指甲', '', true),
new HealthRecModel(10, 3, '体检', '2024-03-05', 1.9, 38.9, '李医生', '异宠诊所', 320, '牙齿检查正常', '', true),
new HealthRecModel(11, 3, '驱虫', '2024-05-18', 2.0, 39.0, '李医生', '异宠诊所', 80, '体内驱虫', '2024-08-18', true),
new HealthRecModel(12, 3, '生病', '2024-08-02', 2.0, 39.5, '李医生', '异宠诊所', 520, '食欲不振,补充益生菌', '', true),
new HealthRecModel(13, 4, '体检', '2024-01-20', 12.0, 38.4, '王医生', '爱宠动物医院', 380, '腰椎X光正常,注意控制体重', '', true),
new HealthRecModel(14, 4, '疫苗', '2024-05-10', 12.1, 38.2, '王医生', '爱宠动物医院', 250, '六联疫苗', '2025-05-10', true),
new HealthRecModel(15, 4, '驱虫', '2024-08-25', 12.2, 38.5, '王医生', '爱宠动物医院', 120, '体外驱虫', '2024-11-25', true),
new HealthRecModel(16, 4, '美容', '2024-12-01', 12.3, 38.3, '赵美容师', '萌宠美容院', 250, '全套美容护理', '', true),
new HealthRecModel(17, 5, '体检', '2024-04-01', 7.0, 38.6, '陈医生', '猫咪专科医院', 420, '肾功能检查正常', '', true),
new HealthRecModel(18, 5, '疫苗', '2024-06-15', 7.1, 38.4, '陈医生', '猫咪专科医院', 220, '猫三联加强', '2025-06-15', true),
new HealthRecModel(19, 5, '生病', '2024-10-20', 7.1, 39.0, '陈医生', '猫咪专科医院', 680, '口腔炎症,洗牙+治疗', '', true),
new HealthRecModel(20, 5, '美容', '2024-11-28', 7.2, 38.5, '孙美容师', '萌宠美容院', 180, '洗澡+修毛', '', false),
new HealthRecModel(21, 6, '体检', '2024-02-01', 0.13, 37.5, '林医生', '异宠诊所', 200, '小宠体检一切正常', '', true),
new HealthRecModel(22, 6, '驱虫', '2024-04-10', 0.14, 37.6, '林医生', '异宠诊所', 60, '体外驱虫', '2024-07-10', true)
]
this.alerts = [
{ emoji: '⚠️', title: '旺财狂犬疫苗即将到期', detail: '上次接种: 2024-03-15,需在30天内补种', urgency: '高', bgColor: '#FFF3E0', borderColor: '#FF9800' },
{ emoji: '🔔', title: '布丁年度体检时间到', detail: '上次体检: 2024-01-20,建议每年体检一次', urgency: '中', bgColor: '#E3F2FD', borderColor: '#2196F3' },
{ emoji: '⚠️', title: '橘子驱虫逾期未做', detail: '上次驱虫: 2024-08-18,已超过建议间隔', urgency: '高', bgColor: '#FFEBEE', borderColor: '#F44336' },
{ emoji: '📅', title: '坚果新宠复查', detail: '新宠到家满一个月,建议复查一次', urgency: '低', bgColor: '#F3E5F5', borderColor: '#9C27B0' }
]
this.expenses = [
{ label: '体检', value: 2150, color: '#E91E63' },
{ label: '疫苗', value: 1160, color: '#C2185B' },
{ label: '驱虫', value: 480, color: '#AD1457' },
{ label: '生病', value: 2050, color: '#880E4F' },
{ label: '美容', value: 890, color: '#D81B60' },
{ label: '其他', value: 320, color: '#F06292' }
]
}
getPetName(petId: number): string {
for (let p of this.pets) {
if (p.id === petId) { return p.name }
}
return '未知'
}
getPetRecords(petId: number): HealthRecModel[] {
const records: HealthRecModel[] = []
for (let r of this.healthRecords) {
if (r.petId === petId) { records.push(r) }
}
return records
}
getRecTypeIcon(type: string): string {
if (type === '体检') { return '🩺' }
if (type === '疫苗') { return '💉' }
if (type === '驱虫') { return '💊' }
if (type === '生病') { return '🤒' }
if (type === '美容') { return '✂️' }
return '📋'
}
getTotalCost(): number {
let total = 0
for (let r of this.healthRecords) { total += r.cost }
return total
}
formatAmount(n: number): string {
return '¥' + n.toLocaleString()
}
togglePet(idx: number): void {
const newExpanded: boolean[] = []
for (let i = 0; i < this.expandedPets.length; i++) {
if (i === idx) {
newExpanded[i] = !this.expandedPets[i]
} else {
newExpanded[i] = this.expandedPets[i]
}
}
this.expandedPets = newExpanded
}
doAddPet(): void {
this.showAddPet = false
}
doEditPet(): void {
this.showEditPet = false
}
doDeletePet(): void {
if (this.selectedPetIdx >= 0 && this.selectedPetIdx < this.pets.length) {
this.pets.splice(this.selectedPetIdx, 1)
}
this.showDeletePet = false
this.selectedPetIdx = -1
}
doAddRecord(): void {
this.showAddRecord = false
}
openEditPet(idx: number): void {
this.selectedPetIdx = idx
this.formName = this.pets[idx].name
this.formSpecies = this.pets[idx].species
this.formBreed = this.pets[idx].breed
this.formAge = this.pets[idx].age
this.formWeight = this.pets[idx].weight
this.formBirthday = this.pets[idx].birthday
this.showEditPet = true
}
openDeletePet(idx: number): void {
this.selectedPetIdx = idx
this.showDeletePet = true
}
openAddRecord(idx: number): void {
this.selectedPetIdx = idx
this.showAddRecord = true
}
@Builder bottomNav() {
Row() {
this.bottomTabItem('🐾', '宠物', PetTab.PETS)
this.bottomTabItem('💊', '健康', PetTab.HEALTH)
this.bottomTabItem('📊', '统计', PetTab.STATS)
this.bottomTabItem('👤', '我的', PetTab.PROFILE)
}
.width('100%').height(56)
.backgroundColor('#FFFFFF')
.padding({ bottom: 4 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
@Builder bottomTabItem(icon: string, label: string, tab: PetTab) {
Column() {
Text(icon).fontSize(22)
.opacity(this.activeTab === tab ? 1.0 : 0.4)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? '#E91E63' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column().width(16).height(3)
.backgroundColor('#E91E63').borderRadius(2).margin({ top: 3 })
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 4 })
.onClick(() => { this.activeTab = tab })
}
@Builder modalBg(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.45)').onClick(onClose)
}
@Builder addPetModal() {
Column() {
this.modalBg(() => { this.showAddPet = false })
Column() {
Scroll() {
Column() {
Text('添加宠物').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 16 })
Text('宠物名字').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '取个好听的名字' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formName = v })
Text('种类').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '狗/猫/兔/仓鼠...' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formSpecies = v })
Text('品种').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '如:金毛寻回犬' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formBreed = v })
Text('年龄 (岁)').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '宠物年龄' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formAge = parseInt(v) })
Text('体重 (kg)').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '体重,如28.5' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formWeight = parseFloat(v) })
Text('出生日期').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '如 2022-05-12' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 16 })
.onChange((v: string) => { this.formBirthday = v })
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddPet = false })
Column().layoutWeight(1)
Text('添加').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doAddPet() })
}.width('100%')
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(999)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder editPetModal() {
Column() {
this.modalBg(() => { this.showEditPet = false })
Column() {
Scroll() {
Column() {
Text('编辑宠物信息').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 16 })
Text('宠物名字').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formName })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formName = v })
Text('种类').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formSpecies })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formSpecies = v })
Text('品种').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formBreed })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formBreed = v })
Text('年龄').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formAge.toString() })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formAge = parseInt(v) })
Text('体重').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formWeight.toString() })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 16 })
.onChange((v: string) => { this.formWeight = parseFloat(v) })
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showEditPet = false })
Column().layoutWeight(1)
Text('更新').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doEditPet() })
}.width('100%')
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1000)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder deletePetModal() {
Column() {
this.modalBg(() => { this.showDeletePet = false })
Column() {
Text('确认删除').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 8 })
Text('删除宠物将同时清空其所有健康记录,确认删除?').fontSize(14)
.fontColor('#666666').textAlign(TextAlign.Center)
.padding({ left: 16, right: 16 }).margin({ bottom: 20 })
Row() {
Text('取消').fontSize(15).fontColor('#666666')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.showDeletePet = false })
Column().width(16)
Text('删除').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E53935').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.doDeletePet() })
}
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
.padding(24).alignItems(HorizontalAlign.Center)
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1001)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder addRecordModal() {
Column() {
this.modalBg(() => { this.showAddRecord = false })
Column() {
Scroll() {
Column() {
Text('添加健康记录').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#263238').margin({ bottom: 16 })
Text(this.formName + ' — 记录类型').fontSize(13).fontColor('#E91E63').margin({ bottom: 8 }).alignSelf(ItemAlign.Start)
Row() {
this.recordTypeChip('体检', '🩺')
this.recordTypeChip('疫苗', '💉')
this.recordTypeChip('驱虫', '💊')
this.recordTypeChip('生病', '🤒')
this.recordTypeChip('美容', '✂️')
}.width('100%').margin({ bottom: 12 })
Text('日期').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '日期,如 2024-01-15' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formDate = v })
Text('体重 (kg)').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '当前体重' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formWeight = parseFloat(v) })
Text('体温 (°C)').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '体温,如38.5' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formTemp = parseFloat(v) })
Text('兽医姓名').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '主治医生' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formVet = v })
Text('诊所').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '诊所名称' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formClinic = v })
Text('费用 (元)').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '费用金额' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formCost = parseInt(v) })
Text('备注').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '记录备注...' })
.width('100%').height(60).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12, top: 8 }).margin({ bottom: 16 })
.onChange((v: string) => { this.formNotes = v })
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddRecord = false })
Column().layoutWeight(1)
Text('保存').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doAddRecord() })
}.width('100%')
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('92%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '88%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1002)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder recordTypeChip(label: string, emoji: string) {
Row() {
Text(emoji).fontSize(13).margin({ right: 3 })
Text(label).fontSize(12)
}
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.formType === label ? '#E91E63' : '#F5F5F5')
.borderRadius(14).margin({ right: 6 })
.onClick(() => { this.formType = label })
}
@Builder alertCard(alert: AlertItem) {
Row() {
Text(alert.emoji).fontSize(22).margin({ right: 10 })
Column() {
Text(alert.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(alert.detail).fontSize(11).fontColor('#888888').margin({ top: 2 }).maxLines(2)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(alert.urgency).fontSize(10).fontColor('#FFFFFF')
.backgroundColor(alert.borderColor).borderRadius(6)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
.width('100%').backgroundColor(alert.bgColor)
.border({ width: 1, color: alert.borderColor }).borderRadius(10)
.padding(12).margin({ bottom: 8 })
}
@Builder petCard(pet: PetModel, idx: number) {
Column() {
Row() {
Column() {
Text(pet.emoji).fontSize(40)
}
.width(64).height(64).backgroundColor(pet.avatarBg).borderRadius(32)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(pet.name).fontSize(17).fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
Text(pet.breed).fontSize(11).fontColor('#999999')
.backgroundColor('#F5F5F5').borderRadius(6)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}.width('100%')
Row() {
Text(pet.species + ' · ' + pet.age.toString() + '岁').fontSize(12).fontColor('#888888')
Text(pet.weight.toString() + 'kg').fontSize(12).fontColor('#E91E63')
.backgroundColor('#FCE4EC').borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 8 })
Text('🎂' + pet.birthday).fontSize(11).fontColor('#ADADAD').margin({ left: 8 })
}.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}.width('100%')
if (this.expandedPets[idx]) {
Column() {
Row() {
Column().width('100%').height(1).backgroundColor('#FDE0EB').margin({ top: 12, bottom: 12 })
}
Row() {
this.petMetric('体重', pet.weight.toString() + 'kg', '⚖️', '#E91E63')
this.petMetric('年龄', pet.age.toString() + '岁', '📅', '#AD1457')
this.petMetric('记录', this.getPetRecords(pet.id).length.toString() + '条', '📋', '#C2185B')
Column().layoutWeight(1)
}.width('100%')
Row() {
Text('近期记录').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#666666').margin({ top: 10, bottom: 6 })
}.width('100%')
this.petRecordItem(this.healthRecords[pet.id === 1 ? 3 : pet.id === 2 ? 6 : pet.id === 3 ? 10 : pet.id === 4 ? 13 : pet.id === 5 ? 17 : 20])
Row() {
Text('编辑').fontSize(11).fontColor('#E91E63')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.border({ width: 1, color: '#E91E63' }).borderRadius(14)
.onClick(() => { this.openEditPet(idx) })
Column().width(8)
Text('添加记录').fontSize(11).fontColor('#FFFFFF')
.backgroundColor('#E91E63').borderRadius(14)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.openAddRecord(idx) })
Column().layoutWeight(1)
Text('删除').fontSize(11).fontColor('#E53935')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.border({ width: 1, color: '#E53935' }).borderRadius(14)
.onClick(() => { this.openDeletePet(idx) })
}.width('100%').margin({ top: 12 })
}
}
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(16)
.padding(14).margin({ bottom: 10 })
.shadow({ radius: 6, color: '#15000000', offsetY: 2 })
.onClick(() => { this.togglePet(idx) })
}
@Builder petMetric(label: string, value: string, icon: string, color: string) {
Column() {
Text(icon).fontSize(18)
Text(value).fontSize(13).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 4 })
Text(label).fontSize(10).fontColor('#999999').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFF5F8').borderRadius(10).padding({ top: 10, bottom: 10 })
}
@Builder petRecordItem(record: HealthRecModel) {
Row() {
Text(this.getRecTypeIcon(record.type)).fontSize(18).margin({ right: 8 })
Column() {
Text(record.type + ' · ' + record.date).fontSize(12).fontColor('#333333')
Text(record.notes).fontSize(10).fontColor('#999999').maxLines(1).margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.formatAmount(record.cost)).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#E91E63')
}
.width('100%').backgroundColor('#FFF5F8').borderRadius(8)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
}
@Builder petsTab() {
Scroll() {
Column() {
Text('健康提醒').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.padding({ left: 16, top: 16, bottom: 4 }).alignSelf(ItemAlign.Start)
Column() {
this.alertCard(this.alerts[0])
this.alertCard(this.alerts[1])
this.alertCard(this.alerts[2])
this.alertCard(this.alerts[3])
}
.padding({ left: 12, right: 12 })
Row() {
Text('我的宠物').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
Text('+ 添加').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(16)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.showAddPet = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
Column() {
this.petCard(this.pets[0], 0)
this.petCard(this.pets[1], 1)
this.petCard(this.pets[2], 2)
this.petCard(this.pets[3], 3)
this.petCard(this.pets[4], 4)
this.petCard(this.pets[5], 5)
}
.padding({ left: 12, right: 12 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder healthTab() {
Scroll() {
Column() {
Row() {
Text('健康记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
Text('+ 新记录').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E91E63').borderRadius(16)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.showAddRecord = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
Column() {
Text('2024年').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#AD1457').margin({ bottom: 8 })
this.healthRecordCard(this.healthRecords[0])
this.healthRecordCard(this.healthRecords[1])
this.healthRecordCard(this.healthRecords[2])
this.healthRecordCard(this.healthRecords[3])
this.healthRecordCard(this.healthRecords[4])
this.healthRecordCard(this.healthRecords[5])
this.healthRecordCard(this.healthRecords[6])
this.healthRecordCard(this.healthRecords[7])
this.healthRecordCard(this.healthRecords[8])
this.healthRecordCard(this.healthRecords[9])
this.healthRecordCard(this.healthRecords[10])
this.healthRecordCard(this.healthRecords[11])
this.healthRecordCard(this.healthRecords[12])
this.healthRecordCard(this.healthRecords[13])
this.healthRecordCard(this.healthRecords[14])
this.healthRecordCard(this.healthRecords[15])
this.healthRecordCard(this.healthRecords[16])
this.healthRecordCard(this.healthRecords[17])
this.healthRecordCard(this.healthRecords[18])
this.healthRecordCard(this.healthRecords[19])
this.healthRecordCard(this.healthRecords[20])
this.healthRecordCard(this.healthRecords[21])
}
.padding({ left: 12, right: 12, bottom: 16 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder healthRecordCard(record: HealthRecModel) {
Row() {
Column() {
Text(this.getRecTypeIcon(record.type)).fontSize(24)
}
.width(44).height(44).backgroundColor('#FCE4EC').borderRadius(22)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(this.getPetName(record.petId) + ' · ' + record.type).fontSize(14)
.fontWeight(FontWeight.Bold).fontColor('#333333')
Column().layoutWeight(1)
if (record.isCompleted) {
Text('已完成').fontSize(9).fontColor('#4CAF50')
.backgroundColor('#E8F5E9').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
} else {
Text('待处理').fontSize(9).fontColor('#FF9800')
.backgroundColor('#FFF3E0').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
}.width('100%')
Row() {
Text(record.date).fontSize(11).fontColor('#888888')
Text(record.vetName).fontSize(11).fontColor('#AD1457').margin({ left: 8 })
Text(record.clinic).fontSize(11).fontColor('#AAAAAA').margin({ left: 4 })
}.margin({ top: 2 })
Row() {
Text('⚖️' + record.weight.toString() + 'kg').fontSize(10).fontColor('#888888')
Text('🌡️' + record.temperature.toString() + '°C').fontSize(10).fontColor('#888888').margin({ left: 8 })
Text(this.formatAmount(record.cost)).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor('#E91E63').margin({ left: 8 })
}.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding(12).margin({ bottom: 8 })
.shadow({ radius: 3, color: '#10000000', offsetY: 1 })
}
@Builder expenseBar(item: ExpenseBarItem) {
Column() {
Row() {
Text(item.label).fontSize(12).fontColor('#666666').width(40)
Row() {
Column()
.width((item.value * 100 / this.maxExpense).toString() + '%')
.height(16).backgroundColor(item.color)
.borderRadius({ topLeft: 8, bottomLeft: 8 })
if (item.value < this.maxExpense) {
Column().layoutWeight(1)
}
}
.layoutWeight(1).height(16).backgroundColor('#F5F5F5').borderRadius(8)
Text('¥' + item.value.toString()).fontSize(11).fontColor('#333333').width(60).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
}
.margin({ bottom: 10 })
}
@Builder statsTab() {
Scroll() {
Column() {
Row() {
Column() {
Text(this.getTotalCost().toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('年度总花费(元)').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
Column() {
Text(this.healthRecords.length.toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#AD1457')
Text('健康记录').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 8 })
Column() {
Text(this.pets.length.toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('毛孩子').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 8 })
}
.width('100%').padding({ left: 12, right: 12, top: 12 })
Column() {
Text('花费分类').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#333333').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
this.expenseBar(this.expenses[0])
this.expenseBar(this.expenses[1])
this.expenseBar(this.expenses[2])
this.expenseBar(this.expenses[3])
this.expenseBar(this.expenses[4])
this.expenseBar(this.expenses[5])
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, top: 12, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column() {
Text('各宠物健康概览').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#333333').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
this.petOverviewBar(this.pets[0])
this.petOverviewBar(this.pets[1])
this.petOverviewBar(this.pets[2])
this.petOverviewBar(this.pets[3])
this.petOverviewBar(this.pets[4])
this.petOverviewBar(this.pets[5])
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, top: 0, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column() {
Text('体重监测').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#333333').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
Row() {
Column() {
Text('28.5').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('旺财(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('4.8').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#AD1457')
Text('咪咪(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('12.3').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#C2185B')
Text('布丁(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ bottom: 12 })
Row() {
Column() {
Text('7.2').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#880E4F')
Text('橘子(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('2.1').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('雪球(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('0.15').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#AD1457')
Text('坚果(kg)').fontSize(10).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, top: 0, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder petOverviewBar(pet: PetModel) {
Row() {
Text(pet.emoji).fontSize(20).margin({ right: 8 })
Text(pet.name).fontSize(13).fontColor('#333333').width(40)
Row() {
Column()
.width((this.getPetRecords(pet.id).length * 100 / 5).toString() + '%')
.height(12).backgroundColor(pet.color)
.borderRadius({ topLeft: 6, bottomLeft: 6 })
Column().layoutWeight(1)
}
.layoutWeight(1).height(12).backgroundColor('#F5F5F5').borderRadius(6)
Text(this.getPetRecords(pet.id).length.toString() + '条').fontSize(10).fontColor('#999999').width(30).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
}
@Builder profileTab() {
Scroll() {
Column() {
Column() {
Column().width(72).height(72).backgroundColor('#F8BBD0').borderRadius(36)
Row() {
Column().width(72).height(72).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Text('🐾').fontSize(32)
}
Column()
Text('宠物家长').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333').margin({ top: 12 })
Text('用心守护每一个毛孩子的健康').fontSize(13).fontColor('#999999').margin({ top: 4 })
}
.width('100%').alignItems(HorizontalAlign.Center).padding({ top: 30, bottom: 20 })
Row() {
Column() {
Text(this.pets.length.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('宠物').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.healthRecords.length.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('记录').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.getTotalCost().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#E91E63')
Text('花费(元)').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 16, bottom: 16 }).margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column() {
this.profileMenuRow('🐕', '我的宠物档案', '管理宠物信息')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('📋', '健康记录归档', this.healthRecords.length.toString() + '条记录')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('💉', '疫苗接种提醒', '查看接种计划')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('🏥', '医院收藏', '3家常去诊所')
Row().width('100%').height(1).backgroundColor('#FDE0EB')
this.profileMenuRow('⚙️', '设置', '提醒与偏好')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder profileMenuRow(icon: string, title: string, sub: string) {
Row() {
Text(icon).fontSize(18).margin({ right: 12 })
Column() {
Text(title).fontSize(15).fontColor('#333333')
Text(sub).fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('>').fontSize(14).fontColor('#CCCCCC')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
build() {
Stack() {
Column() {
Row() {
Text('宠物健康').fontSize(22).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column().layoutWeight(1)
Text('🐾').fontSize(24)
}
.width('100%').height(52).backgroundColor('#E91E63')
.padding({ left: 16, right: 16 })
if (this.activeTab === PetTab.PETS) {
this.petsTab()
} else if (this.activeTab === PetTab.HEALTH) {
this.healthTab()
} else if (this.activeTab === PetTab.STATS) {
this.statsTab()
} else {
this.profileTab()
}
this.bottomNav()
}
.width('100%').height('100%')
.backgroundColor('#FCE4EC')
if (this.showAddPet) { this.addPetModal() }
if (this.showEditPet) { this.editPetModal() }
if (this.showDeletePet) { this.deletePetModal() }
if (this.showAddRecord) { this.addRecordModal() }
}
.width('100%').height('100%')
}
}
整体架构技术点总结
从全局视角来看,本应用体现了以下 HarmonyOS ArkTS 开发的核心技术实践:

-
状态驱动架构:所有 UI 变化由
@State变量驱动,通过修改状态触发响应式重渲染,实现了数据与视图的单向数据流。 -
@Observed+@State双层响应式:数据模型类使用@Observed获得属性级可观察性,组件级状态使用@State管理引用级变化,两者配合实现完整的响应式链路。 -
@Builder组件化拆分:将复杂的 UI 结构拆解为数十个@Builder方法,每个 Builder 负责一个独立的 UI 片段,提高了代码的可读性和可维护性。 -
Stack层叠布局实现模态:通过Stack容器叠加主内容与条件渲染的弹窗,配合zIndex控制层级,实现了灵活的自定义模态系统。 -
枚举驱动的 Tab 导航:使用
enum PetTab管理页面状态,通过if...else条件渲染切换 Tab 内容,代码语义清晰。 -
不可变更新模式:在修改数组状态时采用"创建新数组再整体赋值"的模式,确保
@State能正确捕获变化。 -
数据与视觉的深度融合:将颜色、图标等视觉信息直接嵌入数据模型(如
color、avatarBg、bgColor、borderColor),让数据自带视觉属性,简化了渲染逻辑。 -
防御性编程:在删除操作中进行索引边界检查,在数据查询中提供兜底返回值,确保应用的健壮性。
更多推荐



所有评论(0)