萌宠美容预约管理应用:HarmonyOS ArkTS API 24每个弹窗的根容器都设置了 position({ x: 0, y: 0 }) 和 zIndex(999),确保它正确覆盖全屏
一、应用背景与行业意义
随着城市宠物经济的快速崛起,越来越多的家庭将宠物视为重要的家庭成员。宠物美容作为宠物消费链条中复购率极高的一环,已经从过去简单的"洗澡吹干"演变为包含造型设计、SPA 护理、药浴驱虫、口腔清洁等多元服务在内的综合性产业。一家中等规模的宠物美容工作室每天要处理数十甚至上百个预约,涉及不同体型、不同品种、不同服务项目、不同美容师的复杂排期,仅靠纸质登记本或微信沟通已经远远无法满足精细化运营的需求。

在这样的行业背景下,一套结构清晰、交互流畅、数据可视化的宠物美容预约管理应用就显得尤为关键。它不仅需要帮助门店前台快速创建和检索预约,还要让美容师清楚地知道自己下一个服务的对象、宠物的特殊注意事项(例如过敏、怕水、皮肤敏感等);同时,老板还需要通过统计面板了解每月营收走势、各服务类型的占比、各美容师的业绩表现,从而做出经营决策。更重要的是,面向宠物主人的界面需要温暖、亲切、易于操作,因为宠物消费本质上是情感消费,界面的"温度"直接影响用户的复购意愿。
本文将要剖析的这套实现,正是这样一款面向宠物美容工作室的移动端管理应用。它采用了声明式 UI 开发范式,以组件化、状态驱动为核心思想,将一个完整的业务场景拆分为五大功能模块:预约管理、套餐展示、宠物档案、数据统计和个人中心。整个应用在视觉上统一采用了"暖紫 + 柠檬黄 + 薄荷绿"的柔和配色方案,营造出温馨而不失专业的氛围,非常契合宠物行业的调性。接下来,我们将从代码层面逐段拆解它的实现细节,看看一个生产级别的鸿蒙原生应用是如何从零搭建起来的。
二、整体架构概览
在正式进入代码细节之前,我们先从宏观上把握这个应用的架构骨架。整个工程遵循"类型定义 → 数据模型 → 静态配置 → 模拟数据 → 工具函数 → 页面组件"的自顶向下组织方式,这是一种非常经典且易于维护的前端工程结构。
从运行时的角度看,应用启动后会进入一个带底部 Tab 导航的入口页面,通过一个 activeTab 状态变量来控制当前显示的内容区域。五个 Tab 分别对应五个独立的子组件,每个子组件内部又各自管理自己的局部状态(例如弹窗的显隐、表单的输入值、当前选中的筛选条件等)。这种"父组件管路由、子组件管业务"的分层设计,使得各模块之间高度解耦,任何一个模块都可以独立开发、独立测试,未来也方便拆分到不同的源文件中。
值得一提的是,应用大量使用了模态弹窗(Modal)来处理新建、编辑、删除确认、详情查看等交互,而不是跳转到新页面。这种设计在移动端能保持上下文的连贯性,用户操作完毕后无需返回即可看到列表变化,体验更加顺滑。下面我们就按照代码的自然顺序,一段一段地深入分析。
三、接口类型定义:为业务概念建立契约
在任何稍具规模的应用中,类型定义都是第一步,也是最关键的一步。它相当于开发者之间、开发者与编译器之间的一份"契约",明确了每个业务概念包含哪些字段、字段的类型是什么。这套代码在文件顶部集中定义了五个接口,分别描述了五种业务元数据。
interface ServiceTypeMeta {
label: string
icon: string
color: string
bg: string
duration: number
basePrice: number
}

ServiceTypeMeta 描述的是"服务类型"的元信息。label 是展示给用户的中文名称,例如"基础洗护";icon 是一个 emoji 图标,用于在卡片和按钮上快速识别;color 和 bg 是一对配色,分别用于文字颜色和背景色,这种"文字色 + 背景色"成对出现的设计贯穿全应用,保证了视觉一致性;duration 表示该项服务的预计耗时(分钟),basePrice 是基础价格(元)。把"耗时"和"价格"放在元信息里,意味着同一个服务类型在不同体型上的最终价格是通过基础价格乘以体型系数计算出来的,这是一种灵活的定价策略。
interface PetSizeMeta {
label: string
icon: string
multiplier: number
}

PetSizeMeta 描述宠物体型的元信息。这里的 multiplier(倍率)是整个定价体系的核心:小型犬倍率为 1.0,中型犬 1.5,大型犬 2.0,猫咪 1.2。这种设计非常符合实际——大型犬的洗护工作量远大于小型犬,而猫咪虽然体型小但需要更多耐心安抚,所以倍率略高于小型犬。通过倍率机制,新增一种体型只需要加一条配置,无需修改任何业务逻辑代码,体现了开闭原则。
interface AppointmentStatusMeta {
label: string
color: string
bg: string
}

AppointmentStatusMeta 描述预约状态的元信息,只有三个字段。状态在预约系统中至关重要,因为它决定了卡片的视觉呈现和可执行的操作。这套系统定义了五种状态:待确认、已确认、进行中、已完成、已取消,每种状态都有独立的文字色和背景色,让用户一眼就能分辨。
interface GroomerMeta {
label: string
avatar: string
rating: number
specialty: string
}

GroomerMeta 描述美容师的元信息。avatar 同样使用 emoji,rating 是评分,specialty 是擅长的领域(例如"精致造型"、“大型犬护理”)。把美容师的专业特长显式地建模出来,不仅可以在预约时引导用户选择合适的美容师,还能在统计页展示团队的整体实力。
interface TabMeta {
label: string
icon: string
}

最后是 TabMeta,描述底部导航栏每一项的元信息,结构最简单。虽然只有两个字段,但单独建模的好处是:未来如果要给 Tab 增加角标数字、未读红点等属性,只需要扩展这个接口即可,而不需要去改动散落在各处的硬编码。
四、数据模型:用 @Observed 装饰器建立可观察对象
类型定义只是静态的"契约",真正承载数据的是数据模型类。这套代码定义了三个核心数据模型类,并且都使用了 @Observed 装饰器。这是声明式 UI 框架中非常重要的一个装饰器,它告诉框架:这个类的实例是"可观察的",当其内部属性发生变化时,依赖这些属性的 UI 组件会自动刷新。
@Observed
export class GroomingAppointment {
id: number = 0
petName: string = ''
petType: string = ''
petSize: string = ''
ownerName: string = ''
phone: string = ''
service: string = ''
date: string = ''
time: string = ''
duration: number = 0
price: number = 0
status: string = '待确认'
notes: string = ''
groomer: string = ''
rating: number = 0
createdAt: string = ''
constructor(id: number, petName: string, petType: string, petSize: string,
ownerName: string, phone: string, service: string, date: string,
time: string, duration: number, price: number, status: string,
notes: string, groomer: string, rating: number, createdAt: string) {
this.id = id; this.petName = petName; this.petType = petType; this.petSize = petSize
this.ownerName = ownerName; this.phone = phone; this.service = service
this.date = date; this.time = time; this.duration = duration; this.price = price
this.status = status; this.notes = notes; this.groomer = groomer
this.rating = rating; this.createdAt = createdAt
}
}

GroomingAppointment 是预约模型,也是整个应用最核心的数据结构。它包含 16 个字段,覆盖了一只宠物从"被预约"到"服务完成"的全生命周期信息。我们逐组来看:
第一组是身份与归属:id 是唯一标识,petName 是宠物名字(如"豆豆"),petType 是宠物种类(狗/猫),petSize 是体型分级,ownerName 和 phone 是宠物主人信息。把宠物和主人信息都放在预约对象里,是一种"反范式"的设计——在关系型数据库里通常会拆成宠物表和主人表再关联,但在前端展示场景下,扁平化结构能减少大量的关联查询,让 UI 渲染更直接。
第二组是服务与时间:service 是服务项目名称,date 和 time 是预约日期和时间,duration 是预计时长,price 是最终价格。注意 price 是已经计算好的最终值(基础价 × 体型倍率),而不是基础价,这样存的好处是即使未来调整了定价策略,历史预约的价格记录也不会变。
第三组是状态与评价:status 默认值为"待确认",这是新建预约后的初始状态;notes 是备注,记录宠物的特殊情况,对美容师非常重要;groomer 是分配的美容师;rating 是服务完成后的评分(1-5 星),未评价时为 0;createdAt 是预约创建时间,用于排序和审计。
构造函数采用了一种紧凑的写法,用分号将多个赋值语句放在同一行。虽然牺牲了一点可读性,但在字段众多的情况下能显著减少代码行数。每个字段在声明时都给了默认值(空字符串或 0),这是一个好习惯——即使构造函数漏传某个参数,对象也不会出现 undefined,避免了运行时错误。
@Observed
export class ServicePackage {
id: number = 0
name: string = ''
icon: string = ''
description: string = ''
price: number = 0
originalPrice: number = 0
duration: number = 0
category: string = ''
popular: boolean = false
salesCount: number = 0
rating: number = 0
includes: string[] = []
constructor(id: number, name: string, icon: string, description: string,
price: number, originalPrice: number, duration: number, category: string,
popular: boolean, salesCount: number, rating: number, includes: string[]) {
this.id = id; this.name = name; this.icon = icon; this.description = description
this.price = price; this.originalPrice = originalPrice; this.duration = duration
this.category = category; this.popular = popular; this.salesCount = salesCount
this.rating = rating; this.includes = includes
}
}

ServicePackage 是服务套餐模型,用于套餐展示页。这里有几个值得注意的设计点。首先是 price 和 originalPrice 同时存在,这是电商常见的"划线价"设计——originalPrice 显示为带删除线的原价,price 是实际售价,两者并存能营造"优惠感",刺激用户下单。其次是 popular 布尔字段,标记是否为热门推荐,热门套餐在卡片底部会显示"🔥 热门推荐"标签,并且图标背景色会从浅紫变为暖黄色,形成视觉强调。最后是 includes 字符串数组,列出套餐包含的具体服务项,在详情弹窗里会以"✅"列表的形式逐条展示,让用户清楚知道自己花钱买了什么。
category 字段将套餐分为"基础"、“造型”、"护理"三类,配合筛选栏使用。salesCount 和 rating 则是社交证明,高销量和高评分会增强新用户的信任感。
@Observed
export class PetProfile {
id: number = 0
name: string = ''
type: string = ''
breed: string = ''
size: string = ''
age: number = 0
gender: string = ''
weight: number = 0
color: string = ''
avatar: string = ''
vaccinated: boolean = false
neutered: boolean = false
allergies: string = ''
notes: string = ''
visitCount: number = 0
lastVisit: string = ''
constructor(id: number, name: string, type: string, breed: string, size: string,
age: number, gender: string, weight: number, color: string, avatar: string,
vaccinated: boolean, neutered: boolean, allergies: string, notes: string,
visitCount: number, lastVisit: string) {
this.id = id; this.name = name; this.type = type; this.breed = breed
this.size = size; this.age = age; this.gender = gender; this.weight = weight
this.color = color; this.avatar = avatar; this.vaccinated = vaccinated
this.neutered = neutered; this.allergies = allergies; this.notes = notes
this.visitCount = visitCount; this.lastVisit = lastVisit
}
}

PetProfile 是宠物档案模型,字段最多,达到了 16 个。它不仅记录宠物的基本信息(名字、种类、品种、体型、年龄、性别、体重、毛色),更重要的是记录健康信息:vaccinated(是否接种疫苗)、neutered(是否绝育)、allergies(过敏史)。这些健康信息对美容师来说是至关重要的操作依据——比如一只对鸡肉过敏的狗,就不能使用含鸡肉成分的沐浴露;一只未绝育的母猫在发情期需要特别小心。notes 字段则用于记录行为特征,例如"怕水,需要耐心"、“活泼好动,喜欢被抱”,这些"软知识"能大幅提升服务质量和安全性。visitCount 和 lastVisit 用于统计宠物的复购情况,高复购宠物的主人往往是门店的核心客户,值得重点维护。
五、静态配置:将业务知识从代码中抽离
定义完数据模型后,代码用一组 const 常量把所有的"业务知识"集中配置起来。这种做法的好处是:当业务规则变化时(例如新增一种服务、调整体型倍率、增加一个美容师),只需要修改这一处配置,而不需要去翻找散落在各组件里的硬编码值。
const SERVICE_CONFIG: Record<string, ServiceTypeMeta> = {
'基础洗护': { label: '基础洗护', icon: '🛁', color: '#7E57C2', bg: '#F3E5F5', duration: 60, basePrice: 88 },
'精致造型': { label: '精致造型', icon: '✂️', color: '#AB47BC', bg: '#F3E5F5', duration: 120, basePrice: 168 },
'SPA护理': { label: 'SPA护理', icon: '🧖', color: '#26A69A', bg: '#E0F2F1', duration: 90, basePrice: 128 },
'药浴除蚤': { label: '药浴除蚤', icon: '💊', color: '#EF5350', bg: '#FFEBEE', duration: 75, basePrice: 108 },
'指甲护理': { label: '指甲护理', icon: '💅', color: '#FF8F00', bg: '#FFF8E1', duration: 30, basePrice: 38 },
'牙齿清洁': { label: '牙齿清洁', icon: '🦷', color: '#42A5F5', bg: '#E3F2FD', duration: 40, basePrice: 48 }
}

SERVICE_CONFIG 是一个 Record<string, ServiceTypeMeta> 类型的字典,以服务名称为键。这里定义了六种服务类型,每种都有自己的图标、配色、耗时和基础价格。注意配色是经过精心搭配的:基础洗护用主色调暖紫,精致造型用稍深的紫罗兰,SPA 护理用清新的薄荷绿,药浴除蚤用警示性的红色,指甲护理用温暖的橙黄,牙齿清洁用清爽的天蓝。这种"一服务一色"的设计让用户在浏览列表时能通过颜色快速定位服务类型,降低了认知负担。从价格梯度也能看出业务设计:基础洗护 88 元是入门款,精致造型 168 元是利润款,SPA 护理 128 元是体验款,而指甲护理和牙齿清洁则是低价引流款,用低门槛吸引客户进店后再向上转化。
const PET_SIZE_CONFIG: Record<string, PetSizeMeta> = {
'小型': { label: '小型犬', icon: '🐕', multiplier: 1.0 },
'中型': { label: '中型犬', icon: '🐕🦺', multiplier: 1.5 },
'大型': { label: '大型犬', icon: '🦮', multiplier: 2.0 },
'猫': { label: '猫咪', icon: '🐈', multiplier: 1.2 }
}

PET_SIZE_CONFIG 定义了四种体型及其倍率。这里有一个细节值得品味:键名用的是"小型"、“中型”、“大型”、“猫”,而 label 展示的是"小型犬"、“中型犬”、“大型犬”、“猫咪”。这是因为猫不是"犬",所以在展示文案上做了区分,但在数据层统一用 petSize 字段管理,避免了为猫单独建一套逻辑。
const STATUS_CONFIG: Record<string, AppointmentStatusMeta> = {
'待确认': { label: '待确认', color: '#FF8F00', bg: '#FFF8E1' },
'已确认': { label: '已确认', color: '#7E57C2', bg: '#F3E5F5' },
'进行中': { label: '进行中', color: '#26A69A', bg: '#E0F2F1' },
'已完成': { label: '已完成', color: '#43A047', bg: '#E8F5E9' },
'已取消': { label: '已取消', color: '#EF5350', bg: '#FFEBEE' }
}
STATUS_CONFIG 定义了五种预约状态及其配色。颜色的选择遵循了通用语义:待确认用橙色(提醒注意),已确认用紫色(品牌主色),进行中用青绿(活跃感),已完成用绿色(成功),已取消用红色(警示)。这种语义化的配色方案让用户无需阅读文字就能从颜色感知状态,极大地提升了信息获取效率。
const GROOMER_CONFIG: Record<string, GroomerMeta> = {
'小美': { label: '小美', avatar: '👩🦰', rating: 4.9, specialty: '精致造型' },
'阿杰': { label: '阿杰', avatar: '👨', rating: 4.8, specialty: '大型犬护理' },
'丽丽': { label: '丽丽', avatar: '👩', rating: 5.0, specialty: '猫咪SPA' },
'大熊': { label: '大熊', avatar: '🧔', rating: 4.7, specialty: '药浴除蚤' }
}
GROOMER_CONFIG 定义了四位美容师,每位都有专属的 emoji 头像、评分和擅长领域。这种配置让美容师不再是匿名的"工作人员",而是有专业特长、有口碑评分的"手艺人",有助于建立客户与美容师之间的信任关系,提高复购率。注意评分都在 4.7 以上,这是合理的——在实际产品中,评分过低的美容师会被淘汰或培训。
const TAB_CONFIG: Record<string, TabMeta> = {
'appointments': { label: '预约', icon: '📅' },
'services': { label: '套餐', icon: '🎁' },
'pets': { label: '档案', icon: '🐾' },
'stats': { label: '统计', icon: '📊' },
'profile': { label: '我的', icon: '👤' }
}
TAB_CONFIG 定义了底部导航栏的五项配置。虽然在实际的 build 方法里 Tab 项是直接硬编码的,但保留这个配置对象仍然有意义——它文档化了所有合法的 Tab 项,方便后续维护者理解全貌。
随后还有几个字符串数组常量,用于下拉选择和筛选:
const PET_TYPES: string[] = ['狗', '猫', '兔子', '仓鼠']
const PET_SIZES: string[] = ['小型', '中型', '大型', '猫']
const SERVICE_TYPES: string[] = ['基础洗护', '精致造型', 'SPA护理', '药浴除蚤', '指甲护理', '牙齿清洁']
const STATUS_FILTERS: string[] = ['全部', '待确认', '已确认', '进行中', '已完成', '已取消']
const GROOMER_NAMES: string[] = ['小美', '阿杰', '丽丽', '大熊']
这些数组用于 ForEach 渲染选择按钮。注意 PET_TYPES 里包含了"兔子"和"仓鼠",虽然体型配置里没有对应的倍率,但这暗示了应用的扩展性——未来可以轻松支持更多宠物种类。
六、统计图表数据与工具函数
统计页需要展示月度收入趋势和服务类型分布,这些数据被定义为顶层常量:
const MONTHLY_REVENUE: number[] = [3200, 4100, 3800, 5200, 4800, 6100, 5600, 6800, 7200, 6500, 7800, 8400]
const MONTH_LABELS: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
const MAX_REVENUE: number = 8400
MONTHLY_REVENUE 是 12 个月的营收数据,从 1 月的 3200 元到 12 月的 8400 元,整体呈上升趋势,反映了门店经营的良性增长。MAX_REVENUE 是最大值,用于计算柱状图每根柱子的相对高度——这是手动绘制柱状图时的常用技巧:用实际值除以最大值再乘以一个基准高度,就能得到每根柱子的高度比例。MONTH_LABELS 是月份标签,与营收数据一一对应。
const SERVICE_DISTRIBUTION: Record<string, number> = {
'基础洗护': 145,
'精致造型': 89,
'SPA护理': 67,
'药浴除蚤': 52,
'指甲护理': 38,
'牙齿清洁': 24
}
const TOTAL_SERVICES: number = 415
SERVICE_DISTRIBUTION 记录了每种服务类型的累计服务次数,TOTAL_SERVICES 是总次数 415 次。这两个数据配合可以计算出每种服务的占比百分比,用于在统计页绘制横向进度条。
接下来是一组工具函数:
function getTotalAppointments(): number { return 24 }
function getTodayAppointments(): number { return 5 }
function getPendingAppointments(): number { return 8 }
function getCompletedAppointments(): number { return 9 }
function getTotalRevenue(): number { return 3248 }
function getTotalPets(): number { return 12 }
function getMonthlyRevenue(idx: number): number { return MONTHLY_REVENUE[idx] }
function getServiceCount(key: string): number { return SERVICE_DISTRIBUTION[key] ?? 0 }
function getServicePercent(key: string): number { return Math.round((SERVICE_DISTRIBUTION[key] ?? 0) / TOTAL_SERVICES * 100) }
function getMaxServiceCount(): number { return 145 }
这些函数目前返回的是硬编码的模拟值,但用函数封装的好处是:未来接入真实后端时,只需要把函数体改成 API 调用,所有调用方无需修改。getServiceCount 和 getServicePercent 使用了 ?? 空值合并运算符,当传入的 key 不存在时返回 0,避免了 undefined 参与运算导致的 NaN 问题,这是一种防御性编程。getServicePercent 用 Math.round 四舍五入,保证百分比是整数,显示更整洁。
七、底部 Tab 枚举与入口页面
enum GroomingTab {
APPOINTMENTS = 0,
SERVICES = 1,
PETS = 2,
STATS = 3,
PROFILE = 4
}
GroomingTab 是一个数字枚举,定义了五个 Tab 的索引值。使用枚举而不是魔法数字(0、1、2、3、4)能显著提升代码可读性——this.activeTab === GroomingTab.APPOINTMENTS 比 this.activeTab === 0 易懂得多,也避免了后续插入新 Tab 时所有数字都要调整的维护噩梦。
@Entry
@Component
struct PetGroomingApp {
@State activeTab: GroomingTab = GroomingTab.APPOINTMENTS
PetGroomingApp 是应用的入口组件,由 @Entry 和 @Component 两个装饰器标记。@Entry 表示这是页面的根组件,@Component 表示这是一个自定义组件。@State 装饰的 activeTab 是组件的内部状态,初始值为 APPOINTMENTS(预约页),当它变化时,依赖它的 UI 会自动重渲染。
@Builder contentArea() {
Column() {
if (this.activeTab === GroomingTab.APPOINTMENTS) {
AppointmentListContent()
} else if (this.activeTab === GroomingTab.SERVICES) {
ServicePackageContent()
} else if (this.activeTab === GroomingTab.PETS) {
PetProfileContent()
} else if (this.activeTab === GroomingTab.STATS) {
StatsContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
contentArea 是一个 @Builder 方法,相当于一个可复用的 UI 片段构建器。它根据 activeTab 的值,条件渲染对应的子组件。if-else if-else 链虽然朴素,但对于 5 个分支来说完全够用,而且可读性极强。外层的 Column 使用 .layoutWeight(1),表示它占据父容器中除底部导航栏之外的所有剩余空间——这是弹性布局的核心用法。
@Builder bottomTabItem(icon: string, label: string, tab: GroomingTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? '#7E57C2' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(18).height(3)
.backgroundColor('#7E57C2').borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
bottomTabItem 是单个 Tab 项的构建器,接收图标、标签、Tab 枚举三个参数。这里有几个精妙之处:第一,通过三元运算符动态设置图标透明度(选中 1.0,未选中 0.45)、文字颜色(选中紫色,未选中灰色)、字重(选中粗体,未选中常规),三重视觉差异让选中状态非常醒目;第二,选中时额外渲染一个 18×3 的紫色小圆角条作为"指示器",这是 Material Design 风格的经典做法;第三,onClick 里直接修改 this.activeTab,因为 activeTab 是 @State,框架会自动触发重渲染,无需手动调用任何刷新方法。
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('📅', '预约', GroomingTab.APPOINTMENTS)
this.bottomTabItem('🎁', '套餐', GroomingTab.SERVICES)
this.bottomTabItem('🐾', '档案', GroomingTab.PETS)
this.bottomTabItem('📊', '统计', GroomingTab.STATS)
this.bottomTabItem('👤', '我的', GroomingTab.PROFILE)
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor('#FAF5FF')
}
}
入口页的 build 方法结构非常清晰:一个纵向 Column,上方是内容区,下方是横向 Row 排列的五个 Tab 项。底部导航栏设置了白色背景和向上的阴影(offsetY: -2),营造出一种"浮"在内容之上的层次感。整个页面的背景色是 #FAF5FF,一种极淡的紫色,与主色调呼应但不喧宾夺主。
八、预约列表页:瀑布流布局的核心实现
预约列表页是整个应用最复杂、最精彩的模块。它实现了双列瀑布流布局、状态筛选、统计头部、新建预约表单、编辑弹窗、删除确认、详情查看等一整套 CRUD 功能。我们先看它的状态定义:
@Component
struct AppointmentListContent {
@State selectedStatus: string = '全部'
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedAppointment: GroomingAppointment | null = null
@State formPetName: string = ''
@State formPetType: string = '狗'
@State formPetSize: string = '小型'
@State formService: string = '基础洗护'
@State formDate: string = '2026-08-08'
@State formTime: string = '10:00'
@State formGroomer: string = '小美'
@State formNotes: string = ''
这里的状态变量分为三组。第一组是筛选状态 selectedStatus,控制列表按状态过滤。第二组是四个布尔值,分别控制四种弹窗的显隐——这种"一个弹窗一个布尔"的设计虽然简单,但在弹窗数量多时会有冗余,更高级的做法是用一个枚举加 activeModal 单一状态。第三组是 selectedAppointment,记录当前被点击的预约对象,供详情弹窗和编辑弹窗使用,类型是 GroomingAppointment | null,用联合类型表达了"可能为空"的语义。第四组是 form* 系列,绑定新建预约表单的各个输入字段,初始值提供了合理的默认值,减少用户输入成本。
遮罩层 Builder
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
modalOverlay 是一个通用的遮罩层构建器,接收一个 onClose 回调函数。它是一个全屏半透明黑色 Column,点击时触发回调关闭弹窗。把遮罩层抽成可复用的 Builder 是很好的实践——四个弹窗都需要遮罩层,避免了重复代码。rgba(0,0,0,0.5) 是 50% 透明度的黑色,既能突出弹窗内容,又不会完全遮挡背景,让用户保持上下文感知。
新建预约弹窗
新建预约弹窗是表单最复杂的一个,我们分段来看:
@Builder addAppointmentModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('➕ 新建预约').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
弹窗的最外层是一个全屏 Column,第一层是遮罩层,第二层是白色弹窗主体。弹窗头部是一个 Row:左侧是标题"➕ 新建预约",中间用一个 Row().layoutWeight(1) 占位把关闭按钮推到右侧,右侧是"✕"关闭按钮。这种"标题 + 弹性占位 + 操作按钮"的布局模式在移动端弹窗中极其常见。Divider 是一条浅灰分割线,将头部与内容区分隔开。
Scroll() {
Column() {
Text('宠物信息').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Row() {
TextInput({ placeholder: '宠物名字' })
.placeholderColor('#BBBBBB').fontSize(14).layoutWeight(1)
.backgroundColor('#F3E5F5').borderRadius(8)
.onChange((v: string) => { this.formPetName = v })
Text('类型').fontSize(12).fontColor('#888888').margin({ left: 8 })
}
.margin({ left: 20, right: 20, top: 4 })
表单内容区用 Scroll 包裹,因为表单内容较多,在小屏幕上可能需要滚动。第一组是"宠物信息":宠物名字用 TextInput 输入框,背景色用了淡紫 #F3E5F5,圆角 8,视觉柔和;onChange 回调将输入值同步到 formPetName 状态。注意输入框没有设置 width,而是用 layoutWeight(1) 让它自动填满剩余空间,这样无论屏幕宽度如何,布局都不会错乱。
Row() {
ForEach(PET_TYPES, (t: string) => {
if (this.formPetType === t) {
Text(t).fontSize(11).fontColor('#FFFFFF').backgroundColor('#7E57C2')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(t).fontSize(11).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formPetType = t })
}
})
}
.margin({ left: 16, right: 16, top: 6 })
宠物类型选择使用了"标签按钮组"的模式,而不是下拉选择框。ForEach 遍历 PET_TYPES 数组,为每种类型渲染一个圆角标签。选中状态(this.formPetType === t)时背景为紫色、文字白色;未选中时背景为淡紫、文字紫色。点击未选中项时更新 formPetType。这种标签按钮组比下拉框更直观,用户能一眼看到所有可选项,适合选项数量较少(4 个)的场景。体型选择、服务选择、美容师选择都采用了同样的模式。
服务选择部分有一个细节:选中态的背景色不是统一的紫色,而是从 SERVICE_CONFIG 读取该项专属的颜色(SERVICE_CONFIG[s]?.color),这样每个服务按钮在选中时会呈现自己的主题色,视觉辨识度更高。
弹窗底部是"取消"和"确认预约"两个按钮,分别用灰色和紫色背景,圆角 20,水平居中排列。这种"次操作 + 主操作"的双按钮模式是移动端表单的标准做法。
编辑弹窗
编辑弹窗比新建弹窗简洁,只允许修改宠物名字、预约日期和备注三个字段:
@Builder editAppointmentModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('✏️ 编辑预约').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Column() {
Text('宠物名字').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: this.selectedAppointment?.petName ?? '' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F3E5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
这里的关键技巧是用 this.selectedAppointment?.petName ?? '' 作为输入框的 placeholder。?. 是可选链运算符,当 selectedAppointment 为 null 时不会报错而是返回 undefined,再通过 ?? '' 兜底为空字符串。这样即使没有选中任何预约就打开了编辑弹窗(虽然正常流程不会发生),也不会崩溃。编辑弹窗的"保存修改"按钮用了橙色 #FF8F00 而不是紫色,这是为了在视觉上区分"新建"和"编辑"两种操作,避免用户混淆。
删除确认弹窗
删除是不可逆操作,所以专门设计了一个确认弹窗:
@Builder deleteConfirmModal() {
Column() {
this.modalOverlay(() => { this.showDeleteConfirm = false })
Column() {
Text('⚠️').fontSize(48).margin({ top: 24 })
Text('确认取消此预约?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('取消后预约记录将不可恢复').fontSize(13).fontColor('#EF5350').margin({ top: 4 })
Row() {
Text(SERVICE_CONFIG[this.selectedAppointment?.service ?? '基础洗护']?.icon ?? '📅')
.fontSize(20)
Text(this.selectedAppointment?.petName ?? '').fontSize(14).fontColor('#333333')
.fontWeight(FontWeight.Bold).margin({ left: 8 })
Text(this.selectedAppointment?.date ?? '').fontSize(12).fontColor('#888888').margin({ left: 8 })
}
.backgroundColor('#FFEBEE').borderRadius(10)
.padding({ left: 16, right: 16, top: 10, bottom: 10 }).margin({ top: 16 })
弹窗顶部是一个大号的警告 emoji"⚠️",然后是加粗的确认提问,再是一行红色小字提示"不可恢复"——这是经典的危险操作确认设计。中间展示了一个淡红色背景的信息卡片,列出即将被取消的预约的服务图标、宠物名和日期,让用户再次确认操作对象,避免误删。底部两个按钮分别是灰色的"再想想"和红色的"确认取消",用文案和颜色的双重差异引导用户谨慎操作。
详情弹窗
详情弹窗是最长的,它展示了预约的全部信息:
@Builder detailModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Column() {
Text(SERVICE_CONFIG[this.selectedAppointment?.service ?? '基础洗护']?.icon ?? '📅')
.fontSize(36)
}.width(60).height(60).backgroundColor('#F3E5F5').borderRadius(12)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text(this.selectedAppointment?.petName ?? '').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#212121')
Row() {
Text(PET_SIZE_CONFIG[this.selectedAppointment?.petSize ?? '小型']?.icon ?? '🐕')
.fontSize(12)
Text(this.selectedAppointment?.petType ?? '').fontSize(11).fontColor('#888888').margin({ left: 4 })
Text('·').fontSize(11).fontColor('#CCCCCC').margin({ left: 4 })
Text(this.selectedAppointment?.service ?? '').fontSize(11)
.fontColor(SERVICE_CONFIG[this.selectedAppointment?.service ?? '基础洗护']?.color ?? '#7E57C2')
.margin({ left: 4 })
}
.margin({ top: 4 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18 })
头部区域是一个三段式布局:左侧是 60×60 的服务图标方块(淡紫背景圆角),中间是宠物名(大号粗体)和服务摘要行(体型图标 · 种类 · 服务名,服务名用该项的主题色),右侧是关闭按钮。这种"图标 + 标题/副标题 + 操作"的头部布局非常通用。
Scroll() {
Column() {
Row() {
Column() {
Text('📅 日期').fontSize(10).fontColor('#888888')
Text(this.selectedAppointment?.date ?? '').fontSize(13).fontColor('#212121').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('⏰ 时间').fontSize(10).fontColor('#888888')
Text(this.selectedAppointment?.time ?? '').fontSize(13).fontColor('#212121').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').margin({ top: 12 })
信息区采用"两列一组"的网格布局,每组两个信息项横向排列,每项包含一个带 emoji 的灰色小标签和对应的内容值。日期、时间、时长、费用、美容师、状态、主人、电话共八项信息分四行展示。费用项的数字用紫色粗体强调,状态项的文字颜色根据状态动态取值。
if (this.selectedAppointment?.notes !== '') {
Column() {
Text('📝 备注').fontSize(10).fontColor('#888888')
Text(this.selectedAppointment?.notes ?? '').fontSize(13).fontColor('#555555').margin({ top: 4 })
.width('100%')
}
.width('100%').margin({ top: 10 }).alignItems(HorizontalAlign.Start)
}
if ((this.selectedAppointment?.rating ?? 0) > 0) {
Column() {
Text('⭐ 评价').fontSize(10).fontColor('#888888')
Row() {
ForEach([1, 2, 3, 4, 5], (i: number) => {
Text(i <= (this.selectedAppointment?.rating ?? 0) ? '⭐' : '☆').fontSize(14)
})
}
.margin({ top: 4 })
}
.width('100%').margin({ top: 10 }).alignItems(HorizontalAlign.Start)
}
备注和评价是条件渲染的:只有当备注非空时才显示备注区块,只有当评分大于 0 时才显示星级评价。星级评价用 ForEach([1,2,3,4,5]) 渲染五颗星,通过 i <= rating ? '⭐' : '☆' 决定每颗星是实心还是空心,这是一种非常简洁的星级展示实现。
弹窗底部是"✏️ 编辑"(橙色)和"🗑️ 取消"(红色)两个操作按钮,分别打开编辑弹窗和删除确认弹窗,形成了弹窗之间的链式跳转。
瀑布流卡片:左右两列的差异
瀑布流的核心是两个卡片构建器,左列卡片较短,右列卡片较长(含备注):
@Builder waterfallCardLeft(a: GroomingAppointment) {
Column() {
Row() {
Column() {
Text(SERVICE_CONFIG[a.service]?.icon ?? '📅').fontSize(24)
}.width(40).height(40)
.backgroundColor(SERVICE_CONFIG[a.service]?.bg ?? '#F3E5F5').borderRadius(10)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text(a.petName).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
Text(PET_SIZE_CONFIG[a.petSize]?.label ?? '').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Text(STATUS_CONFIG[a.status]?.label ?? '待确认').fontSize(9)
.fontColor(STATUS_CONFIG[a.status]?.color ?? '#FF8F00')
.backgroundColor(STATUS_CONFIG[a.status]?.bg ?? '#FFF8E1')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
}
.width('100%')
Row() {
Text('📅').fontSize(10).fontColor('#888888')
Text(a.date + ' ' + a.time).fontSize(10).fontColor('#666666').margin({ left: 4 })
}
.margin({ top: 8 })
Row() {
Text('🎨').fontSize(10).fontColor('#888888')
Text(a.service).fontSize(10).fontColor(SERVICE_CONFIG[a.service]?.color ?? '#7E57C2').margin({ left: 4 })
Row().layoutWeight(1)
Text('¥' + a.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#7E57C2')
}
.margin({ top: 6 })
Row() {
Text(GROOMER_CONFIG[a.groomer]?.avatar ?? '👤').fontSize(10)
Text(a.groomer).fontSize(10).fontColor('#888888').margin({ left: 4 })
Row().layoutWeight(1)
Text(a.duration + 'min').fontSize(9).fontColor('#AAAAAA')
}
.margin({ top: 6 })
}
.width('100%').padding(12).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ left: 6, right: 3, top: 6 })
.shadow({ radius: 4, color: '#1A7E57C2', offsetY: 2 })
.onClick(() => { this.selectedAppointment = a; this.showDetailModal = true })
}
左列卡片的结构是:头部行(服务图标 + 宠物名/体型 + 状态标签)、日期行、服务+价格行、美容师+时长行。每行都用 emoji 作为视觉锚点,配合 Row().layoutWeight(1) 弹性占位实现左右对齐。卡片整体白色背景、圆角 12、淡紫色阴影(#1A7E57C2 是带透明度的紫色),点击后设置 selectedAppointment 并打开详情弹窗。
右列卡片 waterfallCardRight 与左列类似,但增加了两个内容:一是头部副标题额外显示了宠物种类,二是底部多了一个备注区块:
if (a.notes !== '') {
Divider().color('#F5F5F5').margin({ top: 8, bottom: 6 })
Text('📝 ' + a.notes).fontSize(10).fontColor('#999999').maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
备注区块用一条极淡的分割线与上方内容隔开,备注文字限制最多 2 行(maxLines(2)),超出部分用省略号显示(textOverflow: Ellipsis)。这种处理保证了卡片高度不会因为备注过长而失控,维持了瀑布流的视觉节奏。左右两列卡片的高度差异正是瀑布流效果的基础——左列短卡和右列长卡交错排列,形成参差错落的视觉效果。
列表页主构建方法
build() {
Stack() {
Column() {
Column() {
Row() {
Column() {
Text('🐾 萌宠美容').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('专业宠物美容预约平台').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
}.alignItems(HorizontalAlign.Start)
Row().layoutWeight(1)
Text('➕').fontSize(22).fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.25)').width(36).height(36).borderRadius(18)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 12 })
页面头部是一个渐变背景的 Column(linearGradient 从 #7E57C2 到 #AB47BC,135 度角),包含标题行和统计条。标题行左侧是应用名和副标题,右侧是一个半透明白色圆形的"➕"按钮,点击打开新建预约弹窗。
Row() {
Column() {
Text(getTotalAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('总预约').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getTodayAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFF176')
Text('今日').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getPendingAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFCC80')
Text('待确认').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getCompletedAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#80CBC4')
Text('已完成').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ bottom: 14 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
统计条是四等分的横向布局,每格显示一个数字和一个标签。四个数字用了不同的颜色:总预约白色(主信息),今日柠檬黄(强调当前),待确认浅橙(提醒注意),已完成薄荷绿(完成感)。这种"同尺寸不同色"的设计让用户在头部就能获取关键运营指标,无需翻页到统计页。
Scroll() {
Row() {
ForEach(STATUS_FILTERS, (s: string) => {
if (this.selectedStatus === s) {
Text(s).fontSize(11).fontColor('#FFFFFF').backgroundColor('#7E57C2')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
} else {
Text(s).fontSize(11).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
.onClick(() => { this.selectedStatus = s })
}
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(38)
状态筛选是一个横向可滚动的标签条,包含"全部"和五种状态共六个标签。scrollable(ScrollDirection.Horizontal) 启用横向滚动,scrollBar(BarState.Off) 隐藏滚动条保持美观。选中态紫色填充白字,未选中态淡紫底紫字,与全应用的选择器风格一致。
Scroll() {
Row() {
Column() {
this.waterfallCardLeft(mockAppointments[0])
this.waterfallCardLeft(mockAppointments[2])
// ... 偶数索引项
this.waterfallCardLeft(mockAppointments[22])
}
.layoutWeight(1)
Column() {
this.waterfallCardRight(mockAppointments[1])
this.waterfallCardRight(mockAppointments[3])
// ... 奇数索引项
this.waterfallCardRight(mockAppointments[23])
}
.layoutWeight(1)
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
瀑布流主体是一个纵向 Scroll,内部是一个 Row 包含两个等宽 Column(layoutWeight(1))。左列放偶数索引的预约(0, 2, 4…),右列放奇数索引的预约(1, 3, 5…),每列各 12 张卡片。这种手动分配两列的方式简单直接,虽然不如真正的瀑布流算法(按高度动态分配)智能,但在数据量固定且卡片高度差异可控的场景下完全够用。
最后,四个弹窗通过 if 条件渲染叠在 Stack 最上层:
if (this.showAddModal) { this.addAppointmentModal() }
if (this.showEditModal) { this.editAppointmentModal() }
if (this.showDeleteConfirm) { this.deleteConfirmModal() }
if (this.showDetailModal) { this.detailModal() }
Stack 是层叠布局,后渲染的元素在上层,所以弹窗会覆盖在列表之上。每个弹窗的根容器都设置了 position({ x: 0, y: 0 }) 和 zIndex(999),确保它正确覆盖全屏。
九、套餐列表页:商品化的服务展示
套餐页将服务打包成"商品"来展示,借鉴了电商的展示模式:
@Component
struct ServicePackageContent {
@State selectedCategory: string = '全部'
@State showDetailModal: boolean = false
@State selectedPackage: ServicePackage | null = null
状态比预约页简单,只有分类筛选、详情弹窗显隐、选中的套餐对象三项。
套餐详情弹窗展示了完整的商品信息:
@Builder detailModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Text(this.selectedPackage?.icon ?? '🎁').fontSize(40)
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
Text(this.selectedPackage?.name ?? '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
.margin({ top: 12 })
Text(this.selectedPackage?.description ?? '').fontSize(13).fontColor('#888888').margin({ top: 4 })
弹窗顶部是大号 emoji 图标,然后是套餐名(大号粗体)和描述(灰色)。接着是三列信息行:
Row() {
Column() {
Text('💰 价格').fontSize(10).fontColor('#888888')
Text('¥' + (this.selectedPackage?.price ?? 0)).fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#7E57C2').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('📦 原价').fontSize(10).fontColor('#888888')
Text('¥' + (this.selectedPackage?.originalPrice ?? 0)).fontSize(14)
.fontColor('#CCCCCC').margin({ top: 2 })
.decoration({ type: TextDecorationType.LineThrough })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('⏱️ 时长').fontSize(10).fontColor('#888888')
Text((this.selectedPackage?.duration ?? 0) + 'min').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#212121').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
三列分别展示价格(紫色大号粗体,最醒目)、原价(灰色带删除线,形成对比)和时长(深色粗体)。TextDecorationType.LineThrough 给原价添加删除线,是电商常见的促销视觉手段。
包含服务列表用 ForEach 渲染,每项前加一个绿色"✅"勾选标记:
Column() {
ForEach(this.selectedPackage?.includes ?? [], (item: string) => {
Row() {
Text('✅').fontSize(12)
Text(item).fontSize(12).fontColor('#555555').margin({ left: 8 })
}
.width('100%').padding({ top: 4, bottom: 4, left: 20 })
})
}
弹窗底部展示销量和评分,以及一个紫色的"立即预约"大按钮。
套餐卡片 packageCard 是列表中的展示单元:
@Builder packageCard(p: ServicePackage) {
Column() {
Row() {
Column() {
Text(p.icon).fontSize(32)
}.width(56).height(56)
.backgroundColor(p.popular ? '#FFF8E1' : '#F3E5F5').borderRadius(14)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text(p.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
Text(p.description).fontSize(10).fontColor('#888888').margin({ top: 2 })
Row() {
Text('⭐ ' + p.rating).fontSize(10).fontColor('#FF8F00')
Text('🔥 ' + p.salesCount + '单').fontSize(10).fontColor('#888888').margin({ left: 8 })
}
.margin({ top: 4 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + p.price).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#7E57C2')
Text('¥' + p.originalPrice).fontSize(11).fontColor('#CCCCCC')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ top: 2 })
}.alignItems(HorizontalAlign.End)
}
.width('100%')
卡片头部是三段式:左侧 56×56 的图标方块(热门项背景为暖黄,普通项为淡紫),中间是套餐名、描述、评分+销量行,右侧是价格和划线原价。这种布局信息密度高但不显拥挤,因为通过字号和颜色的层级区分了主次。
Row() {
ForEach(p.includes, (item: string) => {
Text(item).fontSize(9).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 2, right: 2 })
})
}
.width('100%').margin({ top: 10 })
if (p.popular) {
Row() {
Text('🔥 热门推荐').fontSize(10).fontColor('#FF8F00').fontWeight(FontWeight.Bold)
Row().layoutWeight(1)
Text('查看详情 >').fontSize(10).fontColor('#7E57C2')
}
.width('100%').margin({ top: 8 })
}
卡片中部用淡紫色小标签横向排列套餐包含的服务项,字号 9 非常小,起到"概览"作用。如果是热门套餐,底部还会显示"🔥 热门推荐"标签和"查看详情 >"引导链接。热门标签用橙色粗体,引导链接用紫色,两者颜色不同但都传达了"行动召唤"的语义。
套餐页的 build 方法结构与预约页类似:渐变头部("🎁 美容套餐"标题)→ 分类筛选条(全部/基础/造型/护理)→ 纵向滚动的套餐卡片列表。
十、宠物档案页:健康档案的精细管理
宠物档案页的设计重点在于展示宠物的完整健康画像:
@Component
struct PetProfileContent {
@State showDetailModal: boolean = false
@State showDeleteConfirm: boolean = false
@State selectedPet: PetProfile | null = null
档案详情弹窗是信息最密集的弹窗之一:
@Builder detailModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Text(this.selectedPet?.avatar ?? '🐾').fontSize(48)
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
Text(this.selectedPet?.name ?? '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#212121')
.margin({ top: 8 })
Text((this.selectedPet?.breed ?? '') + ' · ' + (this.selectedPet?.gender ?? '') + ' · ' + (this.selectedPet?.age ?? 0) + '岁')
.fontSize(12).fontColor('#888888').margin({ top: 4 })
弹窗顶部展示大号头像(48 号字)、宠物名(20 号粗体)、以及"品种 · 性别 · 年龄"的摘要行。这种"大头像 + 名字 + 一行摘要"是个人信息卡片的经典范式。
Row() {
Column() {
Text('⚖️ 体重').fontSize(10).fontColor('#888888')
Text((this.selectedPet?.weight ?? 0) + 'kg').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#7E57C2').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('🎨 毛色').fontSize(10).fontColor('#888888')
Text(this.selectedPet?.color ?? '').fontSize(14).fontColor('#212121').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('🏥 就诊').fontSize(10).fontColor('#888888')
Text((this.selectedPet?.visitCount ?? 0) + '次').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#26A69A').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
三列基本信息:体重(紫色粗体)、毛色(深色)、就诊次数(薄荷绿粗体)。体重和就诊次数用了粗体强调,因为它们是动态变化的健康指标;毛色是静态信息,用普通字重即可。
健康信息区块是档案页的灵魂:
Column() {
Text('🏥 健康信息').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
.width('100%').padding({ left: 20, top: 16, bottom: 8 })
Row() {
Text('疫苗').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.vaccinated ? '✅ 已接种' : '❌ 未接种').fontSize(12)
.fontColor(this.selectedPet?.vaccinated ? '#43A047' : '#EF5350')
}
.width('100%').padding({ left: 20, right: 20, top: 4 })
Row() {
Text('绝育').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.neutered ? '✅ 已绝育' : '❌ 未绝育').fontSize(12)
.fontColor(this.selectedPet?.neutered ? '#43A047' : '#EF5350')
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Row() {
Text('过敏').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.allergies ?? '无').fontSize(12)
.fontColor(this.selectedPet?.allergies === '无' ? '#43A047' : '#EF5350')
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Row() {
Text('最后就诊').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.lastVisit ?? '').fontSize(12).fontColor('#212121')
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
}
每项健康信息都是"左侧标签 + 右侧状态值"的行式布局。疫苗、绝育、过敏三项的状态值颜色会根据布尔值动态变化:正常状态绿色,异常状态红色。这种"红绿双色"的语义化设计让美容师能瞬间识别需要特别注意的宠物。过敏项还做了特殊处理:当过敏值为"无"时显示绿色,有具体过敏源时显示红色,比简单的布尔判断更精细。
宠物卡片 petCard 在列表中展示,头部是头像 + 名字 + 品种 + 性别/年龄/体重标签组,右侧是疫苗标记和就诊次数。卡片底部展示绝育状态、毛色和最近就诊日期。
十一、统计页:纯代码绘制的图表
统计页没有使用任何第三方图表库,所有图表都是用基础组件手动绘制的,这体现了声明式 UI 的灵活性。
月度收入柱状图
Column() {
Text('💰 月度收入趋势').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
Column() {
Text((getMonthlyRevenue(i) / 1000).toFixed(1) + 'k')
.fontSize(7).fontColor('#7E57C2').margin({ bottom: 2 })
Column()
.width(16)
.height((getMonthlyRevenue(i) / MAX_REVENUE * 100).toFixed(0) + 'vp')
.linearGradient({ angle: 180, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.borderRadius({ topLeft: 3, topRight: 3 })
Text(MONTH_LABELS[i]).fontSize(7).fontColor('#999999').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 8, right: 8, bottom: 12 })
}
柱状图的实现思路非常巧妙。12 根柱子用 ForEach 渲染,每根柱子是一个纵向 Column,从上到下依次是:金额标签(除以 1000 转成 k 单位,保留 1 位小数)、柱子本体(一个 Column 设置宽 16、高度通过计算得出)、月份标签。柱子高度的计算公式是 getMonthlyRevenue(i) / MAX_REVENUE * 100,再拼接 'vp' 作为单位——也就是把最大值映射为 100vp,其他值按比例缩放。柱子使用了从紫到深紫的纵向渐变(angle: 180),顶部圆角 3,视觉效果接近专业图表库。
四格统计
Row() {
Column() {
Text('📋').fontSize(20).margin({ top: 4 })
Text(getTotalAppointments().toString()).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#7E57C2')
Text('总预约').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
.backgroundColor('#FFFFFF').borderRadius(10).margin({ left: 6, right: 3, top: 8 })
Column() {
Text('💰').fontSize(20).margin({ top: 4 })
Text('¥' + getTotalRevenue()).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#43A047')
Text('本月收入').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
.backgroundColor('#FFFFFF').borderRadius(10).margin({ left: 3, right: 6, top: 8 })
}
两个大号统计卡片横向排列,分别展示总预约数(紫色)和本月收入(绿色)。数字 22 号粗体非常醒目,是整个统计页最突出的数字。
服务类型分布
Column() {
ForEach(SERVICE_TYPES, (s: string) => {
Row() {
Text(SERVICE_CONFIG[s]?.icon + ' ' + s).fontSize(11)
.fontColor(SERVICE_CONFIG[s]?.color ?? '#7E57C2').layoutWeight(1)
Text(getServiceCount(s) + '次').fontSize(11).fontColor('#888888')
Text(getServicePercent(s) + '%').fontSize(11).fontColor('#7E57C2')
.fontWeight(FontWeight.Bold).margin({ left: 8 })
}
.width('100%').padding({ top: 4, bottom: 4 })
Row() {
Column()
.width(getServicePercent(s) + '%')
.height(6)
.linearGradient({ angle: 0, colors: [[SERVICE_CONFIG[s]?.color ?? '#7E57C2', 0], [SERVICE_CONFIG[s]?.color ?? '#7E57C2', 1]] })
.borderRadius(3)
Column().layoutWeight(1)
}
.width('100%').margin({ bottom: 6 })
})
}
服务分布用了横向进度条的样式。每种服务占两行:第一行是"图标+名称 | 次数 | 百分比"三段式信息行,第二行是一个 6vp 高的进度条,进度条宽度直接用百分比字符串作为宽度值(width(getServicePercent(s) + '%')),这是声明式 UI 的一个便利特性。进度条颜色取自该服务类型的主题色,与全应用的配色体系一致。进度条右侧用一个 Column().layoutWeight(1) 占满剩余宽度,这样进度条不会拉伸到整行。
美容师业绩
Row() {
ForEach(GROOMER_NAMES, (g: string) => {
Column() {
Text(GROOMER_CONFIG[g]?.avatar ?? '👤').fontSize(20)
Text(g).fontSize(11).fontColor('#888888').margin({ top: 4 })
Text('⭐' + (GROOMER_CONFIG[g]?.rating ?? 0)).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor('#FF8F00').margin({ top: 2 })
Text(GROOMER_CONFIG[g]?.specialty ?? '').fontSize(8).fontColor('#AAAAAA').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })
})
}
美容师业绩用四等分的横向布局,每位美容师一列,展示头像、名字、评分(橙色粗体)和擅长领域(极小灰字)。字号从 20 递减到 8,形成了清晰的信息层级。
十二、个人中心页:门店信息与快捷入口
个人中心页是门店的"控制台",展示门店基本信息和快捷操作入口:
@Component
struct ProfileContent {
build() {
Column() {
Column() {
Text('🧑💼').fontSize(48).margin({ top: 20 })
Text('宠物美容工作室').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 8 })
Text('营业中 · 上海徐汇区').fontSize(11).fontColor('#E1BEE7').margin({ top: 4 })
Text('📞 021-1234-5678').fontSize(10).fontColor('#E1BEE7').margin({ top: 4 })
}
.width('100%').padding({ bottom: 20 })
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.alignItems(HorizontalAlign.Center)
头部是渐变背景的门店名片:大号 emoji 头像、工作室名称(白色粗体)、营业状态和地址、联系电话。居中对齐,视觉上像一个"个人主页"的头部。
Column() {
Text('快捷操作').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Column() {
Row() { Text('📅').fontSize(18); Text('预约管理').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F0F0F0')
Row() { Text('🐾').fontSize(18); Text('宠物管理').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
// ... 更多菜单项
}
.padding({ left: 16, right: 16 })
}
快捷操作是一个设置列表,每行是"图标 + 菜单名 + 弹性占位 + 箭头"的结构,行间用淡灰分割线隔开。这是 iOS 风格设置列表的经典样式,用户非常熟悉这种交互模式。营业信息区块则用"左侧标签 + 右侧值"的行式布局展示门店地址、营业时间、预约电话和门店评分。
页面底部是一个极淡灰色的版本号"v1.0 · 萌宠美容 · 2026",居中显示,作为页面的收尾。
十三、关键技术特性对比总结
为了更直观地呈现这套实现的技术特点,下面通过一个表格对比各模块的关键特性:
| 模块 | 核心布局技术 | 状态管理策略 | 交互亮点 | 视觉设计特色 |
|---|---|---|---|---|
| 入口页面 | Column + Row 弹性布局 | 单一 activeTab 状态驱动路由 | 底部 Tab 选中指示器动态渲染 | 全局暖紫主色调,统一视觉基调 |
| 预约列表 | 双列 Column 模拟瀑布流 | 14 个 @State 变量精细管控 | 四种弹窗链式跳转,状态筛选横向滚动 | 渐变头部 + 四色统计条,信息密度高 |
| 新建弹窗 | Scroll + Row 表单布局 | form* 系列双向绑定 | 标签按钮组替代下拉框,服务项主题色选中 | 淡紫输入框背景,柔和易读 |
| 详情弹窗 | 两列网格信息布局 | selectedAppointment 单源驱动 | 星级评价动态渲染,条件展示备注/评价 | 配色随服务类型动态变化 |
| 套餐列表 | 纵向 Scroll 单列卡片 | selectedCategory 分类筛选 | 划线原价 + 热门标签 + 包含服务标签 | 电商化商品展示,促销感强 |
| 宠物档案 | 纵向 Scroll 单列卡片 | 双弹窗(详情+删除确认) | 健康信息红绿语义化,过敏源特殊处理 | 健康画像完整,安全提示突出 |
| 统计页 | 纯组件手绘图表 | 无状态(纯展示) | 柱状图按比例计算高度,进度条用百分比宽度 | 渐变柱体 + 主题色进度条,专业感强 |
| 个人中心 | 设置列表 + 信息行 | 无状态(纯展示) | 快捷操作菜单 + 营业信息展示 | iOS 风格设置列表,熟悉易用 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// ============================================================
// 场景:宠物美容预约管理 App
// 布局:双列瀑布流卡片 + 渐变头部 + 浮动按钮
// 配色:暖紫 #7E57C2 / 柠檬黄 #FFF176 / 薄荷绿 #80CBC4 / 浅紫 #F3E5F5
// ============================================================
// ==================== 接口类型定义 ====================
interface ServiceTypeMeta {
label: string
icon: string
color: string
bg: string
duration: number
basePrice: number
}
interface PetSizeMeta {
label: string
icon: string
multiplier: number
}
interface AppointmentStatusMeta {
label: string
color: string
bg: string
}
interface GroomerMeta {
label: string
avatar: string
rating: number
specialty: string
}
interface TabMeta {
label: string
icon: string
}
// ==================== 数据模型 ====================
@Observed
export class GroomingAppointment {
id: number = 0
petName: string = ''
petType: string = ''
petSize: string = ''
ownerName: string = ''
phone: string = ''
service: string = ''
date: string = ''
time: string = ''
duration: number = 0
price: number = 0
status: string = '待确认'
notes: string = ''
groomer: string = ''
rating: number = 0
createdAt: string = ''
constructor(id: number, petName: string, petType: string, petSize: string, ownerName: string, phone: string, service: string, date: string, time: string, duration: number, price: number, status: string, notes: string, groomer: string, rating: number, createdAt: string) {
this.id = id; this.petName = petName; this.petType = petType; this.petSize = petSize
this.ownerName = ownerName; this.phone = phone; this.service = service
this.date = date; this.time = time; this.duration = duration; this.price = price
this.status = status; this.notes = notes; this.groomer = groomer
this.rating = rating; this.createdAt = createdAt
}
}
@Observed
export class ServicePackage {
id: number = 0
name: string = ''
icon: string = ''
description: string = ''
price: number = 0
originalPrice: number = 0
duration: number = 0
category: string = ''
popular: boolean = false
salesCount: number = 0
rating: number = 0
includes: string[] = []
constructor(id: number, name: string, icon: string, description: string, price: number, originalPrice: number, duration: number, category: string, popular: boolean, salesCount: number, rating: number, includes: string[]) {
this.id = id; this.name = name; this.icon = icon; this.description = description
this.price = price; this.originalPrice = originalPrice; this.duration = duration
this.category = category; this.popular = popular; this.salesCount = salesCount
this.rating = rating; this.includes = includes
}
}
@Observed
export class PetProfile {
id: number = 0
name: string = ''
type: string = ''
breed: string = ''
size: string = ''
age: number = 0
gender: string = ''
weight: number = 0
color: string = ''
avatar: string = ''
vaccinated: boolean = false
neutered: boolean = false
allergies: string = ''
notes: string = ''
visitCount: number = 0
lastVisit: string = ''
constructor(id: number, name: string, type: string, breed: string, size: string, age: number, gender: string, weight: number, color: string, avatar: string, vaccinated: boolean, neutered: boolean, allergies: string, notes: string, visitCount: number, lastVisit: string) {
this.id = id; this.name = name; this.type = type; this.breed = breed
this.size = size; this.age = age; this.gender = gender; this.weight = weight
this.color = color; this.avatar = avatar; this.vaccinated = vaccinated
this.neutered = neutered; this.allergies = allergies; this.notes = notes
this.visitCount = visitCount; this.lastVisit = lastVisit
}
}
// ==================== 静态配置 ====================
const SERVICE_CONFIG: Record<string, ServiceTypeMeta> = {
'基础洗护': { label: '基础洗护', icon: '🛁', color: '#7E57C2', bg: '#F3E5F5', duration: 60, basePrice: 88 },
'精致造型': { label: '精致造型', icon: '✂️', color: '#AB47BC', bg: '#F3E5F5', duration: 120, basePrice: 168 },
'SPA护理': { label: 'SPA护理', icon: '🧖', color: '#26A69A', bg: '#E0F2F1', duration: 90, basePrice: 128 },
'药浴除蚤': { label: '药浴除蚤', icon: '💊', color: '#EF5350', bg: '#FFEBEE', duration: 75, basePrice: 108 },
'指甲护理': { label: '指甲护理', icon: '💅', color: '#FF8F00', bg: '#FFF8E1', duration: 30, basePrice: 38 },
'牙齿清洁': { label: '牙齿清洁', icon: '🦷', color: '#42A5F5', bg: '#E3F2FD', duration: 40, basePrice: 48 }
}
const PET_SIZE_CONFIG: Record<string, PetSizeMeta> = {
'小型': { label: '小型犬', icon: '🐕', multiplier: 1.0 },
'中型': { label: '中型犬', icon: '🐕🦺', multiplier: 1.5 },
'大型': { label: '大型犬', icon: '🦮', multiplier: 2.0 },
'猫': { label: '猫咪', icon: '🐈', multiplier: 1.2 }
}
const STATUS_CONFIG: Record<string, AppointmentStatusMeta> = {
'待确认': { label: '待确认', color: '#FF8F00', bg: '#FFF8E1' },
'已确认': { label: '已确认', color: '#7E57C2', bg: '#F3E5F5' },
'进行中': { label: '进行中', color: '#26A69A', bg: '#E0F2F1' },
'已完成': { label: '已完成', color: '#43A047', bg: '#E8F5E9' },
'已取消': { label: '已取消', color: '#EF5350', bg: '#FFEBEE' }
}
const GROOMER_CONFIG: Record<string, GroomerMeta> = {
'小美': { label: '小美', avatar: '👩🦰', rating: 4.9, specialty: '精致造型' },
'阿杰': { label: '阿杰', avatar: '👨', rating: 4.8, specialty: '大型犬护理' },
'丽丽': { label: '丽丽', avatar: '👩', rating: 5.0, specialty: '猫咪SPA' },
'大熊': { label: '大熊', avatar: '🧔', rating: 4.7, specialty: '药浴除蚤' }
}
const TAB_CONFIG: Record<string, TabMeta> = {
'appointments': { label: '预约', icon: '📅' },
'services': { label: '套餐', icon: '🎁' },
'pets': { label: '档案', icon: '🐾' },
'stats': { label: '统计', icon: '📊' },
'profile': { label: '我的', icon: '👤' }
}
const PET_TYPES: string[] = ['狗', '猫', '兔子', '仓鼠']
const PET_SIZES: string[] = ['小型', '中型', '大型', '猫']
const SERVICE_TYPES: string[] = ['基础洗护', '精致造型', 'SPA护理', '药浴除蚤', '指甲护理', '牙齿清洁']
const STATUS_FILTERS: string[] = ['全部', '待确认', '已确认', '进行中', '已完成', '已取消']
const GROOMER_NAMES: string[] = ['小美', '阿杰', '丽丽', '大熊']
// ==================== 月度收入柱状图数据 ====================
const MONTHLY_REVENUE: number[] = [3200, 4100, 3800, 5200, 4800, 6100, 5600, 6800, 7200, 6500, 7800, 8400]
const MONTH_LABELS: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
const MAX_REVENUE: number = 8400
// ==================== 服务类型分布 ====================
const SERVICE_DISTRIBUTION: Record<string, number> = {
'基础洗护': 145,
'精致造型': 89,
'SPA护理': 67,
'药浴除蚤': 52,
'指甲护理': 38,
'牙齿清洁': 24
}
const TOTAL_SERVICES: number = 415
// ==================== 预约数据(24条) ====================
const mockAppointments: GroomingAppointment[] = [
new GroomingAppointment(1, '豆豆', '狗', '小型', '王女士', '138****6688', '精致造型', '2026-08-06', '10:00', 120, 168, '已确认', '泰迪犬标准造型,修剪圆头', '小美', 5, '2026-08-01'),
new GroomingAppointment(2, '咪咪', '猫', '猫', '李先生', '139****2233', 'SPA护理', '2026-08-06', '11:30', 90, 128, '进行中', '英短蓝猫,温和型SPA', '丽丽', 5, '2026-08-02'),
new GroomingAppointment(3, '大黄', '狗', '大型', '张师傅', '137****8899', '基础洗护', '2026-08-06', '14:00', 60, 176, '待确认', '金毛,需要注意皮肤敏感', '阿杰', 4, '2026-08-03'),
new GroomingAppointment(4, '球球', '狗', '小型', '陈小姐', '135****4567', '药浴除蚤', '2026-08-06', '15:30', 75, 108, '待确认', '比熊,最近有跳蚤', '大熊', 4, '2026-08-04'),
new GroomingAppointment(5, '花花', '猫', '猫', '刘女士', '136****1234', '基础洗护', '2026-08-06', '16:30', 60, 96, '已完成', '布偶猫,长毛需要深层清洁', '丽丽', 5, '2026-08-01'),
new GroomingAppointment(6, '旺财', '狗', '中型', '赵先生', '133****5678', '精致造型', '2026-08-07', '09:00', 120, 252, '已确认', '柯基,修短毛+染色', '小美', 5, '2026-08-02'),
new GroomingAppointment(7, '小白', '狗', '小型', '钱女士', '132****9012', '指甲护理', '2026-08-07', '10:30', 30, 38, '已完成', '博美,指甲较长', '阿杰', 4, '2026-07-28'),
new GroomingAppointment(8, '奥利奥', '猫', '猫', '孙先生', '131****3456', '牙齿清洁', '2026-08-07', '11:00', 40, 48, '已完成', '美短虎斑,牙结石较多', '丽丽', 5, '2026-07-30'),
new GroomingAppointment(9, '大奔', '狗', '大型', '周师傅', '130****7890', 'SPA护理', '2026-08-07', '13:00', 90, 256, '已确认', '拉布拉多,运动后SPA放松', '阿杰', 5, '2026-08-03'),
new GroomingAppointment(10, '奶茶', '狗', '小型', '吴女士', '188****3344', '精致造型', '2026-08-07', '14:30', 120, 168, '待确认', '贵宾,泰迪装+蝴蝶结', '小美', 5, '2026-08-04'),
new GroomingAppointment(11, '胖橘', '猫', '猫', '郑先生', '187****5566', '药浴除蚤', '2026-08-07', '16:00', 75, 108, '待确认', '橘猫,体重8kg,注意安抚', '大熊', 4, '2026-08-05'),
new GroomingAppointment(12, '黑妞', '狗', '中型', '王先生', '186****7788', '基础洗护', '2026-08-08', '09:30', 60, 132, '已确认', '边牧,双层毛需要深度清洁', '阿杰', 5, '2026-08-02'),
new GroomingAppointment(13, '棉花', '狗', '小型', '李女士', '185****9900', 'SPA护理', '2026-08-08', '11:00', 90, 128, '已确认', '比熊,白毛SPA提亮', '丽丽', 5, '2026-08-03'),
new GroomingAppointment(14, '坦克', '狗', '大型', '张先生', '184****1122', '药浴除蚤', '2026-08-08', '13:30', 75, 216, '待确认', '德牧,户外活动多跳蚤严重', '大熊', 4, '2026-08-05'),
new GroomingAppointment(15, '布丁', '猫', '猫', '陈先生', '183****3344', '精致造型', '2026-08-08', '15:00', 120, 168, '已取消', '加菲猫,主人出差延期', '丽丽', 0, '2026-08-01'),
new GroomingAppointment(16, '巧克力', '狗', '中型', '刘先生', '182****5566', '基础洗护', '2026-08-09', '10:00', 60, 132, '已完成', '柴犬,换毛季需要去底绒', '阿杰', 5, '2026-07-25'),
new GroomingAppointment(17, '雪球', '狗', '小型', '赵女士', '181****7788', '指甲护理', '2026-08-09', '11:00', 30, 38, '已完成', '银狐犬,指甲弯曲需特殊处理', '小美', 4, '2026-07-27'),
new GroomingAppointment(18, '毛毛', '猫', '猫', '钱先生', '180****9900', 'SPA护理', '2026-08-09', '13:00', 90, 128, '已完成', '暹罗猫,怕水需要耐心安抚', '丽丽', 5, '2026-07-28'),
new GroomingAppointment(19, '阿福', '狗', '大型', '孙女士', '189****1212', '精致造型', '2026-08-09', '14:30', 120, 336, '已确认', '阿拉斯加,修毛+梳底绒', '阿杰', 5, '2026-08-04'),
new GroomingAppointment(20, '小可', '狗', '小型', '周女士', '177****3434', '基础洗护', '2026-08-10', '09:00', 60, 88, '待确认', '约克夏,细毛需要柔顺护理', '小美', 4, '2026-08-05'),
new GroomingAppointment(21, '团子', '猫', '猫', '吴先生', '176****5656', '牙齿清洁', '2026-08-10', '10:30', 40, 48, '待确认', '缅因猫,体型大需要两个人配合', '丽丽', 5, '2026-08-05'),
new GroomingAppointment(22, '皮皮', '狗', '中型', '郑女士', '175****7878', 'SPA护理', '2026-08-10', '13:00', 90, 192, '已确认', '哈士奇,运动量大需要放松', '阿杰', 5, '2026-08-03'),
new GroomingAppointment(23, '小黑', '狗', '大型', '王师傅', '174****9090', '药浴除蚤', '2026-08-10', '15:00', 75, 216, '已确认', '罗威纳,皮肤需要药浴护理', '大熊', 4, '2026-08-04'),
new GroomingAppointment(24, '元宝', '狗', '小型', '李师傅', '173****1212', '精致造型', '2026-08-10', '16:30', 120, 168, '已完成', '雪纳瑞,标准雪纳瑞造型', '小美', 5, '2026-07-26')
]
// ==================== 套餐数据(16个) ====================
const mockPackages: ServicePackage[] = [
new ServicePackage(1, '萌宠基础洗护', '🛁', '深层清洁+吹干+梳毛', 88, 128, 60, '基础', true, 145, 4.8, ['温水洗浴', '专业吹干', '梳毛开结', '耳朵清洁']),
new ServicePackage(2, '泰迪精致造型', '✂️', '标准泰迪圆头造型', 168, 228, 120, '造型', true, 89, 5.0, ['全身造型', '头部修剪', '四肢修圆', '尾巴修型']),
new ServicePackage(3, '猫咪舒缓SPA', '🧖', '精油按摩+深层护理', 128, 168, 90, '护理', true, 67, 4.9, ['精油按摩', '深层洁毛', '护毛素护理', '吹干梳毛']),
new ServicePackage(4, '药浴除蚤套装', '💊', '专业药浴+除蚤驱虫', 108, 158, 75, '护理', false, 52, 4.7, ['药浴浸泡', '跳蚤清除', '皮肤检查', '防护喷剂']),
new ServicePackage(5, '指甲精细护理', '💅', '修剪+打磨+护甲', 38, 58, 30, '基础', false, 38, 4.6, ['指甲修剪', '边缘打磨', '护甲油', '肉垫护理']),
new ServicePackage(6, '超声波洁牙', '🦷', '专业洁牙+口腔护理', 48, 78, 40, '基础', false, 24, 4.8, ['超声波洁牙', '口腔检查', '口气清新', '牙龈护理']),
new ServicePackage(7, '比熊棉花糖造型', '✂️', '网红棉花糖圆球造型', 198, 268, 150, '造型', true, 76, 5.0, ['全身圆球', '头部修圆', '四肢蘑菇', '尾巴球型']),
new ServicePackage(8, '大型犬深度洗护', '🛁', '大型犬专享深层清洁', 176, 238, 60, '基础', false, 45, 4.7, ['深层洗浴', '底绒梳理', '吹干蓬松', '脚底修毛']),
new ServicePackage(9, '柯基染色造型', '✂️', '创意染色+造型设计', 252, 358, 150, '造型', false, 34, 4.9, ['造型设计', '安全染色', '固色护理', '造型定型']),
new ServicePackage(10, '布偶猫豪华SPA', '🧖', '长毛猫专享SPA护理', 158, 218, 100, '护理', true, 56, 5.0, ['长毛深层清洁', '护毛精华', '按摩放松', '蓬松吹干']),
new ServicePackage(11, '金毛去底绒套餐', '🛁', '换毛季专享去底绒', 196, 268, 90, '基础', false, 41, 4.8, ['底绒梳除', '深层洗浴', '吹干蓬松', '毛发护理']),
new ServicePackage(12, '小型犬豪华套餐', '🎁', '洗护+造型+SPA全套', 298, 428, 180, '造型', true, 62, 5.0, ['基础洗护', '精致造型', 'SPA护理', '指甲护理']),
new ServicePackage(13, '猫咪洁牙+洗护', '🎁', '洁牙+基础洗护组合', 128, 188, 80, '护理', false, 29, 4.7, ['超声波洁牙', '基础洗浴', '梳毛吹干', '口腔检查']),
new ServicePackage(14, '药浴+造型组合', '🎁', '药浴+造型二合一', 228, 318, 150, '造型', false, 38, 4.8, ['药浴除蚤', '精致造型', '皮肤护理', '防护处理']),
new ServicePackage(15, '老年犬温和护理', '🧖', '老年犬专享温和SPA', 148, 198, 80, '护理', false, 22, 4.9, ['温水舒缓', '轻柔按摩', '关节护理', '温和吹干']),
new ServicePackage(16, '赛季犬造型套餐', '✂️', '比赛级专业造型', 388, 528, 200, '造型', false, 15, 5.0, ['赛前造型', '精细修剪', '毛质护理', '造型定型'])
]
// ==================== 宠物档案数据(12只) ====================
const mockPets: PetProfile[] = [
new PetProfile(1, '豆豆', '狗', '泰迪', '小型', 3, '公', 5.2, '棕色', '🐶', true, true, '无', '活泼好动,喜欢被抱', 15, '2026-08-01'),
new PetProfile(2, '咪咪', '猫', '英短蓝猫', '猫', 2, '母', 4.5, '蓝色', '🐱', true, true, '无', '怕水,需要耐心', 12, '2026-08-02'),
new PetProfile(3, '大黄', '狗', '金毛', '大型', 5, '公', 32.0, '金色', '🐕', true, true, '对鸡肉过敏', '皮肤敏感,需用低敏沐浴露', 8, '2026-08-03'),
new PetProfile(4, '球球', '狗', '比熊', '小型', 2, '母', 4.8, '白色', '🐶', true, false, '无', '白毛容易发黄,需定期护理', 10, '2026-08-04'),
new PetProfile(5, '花花', '猫', '布偶', '猫', 4, '母', 6.0, '海豹色', '🐱', true, true, '无', '长毛猫,梳毛要轻柔', 18, '2026-08-01'),
new PetProfile(6, '旺财', '狗', '柯基', '中型', 3, '公', 12.5, '黄白', '🐕', true, true, '无', '腿短,洗澡注意腹部', 20, '2026-08-02'),
new PetProfile(7, '小白', '狗', '博美', '小型', 4, '母', 3.5, '白色', '🐶', true, true, '无', '指甲弯曲,修剪要小心', 14, '2026-07-28'),
new PetProfile(8, '奥利奥', '猫', '美短虎斑', '猫', 3, '公', 5.5, '虎斑', '🐱', true, true, '无', '牙结石较多,定期洁牙', 11, '2026-07-30'),
new PetProfile(9, '大奔', '狗', '拉布拉多', '大型', 4, '公', 28.0, '黄色', '🐕', true, true, '无', '运动量大,喜欢水', 9, '2026-08-03'),
new PetProfile(10, '奶茶', '狗', '贵宾', '小型', 2, '母', 4.0, '棕色', '🐶', true, false, '无', '喜欢戴蝴蝶结', 16, '2026-08-04'),
new PetProfile(11, '胖橘', '猫', '橘猫', '猫', 5, '公', 8.0, '橘色', '🐱', true, true, '无', '超重,洗澡注意心脏', 7, '2026-08-05'),
new PetProfile(12, '黑妞', '狗', '边牧', '中型', 3, '母', 15.0, '黑白', '🐕', true, true, '无', '双层毛,换毛季需要去底绒', 13, '2026-08-02')
]
// ==================== 统计函数 ====================
function getTotalAppointments(): number { return 24 }
function getTodayAppointments(): number { return 5 }
function getPendingAppointments(): number { return 8 }
function getCompletedAppointments(): number { return 9 }
function getTotalRevenue(): number { return 3248 }
function getTotalPets(): number { return 12 }
function getMonthlyRevenue(idx: number): number { return MONTHLY_REVENUE[idx] }
function getServiceCount(key: string): number { return SERVICE_DISTRIBUTION[key] ?? 0 }
function getServicePercent(key: string): number { return Math.round((SERVICE_DISTRIBUTION[key] ?? 0) / TOTAL_SERVICES * 100) }
function getMaxServiceCount(): number { return 145 }
// ==================== 底部 Tab 枚举 ====================
enum GroomingTab {
APPOINTMENTS = 0,
SERVICES = 1,
PETS = 2,
STATS = 3,
PROFILE = 4
}
// ==================== 入口页面 ====================
@Entry
@Component
struct PetGroomingApp {
@State activeTab: GroomingTab = GroomingTab.APPOINTMENTS
@Builder contentArea() {
Column() {
if (this.activeTab === GroomingTab.APPOINTMENTS) {
AppointmentListContent()
} else if (this.activeTab === GroomingTab.SERVICES) {
ServicePackageContent()
} else if (this.activeTab === GroomingTab.PETS) {
PetProfileContent()
} else if (this.activeTab === GroomingTab.STATS) {
StatsContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
@Builder bottomTabItem(icon: string, label: string, tab: GroomingTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? '#7E57C2' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(18).height(3)
.backgroundColor('#7E57C2').borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('📅', '预约', GroomingTab.APPOINTMENTS)
this.bottomTabItem('🎁', '套餐', GroomingTab.SERVICES)
this.bottomTabItem('🐾', '档案', GroomingTab.PETS)
this.bottomTabItem('📊', '统计', GroomingTab.STATS)
this.bottomTabItem('👤', '我的', GroomingTab.PROFILE)
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor('#FAF5FF')
}
}
// ==================== 预约列表页(瀑布流布局) ====================
@Component
struct AppointmentListContent {
@State selectedStatus: string = '全部'
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedAppointment: GroomingAppointment | null = null
@State formPetName: string = ''
@State formPetType: string = '狗'
@State formPetSize: string = '小型'
@State formService: string = '基础洗护'
@State formDate: string = '2026-08-08'
@State formTime: string = '10:00'
@State formGroomer: string = '小美'
@State formNotes: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder addAppointmentModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('➕ 新建预约').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Scroll() {
Column() {
Text('宠物信息').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Row() {
TextInput({ placeholder: '宠物名字' })
.placeholderColor('#BBBBBB').fontSize(14).layoutWeight(1)
.backgroundColor('#F3E5F5').borderRadius(8)
.onChange((v: string) => { this.formPetName = v })
Text('类型').fontSize(12).fontColor('#888888').margin({ left: 8 })
}
.margin({ left: 20, right: 20, top: 4 })
Row() {
ForEach(PET_TYPES, (t: string) => {
if (this.formPetType === t) {
Text(t).fontSize(11).fontColor('#FFFFFF').backgroundColor('#7E57C2')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(t).fontSize(11).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formPetType = t })
}
})
}
.margin({ left: 16, right: 16, top: 6 })
Row() {
ForEach(PET_SIZES, (s: string) => {
if (this.formPetSize === s) {
Text(PET_SIZE_CONFIG[s]?.icon + ' ' + PET_SIZE_CONFIG[s]?.label)
.fontSize(11).fontColor('#FFFFFF').backgroundColor('#AB47BC')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(PET_SIZE_CONFIG[s]?.icon + ' ' + PET_SIZE_CONFIG[s]?.label)
.fontSize(11).fontColor('#AB47BC').backgroundColor('#F3E5F5')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formPetSize = s })
}
})
}
.margin({ left: 16, right: 16, top: 6 })
Text('服务项目').fontSize(12).fontColor('#888888').margin({ top: 14, left: 20 })
Row() {
ForEach(SERVICE_TYPES, (s: string) => {
if (this.formService === s) {
Text(SERVICE_CONFIG[s]?.icon + ' ' + s)
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor(SERVICE_CONFIG[s]?.color ?? '#7E57C2')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(SERVICE_CONFIG[s]?.icon + ' ' + s)
.fontSize(11).fontColor(SERVICE_CONFIG[s]?.color ?? '#7E57C2')
.backgroundColor(SERVICE_CONFIG[s]?.bg ?? '#F3E5F5')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formService = s })
}
})
}
.margin({ left: 16, right: 16, top: 4 })
Text('预约时间').fontSize(12).fontColor('#888888').margin({ top: 14, left: 20 })
Row() {
TextInput({ placeholder: '日期 2026-08-08' })
.placeholderColor('#BBBBBB').fontSize(12).layoutWeight(1)
.backgroundColor('#F3E5F5').borderRadius(8)
.onChange((v: string) => { this.formDate = v })
TextInput({ placeholder: '时间 10:00' })
.placeholderColor('#BBBBBB').fontSize(12).layoutWeight(1)
.backgroundColor('#F3E5F5').borderRadius(8).margin({ left: 8 })
.onChange((v: string) => { this.formTime = v })
}
.margin({ left: 20, right: 20, top: 4 })
Text('美容师').fontSize(12).fontColor('#888888').margin({ top: 14, left: 20 })
Row() {
ForEach(GROOMER_NAMES, (g: string) => {
if (this.formGroomer === g) {
Text(GROOMER_CONFIG[g]?.avatar + ' ' + g)
.fontSize(11).fontColor('#FFFFFF').backgroundColor('#7E57C2')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(GROOMER_CONFIG[g]?.avatar + ' ' + g)
.fontSize(11).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formGroomer = g })
}
})
}
.margin({ left: 16, right: 16, top: 4 })
Text('备注').fontSize(12).fontColor('#888888').margin({ top: 14, left: 20 })
TextArea({ placeholder: '补充说明...' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%').height(60)
.backgroundColor('#F3E5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formNotes = v })
}
}
.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('确认预约').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#7E57C2').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
Text(STATUS_CONFIG[a.status]?.label ?? '待确认').fontSize(9)
.fontColor(STATUS_CONFIG[a.status]?.color ?? '#FF8F00')
.backgroundColor(STATUS_CONFIG[a.status]?.bg ?? '#FFF8E1')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
}
.width('100%')
Row() {
Text('📅').fontSize(10).fontColor('#888888')
Text(a.date + ' ' + a.time).fontSize(10).fontColor('#666666').margin({ left: 4 })
Row().layoutWeight(1)
Text('⏱️ ' + a.duration + 'min').fontSize(9).fontColor('#AAAAAA')
}
.margin({ top: 8 })
Row() {
Text('🎨').fontSize(10).fontColor('#888888')
Text(a.service).fontSize(10).fontColor(SERVICE_CONFIG[a.service]?.color ?? '#7E57C2').margin({ left: 4 })
Row().layoutWeight(1)
Text('¥' + a.price).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#7E57C2')
}
.margin({ top: 6 })
Row() {
Text(GROOMER_CONFIG[a.groomer]?.avatar ?? '👤').fontSize(10)
Text(a.groomer).fontSize(10).fontColor('#888888').margin({ left: 4 })
Text('· 评分' + a.rating).fontSize(9).fontColor('#FF8F00').margin({ left: 4 })
Row().layoutWeight(1)
Text('👤 ' + a.ownerName).fontSize(9).fontColor('#AAAAAA')
}
.margin({ top: 6 })
if (a.notes !== '') {
Divider().color('#F5F5F5').margin({ top: 8, bottom: 6 })
Text('📝 ' + a.notes).fontSize(10).fontColor('#999999').maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
.width('100%').padding(12).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ left: 3, right: 6, top: 6 })
.shadow({ radius: 4, color: '#1A7E57C2', offsetY: 2 })
.onClick(() => { this.selectedAppointment = a; this.showDetailModal = true })
}
build() {
Stack() {
Column() {
// 渐变头部
Column() {
Row() {
Column() {
Text('🐾 萌宠美容').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('专业宠物美容预约平台').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
}.alignItems(HorizontalAlign.Start)
Row().layoutWeight(1)
Text('➕').fontSize(22).fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.25)').width(36).height(36).borderRadius(18)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 12 })
// 统计条
Row() {
Column() {
Text(getTotalAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('总预约').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getTodayAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFF176')
Text('今日').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getPendingAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFCC80')
Text('待确认').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getCompletedAppointments().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#80CBC4')
Text('已完成').fontSize(9).fontColor('#E1BEE7')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ bottom: 14 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
// 状态筛选
Scroll() {
Row() {
ForEach(STATUS_FILTERS, (s: string) => {
if (this.selectedStatus === s) {
Text(s).fontSize(11).fontColor('#FFFFFF').backgroundColor('#7E57C2')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
} else {
Text(s).fontSize(11).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
.onClick(() => { this.selectedStatus = s })
}
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(38)
// 瀑布流双列列表
Scroll() {
Row() {
// 左列
Column() {
this.waterfallCardLeft(mockAppointments[0])
this.waterfallCardLeft(mockAppointments[2])
this.waterfallCardLeft(mockAppointments[4])
this.waterfallCardLeft(mockAppointments[6])
this.waterfallCardLeft(mockAppointments[8])
this.waterfallCardLeft(mockAppointments[10])
this.waterfallCardLeft(mockAppointments[12])
this.waterfallCardLeft(mockAppointments[14])
this.waterfallCardLeft(mockAppointments[16])
this.waterfallCardLeft(mockAppointments[18])
this.waterfallCardLeft(mockAppointments[20])
this.waterfallCardLeft(mockAppointments[22])
}
.layoutWeight(1)
// 右列
Column() {
this.waterfallCardRight(mockAppointments[1])
this.waterfallCardRight(mockAppointments[3])
this.waterfallCardRight(mockAppointments[5])
this.waterfallCardRight(mockAppointments[7])
this.waterfallCardRight(mockAppointments[9])
this.waterfallCardRight(mockAppointments[11])
this.waterfallCardRight(mockAppointments[13])
this.waterfallCardRight(mockAppointments[15])
this.waterfallCardRight(mockAppointments[17])
this.waterfallCardRight(mockAppointments[19])
this.waterfallCardRight(mockAppointments[21])
this.waterfallCardRight(mockAppointments[23])
}
.layoutWeight(1)
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showAddModal) { this.addAppointmentModal() }
if (this.showEditModal) { this.editAppointmentModal() }
if (this.showDeleteConfirm) { this.deleteConfirmModal() }
if (this.showDetailModal) { this.detailModal() }
}
}
}
// ==================== 套餐列表页 ====================
@Component
struct ServicePackageContent {
@State selectedCategory: string = '全部'
@State showDetailModal: boolean = false
@State selectedPackage: ServicePackage | null = null
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder detailModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Text(this.selectedPackage?.icon ?? '🎁').fontSize(40)
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
Text(this.selectedPackage?.name ?? '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
.margin({ top: 12 })
Text(this.selectedPackage?.description ?? '').fontSize(13).fontColor('#888888').margin({ top: 4 })
Divider().color('#F0F0F0').margin({ top: 16, left: 20, right: 20 })
Row() {
Column() {
Text('💰 价格').fontSize(10).fontColor('#888888')
Text('¥' + (this.selectedPackage?.price ?? 0)).fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#7E57C2').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('📦 原价').fontSize(10).fontColor('#888888')
Text('¥' + (this.selectedPackage?.originalPrice ?? 0)).fontSize(14)
.fontColor('#CCCCCC').margin({ top: 2 })
.decoration({ type: TextDecorationType.LineThrough })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('⏱️ 时长').fontSize(10).fontColor('#888888')
Text((this.selectedPackage?.duration ?? 0) + 'min').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#212121').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 16 })
Divider().color('#F0F0F0').margin({ top: 16, left: 20, right: 20 })
Text('包含服务').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
.width('100%').padding({ left: 20, top: 16, bottom: 8 })
Column() {
ForEach(this.selectedPackage?.includes ?? [], (item: string) => {
Row() {
Text('✅').fontSize(12)
Text(item).fontSize(12).fontColor('#555555').margin({ left: 8 })
}
.width('100%').padding({ top: 4, bottom: 4, left: 20 })
})
}
Row() {
Text('🔥 销量 ' + (this.selectedPackage?.salesCount ?? 0)).fontSize(11).fontColor('#FF8F00')
Row().layoutWeight(1)
Text('⭐ ' + (this.selectedPackage?.rating ?? 0)).fontSize(11).fontColor('#FF8F00')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Text('立即预约').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#7E57C2').borderRadius(20)
.padding({ left: 40, right: 40, top: 12, bottom: 12 })
.alignSelf(ItemAlign.Center).margin({ top: 16, bottom: 20 })
.onClick(() => { this.showDetailModal = false })
}
.width('85%').backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '7.5%', y: '12%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder packageCard(p: ServicePackage) {
Column() {
Row() {
Column() {
Text(p.icon).fontSize(32)
}.width(56).height(56)
.backgroundColor(p.popular ? '#FFF8E1' : '#F3E5F5').borderRadius(14)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text(p.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
Text(p.description).fontSize(10).fontColor('#888888').margin({ top: 2 })
Row() {
Text('⭐ ' + p.rating).fontSize(10).fontColor('#FF8F00')
Text('🔥 ' + p.salesCount + '单').fontSize(10).fontColor('#888888').margin({ left: 8 })
}
.margin({ top: 4 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + p.price).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#7E57C2')
Text('¥' + p.originalPrice).fontSize(11).fontColor('#CCCCCC')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ top: 2 })
}.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
ForEach(p.includes, (item: string) => {
Text(item).fontSize(9).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 2, right: 2 })
})
}
.width('100%').margin({ top: 10 })
if (p.popular) {
Row() {
Text('🔥 热门推荐').fontSize(10).fontColor('#FF8F00').fontWeight(FontWeight.Bold)
Row().layoutWeight(1)
Text('查看详情 >').fontSize(10).fontColor('#7E57C2')
}
.width('100%').margin({ top: 8 })
}
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ left: 12, right: 12, top: 6 })
.shadow({ radius: 3, color: '#1A7E57C2', offsetY: 1 })
.onClick(() => { this.selectedPackage = p; this.showDetailModal = true })
}
build() {
Stack() {
Column() {
// 渐变头部
Column() {
Text('🎁 美容套餐').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('精选服务 · 品质保障').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
}
.width('100%').padding({ top: 16, bottom: 16 })
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.alignItems(HorizontalAlign.Center)
// 分类筛选
Scroll() {
Row() {
ForEach(['全部', '基础', '造型', '护理'], (c: string) => {
if (this.selectedCategory === c) {
Text(c).fontSize(11).fontColor('#FFFFFF').backgroundColor('#7E57C2')
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14)
.margin({ left: 3, right: 3 })
} else {
Text(c).fontSize(11).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14)
.margin({ left: 3, right: 3 })
.onClick(() => { this.selectedCategory = c })
}
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)
Scroll() {
Column() {
this.packageCard(mockPackages[0])
this.packageCard(mockPackages[1])
this.packageCard(mockPackages[2])
this.packageCard(mockPackages[3])
this.packageCard(mockPackages[4])
this.packageCard(mockPackages[5])
this.packageCard(mockPackages[6])
this.packageCard(mockPackages[7])
this.packageCard(mockPackages[8])
this.packageCard(mockPackages[9])
this.packageCard(mockPackages[10])
this.packageCard(mockPackages[11])
this.packageCard(mockPackages[12])
this.packageCard(mockPackages[13])
this.packageCard(mockPackages[14])
this.packageCard(mockPackages[15])
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showDetailModal) { this.detailModal() }
}
}
}
// ==================== 宠物档案页 ====================
@Component
struct PetProfileContent {
@State showDetailModal: boolean = false
@State showDeleteConfirm: boolean = false
@State selectedPet: PetProfile | null = null
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder detailModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Text(this.selectedPet?.avatar ?? '🐾').fontSize(48)
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
Text(this.selectedPet?.name ?? '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#212121')
.margin({ top: 8 })
Text((this.selectedPet?.breed ?? '') + ' · ' + (this.selectedPet?.gender ?? '') + ' · ' + (this.selectedPet?.age ?? 0) + '岁')
.fontSize(12).fontColor('#888888').margin({ top: 4 })
Divider().color('#F0F0F0').margin({ top: 16, left: 20, right: 20 })
Row() {
Column() {
Text('⚖️ 体重').fontSize(10).fontColor('#888888')
Text((this.selectedPet?.weight ?? 0) + 'kg').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#7E57C2').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('🎨 毛色').fontSize(10).fontColor('#888888')
Text(this.selectedPet?.color ?? '').fontSize(14).fontColor('#212121').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('🏥 就诊').fontSize(10).fontColor('#888888')
Text((this.selectedPet?.visitCount ?? 0) + '次').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#26A69A').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 16 })
Divider().color('#F0F0F0').margin({ top: 16, left: 20, right: 20 })
Column() {
Text('🏥 健康信息').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
.width('100%').padding({ left: 20, top: 16, bottom: 8 })
Row() {
Text('疫苗').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.vaccinated ? '✅ 已接种' : '❌ 未接种').fontSize(12)
.fontColor(this.selectedPet?.vaccinated ? '#43A047' : '#EF5350')
}
.width('100%').padding({ left: 20, right: 20, top: 4 })
Row() {
Text('绝育').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.neutered ? '✅ 已绝育' : '❌ 未绝育').fontSize(12)
.fontColor(this.selectedPet?.neutered ? '#43A047' : '#EF5350')
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Row() {
Text('过敏').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.allergies ?? '无').fontSize(12)
.fontColor(this.selectedPet?.allergies === '无' ? '#43A047' : '#EF5350')
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Row() {
Text('最后就诊').fontSize(12).fontColor('#888888').layoutWeight(1)
Text(this.selectedPet?.lastVisit ?? '').fontSize(12).fontColor('#212121')
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
}
.width('100%')
if (this.selectedPet?.notes !== '') {
Divider().color('#F0F0F0').margin({ top: 16, left: 20, right: 20 })
Text('📝 备注').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
.width('100%').padding({ left: 20, top: 16, bottom: 4 })
Text(this.selectedPet?.notes ?? '').fontSize(12).fontColor('#555555')
.width('100%').padding({ left: 20, right: 20, bottom: 16 })
}
Row() {
Text('🗑️ 删除档案').fontSize(13).fontColor('#FFFFFF')
.backgroundColor('#EF5350').borderRadius(16)
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.onClick(() => { this.showDeleteConfirm = true })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 20 })
}
.width('85%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '7.5%', y: '10%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder deleteConfirmModal() {
Column() {
this.modalOverlay(() => { this.showDeleteConfirm = false })
Column() {
Text('⚠️').fontSize(48).margin({ top: 24 })
Text('确认删除此宠物档案?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('删除后档案将不可恢复').fontSize(13).fontColor('#EF5350').margin({ top: 4 })
Row() {
Text(this.selectedPet?.avatar ?? '🐾').fontSize(20)
Text(this.selectedPet?.name ?? '').fontSize(14).fontColor('#333333')
.fontWeight(FontWeight.Bold).margin({ left: 8 })
}
.backgroundColor('#FFEBEE').borderRadius(10)
.padding({ left: 16, right: 16, top: 10, bottom: 10 }).margin({ top: 16 })
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.showDeleteConfirm = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#EF5350').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showDeleteConfirm = false; this.showDetailModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 20, bottom: 20 })
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '10%', y: '38%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder petCard(p: PetProfile) {
Column() {
Row() {
Column() {
Text(p.avatar).fontSize(36)
}.width(56).height(56)
.backgroundColor('#F3E5F5').borderRadius(14)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text(p.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
Text(p.breed).fontSize(11).fontColor('#888888').margin({ top: 2 })
Row() {
Text(p.gender).fontSize(10).fontColor('#7E57C2').backgroundColor('#F3E5F5')
.padding({ left: 6, right: 6, top: 1, bottom: 1 }).borderRadius(6)
Text(p.age + '岁').fontSize(10).fontColor('#888888').margin({ left: 6 })
Text(p.weight + 'kg').fontSize(10).fontColor('#888888').margin({ left: 6 })
}
.margin({ top: 4 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
if (p.vaccinated) {
Text('💉').fontSize(14)
}
Text('就诊' + p.visitCount + '次').fontSize(9).fontColor('#AAAAAA').margin({ top: 2 })
}.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text('🩺 ' + (p.neutered ? '已绝育' : '未绝育')).fontSize(10).fontColor('#888888')
Text('🎨 ' + p.color).fontSize(10).fontColor('#888888').margin({ left: 8 })
Row().layoutWeight(1)
Text('最近: ' + p.lastVisit).fontSize(9).fontColor('#AAAAAA')
}
.margin({ top: 8 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ left: 12, right: 12, top: 6 })
.shadow({ radius: 3, color: '#1A7E57C2', offsetY: 1 })
.onClick(() => { this.selectedPet = p; this.showDetailModal = true })
}
build() {
Stack() {
Column() {
Column() {
Text('🐾 宠物档案').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('共' + getTotalPets() + '只萌宠 · 专业管理').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
}
.width('100%').padding({ top: 16, bottom: 16 })
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.alignItems(HorizontalAlign.Center)
Scroll() {
Column() {
this.petCard(mockPets[0])
this.petCard(mockPets[1])
this.petCard(mockPets[2])
this.petCard(mockPets[3])
this.petCard(mockPets[4])
this.petCard(mockPets[5])
this.petCard(mockPets[6])
this.petCard(mockPets[7])
this.petCard(mockPets[8])
this.petCard(mockPets[9])
this.petCard(mockPets[10])
this.petCard(mockPets[11])
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showDetailModal) { this.detailModal() }
if (this.showDeleteConfirm) { this.deleteConfirmModal() }
}
}
}
// ==================== 统计页 ====================
@Component
struct StatsContent {
build() {
Column() {
Column() {
Text('📊 数据统计').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('营业数据分析 · 实时更新').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
}
.width('100%').padding({ top: 16, bottom: 16 })
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.alignItems(HorizontalAlign.Center)
Scroll() {
Column() {
// 月度收入柱状图
Column() {
Text('💰 月度收入趋势').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
Column() {
Text((getMonthlyRevenue(i) / 1000).toFixed(1) + 'k')
.fontSize(7).fontColor('#7E57C2').margin({ bottom: 2 })
Column()
.width(16)
.height((getMonthlyRevenue(i) / MAX_REVENUE * 100).toFixed(0) + 'vp')
.linearGradient({ angle: 180, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.borderRadius({ topLeft: 3, topRight: 3 })
Text(MONTH_LABELS[i]).fontSize(7).fontColor('#999999').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 8, right: 8, bottom: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
// 四格统计
Row() {
Column() {
Text('📋').fontSize(20).margin({ top: 4 })
Text(getTotalAppointments().toString()).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#7E57C2')
Text('总预约').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
.backgroundColor('#FFFFFF').borderRadius(10).margin({ left: 6, right: 3, top: 8 })
Column() {
Text('💰').fontSize(20).margin({ top: 4 })
Text('¥' + getTotalRevenue()).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#43A047')
Text('本月收入').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
.backgroundColor('#FFFFFF').borderRadius(10).margin({ left: 3, right: 6, top: 8 })
}
.width('100%').padding({ left: 6, right: 6 })
// 服务类型分布
Column() {
Text('🎨 服务类型分布').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Column() {
ForEach(SERVICE_TYPES, (s: string) => {
Row() {
Text(SERVICE_CONFIG[s]?.icon + ' ' + s).fontSize(11)
.fontColor(SERVICE_CONFIG[s]?.color ?? '#7E57C2').layoutWeight(1)
Text(getServiceCount(s) + '次').fontSize(11).fontColor('#888888')
Text(getServicePercent(s) + '%').fontSize(11).fontColor('#7E57C2')
.fontWeight(FontWeight.Bold).margin({ left: 8 })
}
.width('100%').padding({ top: 4, bottom: 4 })
Row() {
Column()
.width(getServicePercent(s) + '%')
.height(6)
.linearGradient({ angle: 0, colors: [[SERVICE_CONFIG[s]?.color ?? '#7E57C2', 0], [SERVICE_CONFIG[s]?.color ?? '#7E57C2', 1]] })
.borderRadius(3)
Column().layoutWeight(1)
}
.width('100%').margin({ bottom: 6 })
})
}
.padding({ left: 16, right: 16, bottom: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8 })
// 美容师业绩
Column() {
Text('👩🦰 美容师业绩').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Row() {
ForEach(GROOMER_NAMES, (g: string) => {
Column() {
Text(GROOMER_CONFIG[g]?.avatar ?? '👤').fontSize(20)
Text(g).fontSize(11).fontColor('#888888').margin({ top: 4 })
Text('⭐' + (GROOMER_CONFIG[g]?.rating ?? 0)).fontSize(12).fontWeight(FontWeight.Bold)
.fontColor('#FF8F00').margin({ top: 2 })
Text(GROOMER_CONFIG[g]?.specialty ?? '').fontSize(8).fontColor('#AAAAAA').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })
})
}
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8 })
.padding({ bottom: 4 })
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
}
// ==================== 个人中心页 ====================
@Component
struct ProfileContent {
build() {
Column() {
Column() {
Text('🧑💼').fontSize(48).margin({ top: 20 })
Text('宠物美容工作室').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 8 })
Text('营业中 · 上海徐汇区').fontSize(11).fontColor('#E1BEE7').margin({ top: 4 })
Text('📞 021-1234-5678').fontSize(10).fontColor('#E1BEE7').margin({ top: 4 })
}
.width('100%').padding({ bottom: 20 })
.linearGradient({ angle: 135, colors: [['#7E57C2', 0], ['#AB47BC', 1]] })
.alignItems(HorizontalAlign.Center)
Scroll() {
Column() {
// 快捷操作
Column() {
Text('快捷操作').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Column() {
Row() { Text('📅').fontSize(18); Text('预约管理').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F0F0F0')
Row() { Text('🐾').fontSize(18); Text('宠物管理').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F0F0F0')
Row() { Text('🎁').fontSize(18); Text('套餐管理').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F0F0F0')
Row() { Text('👩🦰').fontSize(18); Text('美容师管理').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F0F0F0')
Row() { Text('🔔').fontSize(18); Text('通知设置').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('>').fontColor('#CCCCCC') }
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
}
.padding({ left: 16, right: 16 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 12, right: 12, top: 10 })
// 营业信息
Column() {
Text('营业信息').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Column() {
Row() { Text('📍 门店地址').fontSize(12).fontColor('#888888').layoutWeight(1); Text('上海徐汇区xx路88号').fontSize(12).fontColor('#212121') }
.width('100%').padding({ top: 8, bottom: 8, left: 16, right: 16 })
Divider().color('#F5F5F5')
Row() { Text('🕐 营业时间').fontSize(12).fontColor('#888888').layoutWeight(1); Text('09:00 - 21:00').fontSize(12).fontColor('#212121') }
.width('100%').padding({ top: 8, bottom: 8, left: 16, right: 16 })
Divider().color('#F5F5F5')
Row() { Text('📞 预约电话').fontSize(12).fontColor('#888888').layoutWeight(1); Text('021-1234-5678').fontSize(12).fontColor('#212121') }
.width('100%').padding({ top: 8, bottom: 8, left: 16, right: 16 })
Divider().color('#F5F5F5')
Row() { Text('⭐ 门店评分').fontSize(12).fontColor('#888888').layoutWeight(1); Text('4.9 (328条评价)').fontSize(12).fontColor('#FF8F00') }
.width('100%').padding({ top: 8, bottom: 8, left: 16, right: 16 })
}
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 12, right: 12, top: 8 })
Text('v1.0 · 萌宠美容 · 2026').fontSize(10).fontColor('#CCCCCC')
.alignSelf(ItemAlign.Center).margin({ top: 16, bottom: 16 })
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
}
十四、总结
通过对这套宠物美容预约管理应用的逐段剖析,我们可以看到,一个优秀的移动端业务应用需要在多个维度上做到平衡与精进。

在架构层面,它采用了"接口定义类型契约、@Observed 类承载数据、const 配置抽离业务知识、组件分模块自治"的分层结构,每一层职责清晰、耦合度低。特别是将服务类型、体型倍率、状态配色、美容师信息等业务知识集中配置为 Record 字典,使得业务规则变更时只需修改一处,充分体现了配置驱动的设计思想。
在交互层面,它大量使用模态弹窗而非页面跳转来处理 CRUD 操作,保持了上下文连贯性。四种弹窗(新建、编辑、删除确认、详情)各司其职,且通过 if 条件渲染叠加在 Stack 最上层,形成了一套完整的弹窗管理体系。标签按钮组替代传统下拉框、星级评价的条件渲染、删除操作的二次确认等细节,都体现了对移动端交互习惯的深入理解。
在视觉层面,全应用统一使用"暖紫 #7E57C2 / 柠檬黄 #FFF176 / 薄荷绿 #80CBC4 / 浅紫 #F3E5F5"的配色方案,每种服务类型还有自己的主题色,形成了丰富但和谐的色彩体系。渐变头部、淡紫输入框、带透明度的紫色阴影、emoji 图标的广泛使用,共同营造了温暖亲切的视觉氛围,非常契合宠物行业的情感属性。统计页的柱状图和进度条完全用基础组件手绘,通过数学计算(值除以最大值乘以基准高度)实现比例可视化,证明了声明式 UI 在数据可视化方面的潜力。
在数据层面,三个 @Observed 数据模型(预约、套餐、宠物档案)字段设计全面且贴合实际业务,特别是宠物档案中的过敏史、绝育状态、疫苗状态等健康信息,以及预约中的备注字段,体现了对宠物美容行业安全规范的深刻理解。体型倍率定价机制则是一种灵活可扩展的商业逻辑建模。
瀑布流是手动分配两列而非按高度动态计算,未来可以引入真实的瀑布流算法;四个弹窗用了四个独立布尔值管理,可以重构为单一枚举状态;form* 系列变量可以聚合成一个表单对象;统计函数目前返回硬编码值,需要接入真实后端 API。但这些都不影响它作为一份优秀的声明式 UI 教学范例的价值——它几乎涵盖了一个业务应用所需的所有核心模式:列表渲染、表单输入、弹窗交互、条件渲染、状态管理、图表绘制、导航路由,值得每一位移动端开发者深入研究与借鉴。
更多推荐
所有评论(0)