基于HarmonyOS API 24的声明式ArkTS开发实战:HarmonyOS 6.1.1下小红书风格美甲星球社区App的架构设计与HarmonyOS ArkTS API 24状态管理深度解析
引言
在移动互联网内容生态持续演进的今天,垂直领域的社区类应用正在成为用户体验创新的重要阵地。HarmonyOS 6.1.1 作为华为全场景智慧生活操作系统的最新里程碑版本,携 HarmonyOS ArkTS API 24 为开发者带来了更加强大的声明式UI编程能力。本文将以一个完整的美甲款式社区应用——"美甲星球 NailStar"为案例,深入剖析基于HarmonyOS API 24 的单文件应用架构设计,从类型系统、数据建模、状态管理到视图构建的全链路技术细节进行逐行级深度解读。该应用融合了小红书风格的内容流浏览、美甲师预约系统、色板宫格展示、时段预约表、色彩趋势追踪、护理知识科普等丰富的业务场景,是一个兼具功能完整性与视觉表现力的综合性实战项目。
HarmonyOS ArkTS API 24 在 TypeScript 的语言基础上进行了深度的静态类型增强和运行时性能优化,引入了 @Entry、@Component、@State、@Observed、@Builder 等装饰器体系,使得开发者能够以纯粹声明式的方式描述界面结构与状态依赖关系。与传统的命令式编程范式不同,ArkTS 的声明式范式让开发者只需关注"界面当前应该呈现什么样子",而将"如何高效地更新界面"的工作交给框架底层的差分渲染引擎。这种范式转变不仅从根本上大幅减少了样板代码的编写量,更重要的是彻底消除了状态与视图不同步的经典隐患。在本案例中,我们将看到这一设计理念如何在一个超过三千行的单文件应用中得到充分而系统的实践。
该美甲星球应用涵盖了七大内容子页面和四类模态弹框系统,通过双层 Tab 导航体系组织了约十八个业务数据集合,包括款式图鉴、美甲师档案、店铺信息、时段排期、色彩趋势、护理问答、收藏记录、预约时间线等。每个数据集都有独立的领域模型定义,通过 @Observed 装饰器实现可观察的数据流。应用还实现了四类弹框系统,分别处理款式DIY设计器、预约改期、卸甲警示和到店排队叫号等交互流程。此外,全屏浮动 emoji 特效层、一周时段余量热力表、色彩趋势横滑卡片、预约记录时间线等组件展示了 ArkTS 在复杂视觉效果和自定义可视化方面的出色能力。这些功能的组合使得该应用在视觉表现力和交互完整度上都达到了可投入生产的水准。
从架构层面来看,这个单文件应用虽然体量庞大,但内部结构清晰有序,严格遵循了"类型契约层—数据模型层—静态数据层—纯函数工具层—状态管理层—视图构建层"的分层组织原则。每一层都有明确的职责边界:类型契约层通过 interface 定义数据结构契约,数据模型层通过 @Observed class 实现可变状态容器,静态数据层以常量数组形式提供模拟数据源,纯函数层封装了与业务逻辑相关的计算函数,状态管理层集中在 @Entry 组件内部通过 @State 装饰器统一管理,视图构建层则通过 @Builder 方法将状态映射为界面。这种分层虽然是在单文件内完成的,但其架构思想与多文件模块化项目一脉相承,对于理解 HarmonyOS 大型应用架构具有重要的参考价值。
值得一提的是,本文分析的代码完全基于 HarmonyOS 6.1.1 提供的 HarmonyOS ArkTS API 24 能力集,没有使用任何第三方库或原生插件,所有视觉效果均通过 ArkTS 内置组件(Column、Row、Text、Scroll、Flex、Stack、TextInput、Divider 等)和属性方法(linearGradient、position、shadow、borderRadius、hitTestBehavior 等)实现。这意味着该代码具有良好的可移植性和可维护性,可以作为 HarmonyOS 原生开发的参考模板。在接下来的章节中,我们将逐段拆解代码,从最基础的接口类型定义开始,一路深入到最复杂的模态弹框系统和主构建方法,力求做到每一个技术点都有充分的解读和设计思路阐释。无论你是刚接触 HarmonyOS 的新手开发者,还是有一定经验希望深入理解 ArkTS 范式的工程师,都能从这份详尽的分析中获得有价值的参考。
一、整体架构总览与运行时数据流
在深入逐行分析之前,我们首先从宏观层面理解这个应用的架构组织方式。整个应用代码可以划分为六个清晰的层次,每一层承担不同的职责,层与层之间通过数据流和函数调用进行协作。下面的流程图展示了从应用启动到界面渲染的完整数据流转路径。
这张架构流程图清晰地展示了应用的运行时行为路径。当应用启动时,@Entry 装饰的 NailArtApp 组件首先触发 aboutToAppear 生命周期回调,在其中注册一个每 120 毫秒递增 tick 计数器的定时器。这个 tick 值是驱动全屏浮动 emoji 特效动画的核心时钟信号。随后进入 build 方法,构建一个 Stack 根容器,其中纵向排列着头部、Tab栏、可滚动内容区和底部导航栏四个主要区域。内容区的具体渲染取决于 curTab 状态变量的值,形成了一个七选一的路由系统。当四个弹框状态中任意一个为 true 时,Stack 的最顶层会叠加一个模态遮罩层,通过条件分支选择渲染对应的弹框内容。底部导航栏的 mainTab 状态与 curTab 联动,点击底部Tab时同步更新内容区的Tab索引,实现了导航层级的协调一致。
二、接口类型定义体系
2.1 StyleItem 款式数据契约
在任何前端项目中,类型系统的设计是代码可维护性的基石。本应用首先通过一个 TypeScript interface 定义了美甲款式数据的结构契约,确保所有款式对象都有统一的类型约束。
interface StyleItem {
id: number
name: string
color: string
color2: string
emoji: string
heat: string
tag: string
}

这段代码定义了一个名为 StyleItem 的接口,其中包含了七个属性。id 是数字类型的唯一标识符,用于 ForEach 的键值生成和列表项区分。name 是字符串类型的款式名称,如"猫眼液态冰蓝"。color 和 color2 是两个渐变色值,用于渲染款式色板卡片的线性渐变背景,这种双色设计可以表现从浅到深的色彩过渡。emoji 是一个表情符号字符串,用于在色板上叠加一个可识别的图标,避免了纯色块无法区分的问题。heat 是热度值字符串,如"🔥 3.2w",用于展示款式的受欢迎程度。tag 是分类标签,如"猫眼"、“法式”、"渐变"等,用于款式的分类筛选。
这种通过 interface 先定义契约的做法,是 ArkTS 类型系统的重要实践。它带来了三个显著优势:首先,IDE 能够提供精确的属性补全提示,开发者输入 s. 后会看到所有可用属性的列表;其次,如果拼错了属性名,编译期就会报错,而不是等到运行时才发现数据不生效;最后,这种语义化命名使得数据结构与业务含义一一对应,当需要新增款式属性时,只需在 interface 中添加字段,所有实现该接口的类都需要同步更新,确保了类型安全。
2.2 ArtistItem 美甲师数据契约
interface ArtistItem {
id: number
name: string
avatar: string
skills: string[]
price: number
status: string
rating: number
fans: string
shop: string
cases: number
}

ArtistItem 接口定义了美甲师的数据结构,包含十个属性。id 是美甲师的唯一标识。name 是美甲师昵称,如"桃桃酱Peach"。avatar 是头像表情符号,本应用使用 emoji 字符代替图片资源,实现零资源依赖。skills 是一个字符串数组,存储美甲师擅长的技能标签,如 ['日式手绘', '法式'],用于在卡片上以胶囊标签形式展示。price 是数字类型的起步价格。status 是当前预约状态字符串,取值包括"今日可约"、“明日约满”、“周六有位”、“可插单”、“本周约满"等,这个字段驱动了状态徽章的颜色映射逻辑。rating 是数字类型的评分。fans 是粉丝数字符串,如"2.3w”。shop 是所属店铺名称。cases 是数字类型的案例数量。
值得注意的是 skills 字段使用了 string[] 数组类型,这在 ArkTS 中是合法的泛型数组声明。数组类型的选择意味着该字段可以存储不定数量的技能标签,适应不同美甲师的技能组合差异。status 字段使用字符串而非枚举类型,这在原型开发阶段是务实的选择,因为状态值可能会频繁调整。在实际生产环境中,可以考虑使用联合字面量类型 '今日可约' | '明日约满' | ... 来获得更严格的类型约束。
2.3 ShopItem 店铺数据契约
interface ShopItem {
id: number
name: string
emoji: string
area: string
rating: number
avgPrice: number
comments: string
tags: string[]
}
ShopItem 接口定义了美甲店铺的数据结构,包含八个属性。id 是店铺唯一标识。name 是店铺名称。emoji 是店铺图标表情符号,如"🌌"代表"指尖宇宙美甲"。area 是商圈位置字符串,如"天河·体育西路",包含了区域和具体地标两个层级的地理信息。rating 是评分数字。avgPrice 是人均消费数字,用于驱动价格进度条的宽度和价格档位标签的文本。comments 是评价数量字符串,如"999+评价"。tags 是标签数组,存储店铺的特色标签,如 ['一站式款式库', '新客立减', '探店爆款']。
avgPrice 字段的设计体现了数据驱动视图的理念。在探店页面中,人均价格不仅以文本形式展示,还通过 pricePct 函数映射为进度条宽度百分比,通过三元表达式映射为"轻奢档"、“中档”、"平价档"的档位标签。这种一个数据字段驱动多种视觉表达的设计模式,是声明式UI的核心优势之一。
2.4 SlotCell 与 ShopSchedule 时段数据契约
interface SlotCell {
period: string
remain: number
total: number
}
interface ShopSchedule {
shop: string
area: string
cells: SlotCell[]
}
这两个接口共同定义了时段预约表的数据结构。SlotCell 描述单个时段格子的信息:period 是时段名称(“上午”、“下午”、“晚上”),remain 是剩余可约位数,total 是总容量位数。ShopSchedule 描述一个店铺的完整排期:包含店铺名称、商圈位置和一个 SlotCell 数组,数组长度通常为 3,对应一天三个时段。
这种嵌套的数据结构设计非常适合表格化展示场景。在时段表页面中,外层 ForEach 遍历 ShopSchedule 数组生成表格行,内层 ForEach 遍历 cells 数组生成每个行内的时段格子。remain 和 total 的组合不仅驱动了格子背景色(通过 slotBg 函数)和文字颜色(通过 slotText 函数),还驱动了格子标签文本(通过 slotLabel 函数),实现了数据到视觉的全面映射。
2.5 TrendItem 色彩趋势数据契约
interface TrendItem {
id: number
name: string
code: string
hex: string
hex2: string
rise: string
desc: string
}
TrendItem 接口定义了色彩趋势条目的数据结构,包含七个属性。id 是趋势色唯一标识。name 是色彩名称,如"冰淇淋粉"。code 是 PANTONE 色号,如"PANTONE 13-1520",增加了专业度和权威性。hex 和 hex2 是两个十六进制色值,用于渲染大色块的渐变背景。rise 是涨幅字符串,如"↑ 32%“,表示该颜色的搜索量增长比例。desc 是一句话描述,如"本季搜索量暴涨,少女感天花板”。
hex 和 hex2 的双色设计使得每个趋势色块都能呈现出从主色到辅色的渐变过渡,比纯色块更加生动。code 字段引用 PANTONE 色号体系,是色彩行业的国际标准,这种设计让应用看起来更专业,也为用户提供了可追溯的色彩参考依据。
2.6 ArticleItem 文章数据契约
interface ArticleItem {
id: number
title: string
summary: string
author: string
likes: string
days: string
cover: string
tag: string
c1: string
c2: string
}

ArticleItem 接口定义了趋势文章的数据结构,包含十个属性。id 是文章唯一标识。title 是标题。summary 是摘要文本。author 是作者名称。likes 是点赞数字符串。days 是发布时间描述,如"2天前"。cover 是封面图标 emoji。tag 是文章分类标签,如"趋势"、“干货”、"测评"等。c1 和 c2 是两个封面渐变色值,用于渲染文章封面图块的渐变背景。
c1 和 c2 字段的设计巧妙地解决了无图片资源情况下的封面展示问题。每篇文章都有独立的双色渐变封面,通过不同的色彩组合区分文章类型,既保证了视觉多样性又避免了资源依赖。这种"数据即设计"的思路在没有设计师配合的快速开发场景中非常实用。
2.7 CareQAItem 护理问答数据契约
interface CareQAItem {
id: number
icon: string
q: string
a: string
views: string
}
CareQAItem 接口定义了护理知识问答的数据结构,包含五个属性。id 是问答唯一标识。icon 是问题分类图标 emoji,如"🧴"代表护理用品相关。q 是问题文本。a 是回答文本。views 是浏览次数字符串,如"12.6w"。
这个接口的设计简洁明了,五个字段覆盖了问答卡片展示所需的全部信息。views 字段除了展示数据外,还传递了内容的受欢迎程度,帮助用户判断信息的可靠性和关注度。回答文本 a 存储了完整的回答内容,在卡片中以多行文本形式展示,配合 lineHeight 属性控制行间距,确保可读性。
2.8 TipItem、FavItem、RecordItem 辅助数据契约
interface TipItem {
icon: string
label: string
}
interface FavItem {
id: number
name: string
color: string
color2: string
artist: string
heart: string
}
interface RecordItem {
id: number
date: string
time: string
styleName: string
artist: string
shop: string
status: string
price: number
}
TipItem 接口最为简洁,仅包含图标和标签两个属性,用于护理小贴士的胶囊标签展示。FavItem 接口定义了收藏款式的数据结构,包含 color 和 color2 双色值用于渲染收藏卡片色块,heart 字段存储收藏热度信息。RecordItem 接口定义了预约记录的数据结构,包含日期、时间、款式名称、美甲师、店铺、状态和价格七个核心字段,其中 status 字段驱动了时间线节点颜色和状态徽章的映射逻辑。
这三个接口的设计体现了数据建模的层次性。TipItem 是最简单的标签型数据,FavItem 是带有视觉属性的内容型数据,RecordItem 是最完整的事务型数据。不同复杂度的数据结构适配不同的展示需求,避免了过度设计或设计不足的问题。
2.9 ColorPalette、DecoItem、WeekDayItem、EditSlotItem、FxEmoji UI数据契约
interface ColorPalette {
name: string
hex: string
}
interface DecoItem {
icon: string
name: string
price: number
}
interface WeekDayItem {
label: string
date: string
rest: boolean
}
interface EditSlotItem {
label: string
state: number
}
interface FxEmoji {
icon: string
xSpeed: number
ySpeed: number
x0: number
y0: number
size: number
drift: number
}

这五个接口定义了 UI 辅助数据结构。ColorPalette 定义了色板选择器中的颜色项,包含名称和十六进制色值。DecoItem 定义了装饰件列表项,包含图标、名称和价格。WeekDayItem 定义了星期选择项,rest 布尔值标识是否为休息日,用于驱动日期标签的强调色。EditSlotItem 定义了时段选择项,state 数字(0=约满、1=紧张、2=充足)驱动了时段格子的颜色和可交互性。FxEmoji 是最复杂的 UI 数据结构,包含七个属性:icon 是表情符号,xSpeed 和 ySpeed 是水平和垂直移动速度,x0 和 y0 是初始位置百分比,size 是字体大小,drift 是相位偏移量——这些参数共同驱动了浮动 emoji 的动画轨迹。
FxEmoji 接口的设计特别值得深入分析。通过为每个 emoji 配置不同的速度、初始位置和相位偏移,十二个 emoji 在屏幕上呈现出完全不同的运动轨迹,营造出自然漂浮的视觉效果。drift 字段的存在使得不同 emoji 在相同的 tick 值下展现出不同的透明度变化节律,进一步增强了视觉的随机性和自然感。这种"参数化动画"的设计思路比硬编码动画路径更加灵活,只需调整数据即可改变动画效果。
三、@Observed 数据模型层
3.1 interface 与 @Observed class 的双层设计模式
在 ArkTS 的状态管理体系中,interface 和 @Observed class 的组合是一种推荐的实践模式。interface 提供编译期的类型契约,@Observed class 提供运行时的可观察状态能力。本应用严格遵循了这一模式,为每个需要状态观察的数据结构都定义了配套的 interface 和 @Observed class。
@Observed
class StyleData implements StyleItem {
id: number = 0
name: string = ''
color: string = ''
color2: string = ''
emoji: string = ''
heat: string = ''
tag: string = ''
constructor(id: number, name: string, color: string, color2: string, emoji: string, heat: string, tag: string) {
this.id = id; this.name = name; this.color = color; this.color2 = color2
this.emoji = emoji; this.heat = heat; this.tag = tag
}
}
以 StyleData 为例,它使用 @Observed 装饰器标记,并通过 implements StyleItem 实现了之前定义的接口契约。类内部为每个属性都提供了默认值初始化(如 id: number = 0、name: string = ''),这是 ArkTS 的语法要求——被 @Observed 标记的类的属性必须初始化。构造函数接收所有属性值作为参数,通过简洁的赋值语句完成实例化。
@Observed 装饰器的作用是为类添加可观察性。当 StyleData 的实例被赋值给 @State 或作为 ForEach 的数据源时,框架会追踪其属性的变化。如果某个属性的值被修改,所有依赖该属性的 UI 部分会自动重新渲染。这种细粒度的观察机制确保了状态变化能够精确地触发最小范围的视图更新,而不是整个页面的重绘。
3.2 ArtistData 美甲师可观察模型
@Observed
class ArtistData implements ArtistItem {
id: number = 0
name: string = ''
avatar: string = ''
skills: string[] = []
price: number = 0
status: string = ''
rating: number = 0
fans: string = ''
shop: string = ''
cases: number = 0
constructor(id: number, name: string, avatar: string, skills: string[], price: number, status: string, rating: number, fans: string, shop: string, cases: number) {
this.id = id; this.name = name; this.avatar = avatar; this.skills = skills
this.price = price; this.status = status; this.rating = rating
this.fans = fans; this.shop = shop; this.cases = cases
}
}
ArtistData 类的结构与 StyleData 类似,但属性更多更复杂。值得注意的是 skills: string[] = [] 的默认值初始化使用了空数组,这确保了在构造函数执行前属性就已经有合法的初始值。构造函数接收十个参数,通过分号分隔的多条赋值语句完成初始化,这种紧凑的写法在 ArkTS 中是常见的实践。
@Observed 装饰器在 ArtistData 上的应用意味着:如果应用运行时修改了某个美甲师的 status 属性(例如从"今日可约"变为"明日约满"),所有引用了该美甲师 status 属性的 UI 元素都会自动刷新。这种响应式更新是声明式 UI 框架的核心能力,在本应用中虽然静态数据不会频繁变化,但这个机制为未来的动态数据更新预留了完整的响应链。
3.3 ShopData 店铺可观察模型
@Observed
class ShopData implements ShopItem {
id: number = 0
name: string = ''
emoji: string = ''
area: string = ''
rating: number = 0
avgPrice: number = 0
comments: string = ''
tags: string[] = []
constructor(id: number, name: string, emoji: string, area: string, rating: number, avgPrice: number, comments: string, tags: string[]) {
this.id = id; this.name = name; this.emoji = emoji; this.area = area
this.rating = rating; this.avgPrice = avgPrice; this.comments = comments; this.tags = tags
}
}
ShopData 类实现了 ShopItem 接口,通过 @Observed 装饰器获得了可观察性。类中 tags: string[] = [] 的默认值初始化与 ArtistData 中的 skills 字段处理方式一致。构造函数的参数顺序与接口属性定义顺序保持一致,这种一致性使得代码可读性更高,在创建实例时不容易传错参数。
3.4 SlotCellData 与 ShopScheduleData 时段可观察模型
@Observed
class SlotCellData implements SlotCell {
period: string = ''
remain: number = 0
total: number = 0
constructor(period: string, remain: number, total: number) {
this.period = period; this.remain = remain; this.total = total
}
}
@Observed
class ShopScheduleData implements ShopSchedule {
shop: string = ''
area: string = ''
cells: SlotCell[] = []
constructor(shop: string, area: string, cells: SlotCell[]) {
this.shop = shop; this.area = area; this.cells = cells
}
}

这两个类共同构成了时段表的数据模型层。SlotCellData 是最简单的可观察类,仅有三个属性。ShopScheduleData 的 cells 属性类型是 SlotCell[](接口数组)而非 SlotCellData[](类数组),这在 ArkTS 中是合法的——因为 SlotCellData implements SlotCell,SlotCellData 的实例可以被赋值给 SlotCell 类型的变量。这种设计在实际使用中,MOCK_SCHEDULES 数组中的每个 ShopScheduleData 实例的 cells 数组存储的都是 SlotCellData 实例,通过接口类型声明实现了松耦合。
3.5 TrendData 与 ArticleData 内容可观察模型
@Observed
class TrendData implements TrendItem {
id: number = 0
name: string = ''
code: string = ''
hex: string = ''
hex2: string = ''
rise: string = ''
desc: string = ''
constructor(id: number, name: string, code: string, hex: string, hex2: string, rise: string, desc: string) {
this.id = id; this.name = name; this.code = code; this.hex = hex; this.hex2 = hex2
this.rise = rise; this.desc = desc
}
}
@Observed
class ArticleData implements ArticleItem {
id: number = 0
title: string = ''
summary: string = ''
author: string = ''
likes: string = ''
days: string = ''
cover: string = ''
tag: string = ''
c1: string = ''
c2: string = ''
constructor(id: number, title: string, summary: string, author: string, likes: string, days: string, cover: string, tag: string, c1: string, c2: string) {
this.id = id; this.title = title; this.summary = summary; this.author = author
this.likes = likes; this.days = days; this.cover = cover; this.tag = tag
this.c1 = c1; this.c2 = c2
}
}
TrendData 和 ArticleData 分别是色彩趋势和趋势文章的可观察数据模型。两个类的构造函数参数都达到了七个和十个,虽然参数较多,但通过紧凑的分号分隔赋值写法,保持了代码的简洁性。@Observed 装饰器的应用使得这两个数据类型在未来实现"趋势色收藏"、"文章点赞"等功能时,可以直接修改属性值触发 UI 更新,无需手动调用刷新方法。
3.6 CareQAData、FavData、RecordData 辅助可观察模型
@Observed
class CareQAData implements CareQAItem {
id: number = 0
icon: string = ''
q: string = ''
a: string = ''
views: string = ''
constructor(id: number, icon: string, q: string, a: string, views: string) {
this.id = id; this.icon = icon; this.q = q; this.a = a; this.views = views
}
}
@Observed
class FavData implements FavItem {
id: number = 0
name: string = ''
color: string = ''
color2: string = ''
artist: string = ''
heart: string = ''
constructor(id: number, name: string, color: string, color2: string, artist: string, heart: string) {
this.id = id; this.name = name; this.color = color; this.color2 = color2
this.artist = artist; this.heart = heart
}
}
@Observed
class RecordData implements RecordItem {
id: number = 0
date: string = ''
time: string = ''
styleName: string = ''
artist: string = ''
shop: string = ''
status: string = ''
price: number = 0
constructor(id: number, date: string, time: string, styleName: string, artist: string, shop: string, status: string, price: number) {
this.id = id; this.date = date; this.time = time; this.styleName = styleName
this.artist = artist; this.shop = shop; this.status = status; this.price = price
}
}
这三个类分别是护理问答、收藏款式和预约记录的可观察数据模型。CareQAData 和 FavData 的构造函数参数适中(五个和六个),RecordData 的构造函数参数最多达到八个。RecordData 的 status 属性是最具交互意义的字段,它驱动了预约记录时间线中节点颜色、状态徽章背景色和操作按钮的显隐逻辑——当状态为"待到店"时显示"改期"和"取消预约"按钮,其他状态显示"再来一单"按钮。这种基于状态的条件渲染是声明式 UI 的经典应用场景。
四、全局纯函数工具层
4.1 状态徽章颜色映射函数
function statusBadgeColor(s: string): string {
if (s === '今日可约') { return '#2FA36B' }
if (s === '明日约满') { return '#E0547A' }
if (s === '周六有位') { return '#E08A3C' }
if (s === '可插单') { return '#8B6FD8' }
if (s === '本周约满') { return '#B08BC0' }
return '#999999'
}
function statusBadgeBg(s: string): string {
if (s === '今日可约') { return '#E1F5EB' }
if (s === '明日约满') { return '#FFE3EA' }
if (s === '周六有位') { return '#FFF1DE' }
if (s === '可插单') { return '#EEE8FB' }
if (s === '本周约满') { return '#F4EDF7' }
return '#F2F2F2'
}

这两个函数是美甲师状态徽章的颜色映射器。statusBadgeColor 返回文字颜色,statusBadgeBg 返回背景颜色,两者配合使用形成完整的徽章配色方案。函数使用连续的 if 语句进行精确字符串匹配,每个状态值对应一组语义化的颜色对:绿色系代表"今日可约"(积极可用),粉色系代表"明日约满"(热门抢手),橙色系代表"周六有位"(周末有位),紫色系代表"可插单"(特殊通道),浅紫色代表"本周约满"(暂时不可约),灰色作为默认兜底值。
这种将颜色映射逻辑抽取为全局纯函数的做法有三个显著优势:首先是关注点分离,颜色决策逻辑与视图构建逻辑解耦,函数可以被任意 Builder 复用;其次是可维护性,当需要调整某个状态的颜色时,只需修改函数中的一行代码,所有引用处自动更新;最后是可测试性,纯函数无副作用,可以独立编写单元测试验证颜色映射的正确性。两个函数的参数名和返回类型完全一致,调用时只需传入状态字符串即可获得对应的颜色值,接口简洁一致。
4.2 时段格子颜色映射函数
function slotBg(r: number): string {
if (r <= 0) { return '#F2F0F3' }
if (r <= 2) { return '#FFE3EA' }
if (r <= 5) { return '#FFF1DE' }
return '#E1F5EB'
}
function slotText(r: number): string {
if (r <= 0) { return '#B9B3BD' }
if (r <= 2) { return '#E0547A' }
if (r <= 5) { return '#E08A3C' }
return '#2FA36B'
}
function slotLabel(r: number): string {
if (r <= 0) { return '约满' }
return `余${r}位`
}
这三个函数共同服务于时段预约表的格子渲染。slotBg 根据剩余位数返回格子背景色:0 位返回灰色(约满),1-2 位返回粉色(少量),3-5 位返回橙色(紧张),6 位及以上返回绿色(充足)。slotText 返回对应的文字颜色,遵循相同的分级逻辑但使用更深的主色调。slotLabel 返回格子标签文本:0 位显示"约满",其他显示"余 N 位"。
这三个函数的设计体现了数据驱动的颜色分级策略。通过剩余位数(remain)这一个数值字段,同时驱动了格子的背景色、文字色和标签文本三个视觉属性。这种设计使得时段表能够通过颜色直观传达余量的紧张程度,用户一眼扫过即可识别哪些时段有空位、哪些已约满,无需逐个阅读数字。slotLabel 函数使用模板字符串 `余${r}位` 动态生成文本,这是 ArkTS 对模板字符串语法的支持,比字符串拼接更加简洁可读。
4.3 价格百分比映射函数
function pricePct(p: number): string {
return `${Math.min(100, Math.round(p * 1.05))}%`
}
pricePct 函数将人均价格数值映射为进度条宽度百分比字符串。函数内部首先对价格值乘以 1.05 进行微调放大,然后通过 Math.round 四舍五入取整,再通过 Math.min(100, ...) 确保结果不超过 100%。最终通过模板字符串拼接百分号返回。
乘以 1.05 的微调是一个巧妙的设计。如果直接使用价格值作为百分比(如 128 元 → 128%),超过 100 的值会导致进度条溢出。但如果简单地截断为 100%,所有高价店铺的进度条都会满格,失去区分度。乘以 1.05 后再截断为 100%,意味着价格在 95 元以上的店铺进度条会满格,而 95 元以下的店铺进度条宽度与价格成正比,既保证了不溢出又保留了适度的区分度。这种细节调优体现了对数据可视化效果的深入思考。
4.4 预约记录状态颜色映射函数
function recordStatusColor(s: string): string {
if (s === '已完成') { return '#2FA36B' }
if (s === '待到店') { return '#E08A3C' }
if (s === '制作中') { return '#8B6FD8' }
return '#E0547A'
}
function recordStatusBg(s: string): string {
if (s === '已完成') { return '#E1F5EB' }
if (s === '待到店') { return '#FFF1DE' }
if (s === '制作中') { return '#EEE8FB' }
return '#FFE3EA'
}
这两个函数与状态徽章颜色映射函数的设计模式一致,但服务于预约记录时间线。recordStatusColor 返回状态标签和时间线节点圆点的文字颜色,recordStatusBg 返回状态徽章的背景色。四个状态值"已完成"(绿色)、“待到店”(橙色)、“制作中”(紫色)和默认的"已取消"(粉色)形成了完整的预约生命周期色彩体系。
这种统一的颜色语义体系贯穿了整个应用:绿色始终代表积极/成功状态,橙色代表进行中/等待状态,紫色代表特殊/制作状态,粉色代表取消/遗憾状态。用户在使用过程中会逐渐建立颜色与状态的直觉关联,提升交互效率。两个函数的存在确保了这种颜色语义的一致性不会因为分散硬编码而被破坏。
4.5 小贴士标签颜色映射函数
function tipBg(i: number): string {
const bgs: string[] = ['#FFE9EF', '#EDE7FB', '#FFF1DE', '#E3F6F3', '#FDF3D8', '#F4EDF7']
return bgs[i % 6]
}
function tipColor(i: number): string {
const cs: string[] = ['#E0547A', '#8B6FD8', '#E08A3C', '#2FA08F', '#C9A23F', '#B08BC0']
return cs[i % 6]
}
这两个函数为护理小贴士标签云提供了循环交替的颜色方案。tipBg 返回背景色,tipColor 返回文字色,两者通过索引取模 6 实现六色循环。六个颜色对分别是:粉系、紫系、橙系、青绿系、金黄系和浅紫系,覆盖了应用主色调的完整色谱。
使用取模运算实现循环颜色分配是一种简洁高效的设计。当标签数量超过六种时,颜色会从头开始循环,避免了为每个标签单独指定颜色的冗余。六种颜色虽然不多,但在视觉上已经足够区分相邻标签,配合不同的背景色和文字色组合,呈现出的效果丰富而不杂乱。这种设计在标签数量不确定的场景下尤其实用——无论增加或减少标签,颜色分配都能自动适配。
4.6 时段状态标签与评分星级函数
function slotStateLabel(st: number): string {
if (st === 0) { return '约满' }
if (st === 1) { return '紧张' }
return '充足'
}
function ratingStars(r: number): string {
if (r >= 4.9) { return '★★★★★' }
if (r >= 4.7) { return '★★★★★' }
return '★★★★☆'
}

slotStateLabel 函数将数字状态码映射为文本标签,用于预约改期弹框中的时段选择格子。ratingStars 函数将数值评分映射为星级字符串,评分 4.9 及以上显示五星实心,4.7-4.8 也显示五星实心,4.7 以下显示四星实心一星空心。这种简化映射虽然不够精细,但在快速展示场景中足够实用。
ratingStars 函数的设计有一个微妙之处:4.9 和 4.7 两个分支都返回五星实心,看起来是冗余的。但实际上这可能是为了未来更精细的分级预留的——如果将来需要区分 4.9 和 4.7 的星级显示(如 4.9 显示五星加金色、4.7 显示五星加银色),只需修改对应的分支即可,不影响其他分支的逻辑。这种防御性设计虽然增加了少量冗余代码,但提升了未来扩展的灵活性。
五、全局静态数据层
5.1 TAB_TITLES 内容页标题
const TAB_TITLES: string[] = ['款式图鉴', '预约美甲师', '探店', '时段表', '色彩趋势', '护理知识', '我的收藏']
TAB_TITLES 是一个字符串数组常量,定义了七个内容页的标题。这个数组被 tabBar Builder 中的 ForEach 遍历,生成七个可点击的 Tab 标签。数组索引(0-6)与 curTab 状态变量的值一一对应,驱动了内容区的条件渲染路由。
将 Tab 标题抽取为数组常量而非硬编码在 ForEach 内部,使得新增或调整 Tab 顺序时只需修改一处。数组中每个标题都是业务语义化的中文描述,直接传达了页面内容,用户无需猜测每个 Tab 的功能。七个 Tab 覆盖了从浏览款式到预约美甲师、从探店到学习护理知识的完整用户旅程,形成了一个闭环的美甲社区体验。
5.2 MOCK_STYLES 款式图鉴数据
const MOCK_STYLES: StyleData[] = [
new StyleData(1, '猫眼液态冰蓝', '#7FD4E8', '#4A9FCF', '🧊', '🔥 3.2w', '猫眼'),
new StyleData(2, '法式小白边', '#FFF6F9', '#F3DCE6', '🤍', '🔥 2.8w', '法式'),
new StyleData(3, '蜜桃乌龙奶冻', '#FFD9C4', '#FFB9A3', '🍑', '🔥 2.6w', '渐变'),
// ... 共20条数据
new StyleData(20, '黑白钢琴键', '#2B2B33', '#F0F0F2', '🎹', '🔥 8750', '极简')
]
MOCK_STYLES 是款式图鉴页面的数据源,包含 20 条 StyleData 实例。每条数据通过 new StyleData(...) 构造函数创建,七个参数分别对应 id、名称、主色、辅色、emoji、热度和标签。数据内容涵盖了猫眼、法式、渐变、纯色、深色、镶钻、手绘、极简等多种美甲风格,色彩从冰蓝、樱粉到黑樱桃、液态金属银,覆盖了广泛的色谱和风格。
20 条数据的数量是精心选择的:在 4 列宫格布局中,20 条数据恰好产生 5 行,不会太多导致页面冗长,也不会太少显得内容单薄。每条数据的 color 和 color2 都是经过精心搭配的双色渐变组合,如"猫眼液态冰蓝"的 #7FD4E8→#4A9FCF(浅蓝→深蓝),"蜜桃乌龙奶冻"的 #FFD9C4→#FFB9A3(浅桃→深桃),这些色值的选择参考了真实的美甲色系,使得应用呈现出专业可信的视觉效果。
5.3 MOCK_ARTISTS 美甲师数据
const MOCK_ARTISTS: ArtistData[] = [
new ArtistData(1, '桃桃酱Peach', '🍑', ['日式手绘', '法式'], 268, '今日可约', 4.9, '2.3w', '蜜桃星球Nail Studio', 356),
new ArtistData(2, '小鹿Nail', '🦌', ['镶钻', '立体花'], 388, '明日约满', 4.8, '1.8w', '云朵与钻Studio', 214),
// ... 共12条数据
new ArtistData(12, '一颗苹果', '🍎', ['果冻胶', '清透'], 208, '今日可约', 4.9, '5400', '桃气补给站', 88)
]
MOCK_ARTISTS 包含 12 条美甲师数据。每条数据的 skills 数组包含 2 个技能标签,status 覆盖了所有五种状态值,rating 在 4.7-5.0 之间。12 条数据在列表中产生了适中的滚动长度,配合"到底啦"的底部提示文案,形成了完整的列表浏览体验。
美甲师昵称的设计很有特色——每个昵称都结合了水果/动物 emoji 和日系/韩系风格的中文+英文组合(如"桃桃酱Peach"、“小鹿Nail”、“Momo手作”),符合美甲行业的品牌命名习惯。价格从 158 元到 388 元覆盖了不同的消费档次,状态值的多样性使得 statusBadgeColor 和 statusBadgeBg 函数的各种分支都能被触发展示。
5.4 MOCK_SHOPS 店铺数据
const MOCK_SHOPS: ShopData[] = [
new ShopData(1, '指尖宇宙美甲(天河店)', '🌌', '天河·体育西路', 4.9, 128, '999+评价', ['一站式款式库', '新客立减', '探店爆款']),
// ... 共10条数据
new ShopData(10, '月亮不睡美甲馆', '🌙', '越秀·淘金', 4.9, 124, '1027评价', ['营业到24点', '夜猫子专供'])
]
MOCK_SHOPS 包含 10 条店铺数据。每条数据包含商圈位置(如"天河·体育西路"),评分在 4.5-5.0 之间,人均价格从 58 元到 138 元。tags 数组中的标签内容非常有特色——“落日景观位”、“和风榻榻米”、“VIP包间”、"营业到24点"等标签不仅传达了店铺特色,还营造了消费场景的想象空间。
人均价格的分布设计巧妙地配合了 pricePct 函数:58 元的"桃气补给站"进度条约 61%,138 元的"樱花小巷日式美甲"进度条满格(超过 95 元被截断为 100%)。通过三元表达式 (sh.avgPrice > 110) ? '轻奢档' : (sh.avgPrice > 85 ? '中档' : '平价档'),10 家店铺被分为了三个价格档位,这种分级展示帮助用户快速定位符合预算的店铺。
5.5 MOCK_SCHEDULES 时段排期数据
const MOCK_SCHEDULES: ShopScheduleData[] = [
new ShopScheduleData('指尖宇宙美甲(天河店)', '天河·体育西路', [new SlotCellData('上午', 3, 8), new SlotCellData('下午', 0, 10), new SlotCellData('晚上', 5, 6)]),
// ... 共8条数据
new ShopScheduleData('月亮不睡美甲馆', '越秀·淘金', [new SlotCellData('上午', 3, 6), new SlotCellData('下午', 2, 8), new SlotCellData('晚上', 6, 8)])
]

MOCK_SCHEDULES 包含 8 条店铺排期数据。每条数据包含店铺名称、商圈位置和一个由三个 SlotCellData 实例组成的数组,分别对应上午、下午、晚上三个时段。remain 值的分布覆盖了 0(约满)到 6(充足)的各种情况,配合 slotBg、slotText 和 slotLabel 函数,时段表呈现出丰富的颜色层次。
时段排期数据的设计体现了对真实预约场景的模拟。如"指尖宇宙美甲(天河店)"下午已约满(0/10)但上午和晚上还有余位,这种不均匀的分布符合真实的预约规律——下午时段通常是最热门的。8 条数据刚好在表格中产生适中的高度,配合表头和底部汇总,形成了完整的表格卡片。
5.6 MOCK_TRENDS 色彩趋势数据
const MOCK_TRENDS: TrendData[] = [
new TrendData(1, '冰淇淋粉', 'PANTONE 13-1520', '#F6C6D5', '#EFA8C0', '↑ 32%', '本季搜索量暴涨,少女感天花板'),
// ... 共8条数据
new TrendData(8, '液态月光银', 'PANTONE 17-3907', '#D9DDE3', '#C2C8D1', '↑ 7%', '金属猫眼绝配,酷感拉满')
]
MOCK_TRENDS 包含 8 条色彩趋势数据。每条数据引用了真实的 PANTONE 色号,如"PANTONE 13-1520"对应冰淇淋粉,hex 和 hex2 色值与 PANTONE 色号匹配。rise 字段的涨幅从 7% 到 32% 递减排列,传递了趋势热度排行榜的语义——排在最前面的颜色增长最猛。
8 条数据在横向滚动布局中产生了良好的浏览体验——用户向左滑动可以查看全部 8 种流行色,每张色卡宽度 140vp,配合 12vp 的右边距,总宽度约 1216vp,需要滑动 2-3 次才能看完。desc 字段的一句话描述既有信息量又有趣味性,如"低饱和紫调,温柔到犯规"、“奶fufu的高级感绿”,符合小红书风格的文案调性。
5.7 MOCK_ARTICLES 趋势文章数据
const MOCK_ARTICLES: ArticleData[] = [
new ArticleData(1, '2026秋冬美甲趋势预测:奶冻感甲面要统治整个季节', '从首尔到东京的秀场后台...', '指甲盖上的宇宙', '1.2w', '2天前', '💅', '趋势', '#FFD9E6', '#F6B8D0'),
// ... 共6条数据
new ArticleData(6, '卸甲不伤甲!美甲师私下都在用的护理流程', '温水+锡纸+耐心才是正解...', '手护小课堂', '1.5w', '2周前', '🫧', '护理', '#DFF3EF', '#BCE3DA')
]
MOCK_ARTICLES 包含 6 条文章数据。每条数据有完整的标题、摘要、作者、点赞数、发布时间、封面 emoji、分类标签和封面渐变双色。6 条文章涵盖了趋势预测、干货教程、测评排名、技巧分享、探店测评和护理科普等多种内容类型,tag 字段的值(“趋势”、“干货”、“测评”、“技巧”、“探店”、“护理”)分类清晰。
每篇文章的 c1 和 c2 封面色都经过精心搭配,与文章主题相关。如趋势预测文章用粉色调(#FFD9E6→#F6B8D0),猫眼干货用蓝色调(#D6E6F8→#B8D0EE),显白测评用紫色调(#EFE0F8→#DCC2F0)。这种内容相关的色彩设计使得用户在浏览时能够通过封面色块快速识别文章类型,提升了浏览效率。
5.8 MOCK_QAS 护理问答数据
const MOCK_QAS: CareQAData[] = [
new CareQAData(1, '🧴', '做完美甲能洗碗吗?', '建议48小时内尽量戴手套接触洗洁精与热水。甲油胶完全固化需要时间,过早浸泡热水可能导致甲面起翘。日常洗手用温水即可,洗完及时涂指缘油锁水。', '12.6w'),
// ... 共6条数据
new CareQAData(6, '🧊', '冬天指甲容易断是什么原因?', '低温干燥让甲板含水量下降变脆。洗手后及时擦干并涂护手霜+指缘油,饮食补充蛋白质与生物素,室内开加湿器也有帮助。', '5.4w')
]
MOCK_QAS 包含 6 条护理问答数据。每条数据包含问题图标、问题文本、详细回答和浏览次数。回答内容相当详细和专业,如第一条回答中提到了"48小时内"、“甲油胶完全固化”、“甲面起翘”、“指缘油锁水"等专业术语,第六条回答中提到了"甲板含水量”、“生物素”、"加湿器"等科学概念。
问答数据的浏览次数从 5.4w 到 15.3w 不等,其中"为什么不能自己抠掉美甲?"的浏览量最高(15.3w),这反映了用户对指甲损伤防护的高度关注。6 条问答在卡片列表中产生了适中的滚动长度,每条问答卡片的回答文本使用 lineHeight(17) 控制行间距,确保了长文本的可读性。
5.9 MOCK_TIPS 护理小贴士数据
const MOCK_TIPS: TipItem[] = [
{ icon: '🧤', label: '做家务戴手套' },
{ icon: '🥜', label: '补充生物素' },
// ... 共17条数据
{ icon: '💌', label: '两周检查一次甲缘' }
]
MOCK_TIPS 包含 17 条小贴士数据。与前面使用 new 构造函数创建实例的数据集不同,MOCK_TIPS 直接使用对象字面量数组。这是因为 TipItem 接口仅包含两个简单属性(icon 和 label),没有对应的 @Observed class,所以直接使用对象字面量是合理的——小贴士标签不需要状态观察性,静态展示即可。
17 条标签数据在 Flex 换行布局中自动排列,配合 tipBg 和 tipColor 函数的六色循环,呈现出一朵色彩斑斓的标签云。17 条数据超过 6 的循环周期,所以会有大约 3 轮颜色循环,但在视觉上不会显得重复——因为每条标签的文字内容不同,颜色循环反而增添了节奏感。标签内容涵盖了日常护理、饮食营养、操作习惯等多个维度,是一份实用的指甲护理清单。
5.10 MOCK_FAVS 与 MOCK_RECORDS 收藏与记录数据
const MOCK_FAVS: FavData[] = [
new FavData(1, '猫眼液态冰蓝', '#7FD4E8', '#4A9FCF', 'Momo手作', '❤️ 3.2w'),
// ... 共8条数据
new FavData(8, '极光猫眼绿', '#63C6A8', '#3E9E7E', 'Luna露娜', '❤️ 9320')
]
const MOCK_RECORDS: RecordData[] = [
new RecordData(1, '08-24', '14:00', '猫眼液态冰蓝 + 碎钻升级', 'Momo手作', '指尖宇宙美甲(天河店)', '待到店', 298),
// ... 共7条数据
new RecordData(7, '07-06', '20:00', '月亮不睡延长款', 'Luna露娜', '月亮不睡美甲馆', '已取消', 348)
]
MOCK_FAVS 包含 8 条收藏数据,用于我的收藏页面的两列网格展示。MOCK_RECORDS 包含 7 条预约记录数据,用于预约记录时间线展示。7 条记录的状态分布设计得很合理:1 条"待到店"(触发改期和取消按钮)、4 条"已完成"(触发再来一单按钮)、1 条"已取消"(粉色状态徽章)。
预约记录的时间从 08-24 倒序排列到 07-06,形成了清晰的时间线。第一条记录(08-24)状态为"待到店",日期是最近的,这种设计让用户进入"我的收藏"页面时首先看到的是最需要关注的待处理订单。每条记录的 styleName 包含了详细的款式描述(如"猫眼液态冰蓝 + 碎钻升级"),price 从 158 到 388 不等,提供了丰富的消费记录信息。
5.11 PALETTES、DECOS、ADD_STYLE_TAGS 弹框辅助数据
const PALETTES: ColorPalette[] = [
{ name: '樱花粉', hex: '#FFB7C9' },
{ name: '冰蓝', hex: '#A8DDEC' },
// ... 共10种颜色
{ name: '黑樱桃', hex: '#6B2D3F' }
]
const DECOS: DecoItem[] = [
{ icon: '💎', name: '碎钻贴片', price: 20 },
{ icon: '🦪', name: '珍珠排钻', price: 25 },
// ... 共8种装饰件
{ icon: '☁️', name: '云朵晕染', price: 22 }
]
const ADD_STYLE_TAGS: string[] = ['甜酷风', '法式', '纯色', '猫眼', '渐变', '手绘', '镶钻', '极简']
这三个数据集合服务于款式 DIY 设计器弹框。PALETTES 提供 10 种主色选择,DECOS 提供 8 种装饰件选项,ADD_STYLE_TAGS 提供 8 种风格标签。三个数据集合与 MOCK_TIPS 一样使用对象字面量数组(而非 @Observed class 实例),因为这些数据在弹框中仅用于静态展示和选择,不需要状态观察性。
PALETTES 的颜色覆盖了樱粉、冰蓝、薰衣草、蜜桃橘、奶油白、抹茶绿、葡萄紫、落日红、海盐蓝、黑樱桃十种色系,这是美甲行业最常见的色彩分类。DECOS 的装饰件及价格从 12 元到 30 元不等,用户勾选时会通过 getDecoCount() 方法实时统计已选数量。ADD_STYLE_TAGS 的 8 个标签与 MOCK_STYLES 数据中的 tag 字段值保持一致,确保了 DIY 款式与已有款式的分类体系统一。
5.12 WEEK_DAYS、EDIT_SLOTS 日期与时段数据
const WEEK_DAYS: WeekDayItem[] = [
{ label: '周一', date: '08/24', rest: false },
{ label: '周二', date: '08/25', rest: false },
{ label: '周三', date: '08/26', rest: false },
{ label: '周四', date: '08/27', rest: false },
{ label: '周五', date: '08/28', rest: false },
{ label: '周六', date: '08/29', rest: true },
{ label: '周日', date: '08/30', rest: true }
]
const EDIT_SLOTS: EditSlotItem[] = [
{ label: '09:00', state: 2 },
{ label: '10:00', state: 1 },
{ label: '11:00', state: 0 },
// ... 共12个时段
{ label: '20:00', state: 2 }
]
WEEK_DAYS 定义了一周的日期选择项,rest 字段标识周末(周六、周日),用于在日期选择条中以粉色文字强调休息日。EDIT_SLOTS 定义了一天 12 个时段(09:00 到 20:00)的状态,state 值 0 表示约满、1 表示紧张、2 表示充足。这两个数据集合分别在时段表页面的星期选择条和预约改期弹框的时段选择区域使用。
EDIT_SLOTS 的状态分布设计模拟了真实预约场景:中午时段(11:00、13:00、16:00)多为约满或紧张,早晚时段(09:00、19:00、20:00)多为充足。这种分布让 slotStateLabel 函数的三种返回值都能被展示,也使得用户在改期时能够直观地看到哪些时段可选、哪些不可选。
5.13 FX_EMOJIS 与 TOP_BANNERS 特效与榜单数据
const FX_EMOJIS: FxEmoji[] = [
{ icon: '💅', xSpeed: 0.35, ySpeed: 0.22, x0: 5, y0: 8, size: 26, drift: 0 },
{ icon: '💖', xSpeed: -0.28, ySpeed: 0.18, x0: 92, y0: 14, size: 20, drift: 3 },
// ... 共12个emoji
{ icon: '✨', xSpeed: -0.26, ySpeed: -0.3, x0: 78, y0: 18, size: 14, drift: 6 }
]
const TOP_BANNERS: TipItem[] = [
{ icon: '🥇', label: '猫眼液态冰蓝 连续7天登顶' },
{ icon: '🥈', label: '蜜桃乌龙奶冻 空降第二' },
{ icon: '🥉', label: '法式小白边 稳居前三' }
]
FX_EMOJIS 是浮动 emoji 特效层的参数集合,包含 12 个 emoji 的运动参数。每个 emoji 的 xSpeed 和 ySpeed 有正有负,使得一部分 emoji 向右下方移动、一部分向左下方、一部分向左上方,营造出四面八方漂浮的效果。x0 和 y0 的初始位置百分比分布在 5-95 之间,确保 emoji 不会全部聚集在某一区域。size 从 14 到 26 不等,大小差异增强了景深感。drift 从 0 到 7 不等,使得各 emoji 的透明度变化节律不同步。
TOP_BANNERS 是款式图鉴页面的榜单前三名横幅,使用金银铜牌 emoji 表示排名。三个款式名与 MOCK_STYLES 数据的前三条对应,形成了一致的排名信息。
六、入口组件与状态管理体系
6.1 @Entry 组件声明与状态变量定义
@Entry
@Component
struct NailArtApp {
@State curTab: number = 0
@State mainTab: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State bizOpen: boolean = false
@State tick: number = 0
private timer: number = -1
@Entry 装饰器标记 NailArtApp 为应用的入口组件,每个应用只能有一个 @Entry 组件。@Component 装饰器标记该 struct 为一个自定义组件,使其可以被框架实例化和管理生命周期。
状态变量的定义分为三组。第一组是导航状态:curTab 控制内容区七选一的页面路由,mainTab 控制底部导航栏的高亮项。第二组是弹框状态:addOpen、editOpen、delOpen、bizOpen 四个布尔值分别控制四类弹框的显隐。第三组是动画时钟:tick 是每 120 毫秒自增的计数器,驱动浮动 emoji 特效。timer 是定时器 ID,使用 private 修饰符标记为组件私有,不参与 UI 渲染。
四个弹框状态变量使用 boolean 类型并初始化为 false,表示初始状态下所有弹框都不可见。这种"多布尔值开关"的设计虽然简单,但在弹框互斥的场景下是够用的——应用同一时刻只允许一个弹框打开,通过 closeAllModals 方法可以一键关闭所有弹框。tick 的初始值为 0,通过定时器每 120 毫秒自增 1,配合 fxLayer 中的位置计算公式实现 emoji 的持续移动。
6.2 弹框状态变量与表单状态
// ---- 新增弹框(款式DIY设计器)状态 ----
@State addName: string = ''
@State addColorIdx: number = 0
@State addStyleSel: string[] = ['甜酷风']
@State addDecoSel: boolean[] = [true, false, false, false, true, false, false, false]
// ---- 编辑弹框(预约改期)状态 ----
@State editDay: number = 0
@State editSlot: number = 6
@State editNote: string = ''
@State editTarget: string = '猫眼液态冰蓝 + 碎钻升级 · Momo手作'
// ---- 删除弹框(卸甲警示)状态 ----
@State delTarget: string = '猫眼液态冰蓝 + 碎钻升级'
// ---- biz弹框(到店排队叫号)状态 ----
@State bizShop: string = '指尖宇宙美甲(天河店)'
@State bizNo: string = 'B-07'
@State bizAhead: number = 6
@State bizWait: number = 25
@State bizStyle: string = '猫眼液态冰蓝'
// ---- 时段表页状态 ----
@State slotDay: number = 0
弹框表单状态的设计体现了"状态即表单"的声明式理念。新增弹框有四个状态:addName 存储款式名称输入,addColorIdx 记录选中的主色索引,addStyleSel 是已选风格标签的字符串数组,addDecoSel 是装饰件勾选布尔数组。编辑弹框有四个状态:editDay 和 editSlot 分别记录选中的日期和时段索引,editNote 存储备注输入,editTarget 记录改期目标信息。删除弹框仅需 delTarget 一个状态。到店排队弹框有五个状态:店铺名、排队号、前方人数、等待时长和款式名。
这些表单状态的共同特点是"可重置"——resetAddForm 方法可以将新增弹框的所有状态恢复为初始值。这种设计使得弹框每次打开时都可以从干净的初始状态开始,避免上次操作的残留数据影响当前交互。editTarget 和 delTarget 等状态的设计则支持"从列表到弹框"的数据传递——当用户在款式列表中点击某个款式时,会将款式名赋值给 editTarget 然后打开编辑弹框,弹框内容直接展示该款式名,实现了数据的跨组件传递。slotDay 状态独立于弹框系统,专门用于时段表页面的星期选择条。
6.3 生命周期方法
aboutToAppear() {
this.timer = setInterval(() => { this.tick++ }, 120)
}
aboutToDisappear() {
if (this.timer >= 0) { clearInterval(this.timer) }
}
aboutToAppear 和 aboutToDisappear 是 ArkTS 组件的两个核心生命周期回调。aboutToAppear 在组件创建后、build 方法执行前调用,用于执行初始化逻辑。这里通过 setInterval 注册了一个每 120 毫秒执行的回调函数,每次执行将 tick 值加 1。由于 tick 是 @State 装饰的状态变量,其值变化会自动触发依赖它的 UI 部分重新渲染——具体来说是 fxLayer 中的 emoji 位置和透明度。
aboutToDisappear 在组件销毁前调用,用于执行清理逻辑。这里通过 clearInterval 清除了定时器,避免组件销毁后定时器继续运行导致的内存泄漏。if (this.timer >= 0) 的判断是一个防御性检查,确保只有在定时器确实被创建过(ID 大于等于 0)时才执行清除操作。定时器 ID 初始值为 -1,如果 aboutToAppear 因某种原因未能正常执行,aboutToDisappear 不会尝试清除一个不存在的定时器。
120 毫秒的间隔选择是一个权衡值。更短的间隔(如 60ms)会带来更流畅的动画但消耗更多 CPU 资源;更长的间隔(如 200ms)更省电但动画会显得卡顿。120ms 约为每秒 8.3 帧,对于位置缓慢变化的浮动 emoji 来说已经足够流畅,同时对性能影响较小。
6.4 状态联动方法
toggleAddStyle(s: string) {
const idx: number = this.addStyleSel.indexOf(s)
if (idx >= 0) {
this.addStyleSel.splice(idx, 1)
} else {
this.addStyleSel.push(s)
}
}
toggleDeco(i: number) {
this.addDecoSel[i] = !this.addDecoSel[i]
}
getDecoCount(): number {
let n: number = 0
for (let i = 0; i < this.addDecoSel.length; i++) {
if (this.addDecoSel[i]) { n++ }
}
return n
}
resetAddForm() {
this.addName = ''
this.addColorIdx = 0
this.addStyleSel = ['甜酷风']
this.addDecoSel = [true, false, false, false, true, false, false, false]
}
closeAllModals() {
this.addOpen = false
this.editOpen = false
this.delOpen = false
this.bizOpen = false
}
这五个方法封装了弹框交互的核心逻辑。toggleAddStyle 实现风格标签的多选切换:通过 indexOf 检查标签是否已在已选列表中,若在则用 splice 移除,若不在则用 push 添加。toggleDeco 通过数组索引直接翻转布尔值实现装饰件的勾选切换。getDecoCount 遍历布尔数组统计已选数量,用于在弹框标题处实时显示"已勾 N 件"。resetAddForm 将新增弹框的所有表单状态恢复为初始值。closeAllModals 将四个弹框的可见状态全部设为 false,用于点击遮罩层时关闭任意弹框。
这些方法的设计体现了 ArkTS 中状态管理的核心原则:通过方法封装状态变更逻辑,而非在 onClick 中直接写赋值语句。这样做的好处是逻辑可复用、可测试,且方法名本身就是业务语义的文档——toggleAddStyle 一看就知道是"切换风格标签的多选状态"。closeAllModals 的设计尤其巧妙,它避免了在遮罩层的 onClick 中写四行赋值语句,通过一个方法调用实现了"一键关闭所有弹框"的语义。
6.5 状态管理时序图
这张时序图展示了用户交互与状态更新之间的因果关系。当用户点击款式卡片时,入口组件接收点击事件,同时更新 editTarget 和 editOpen 两个状态变量,框架检测到状态变化后执行差分渲染,弹框出现在界面上。用户在弹框内操作时,状态变量持续更新,框架持续局部刷新弹框内容。与用户交互并行运行的是定时器,每 120 毫秒递增 tick 状态,触发 fxLayer 的位置刷新。两条时间线独立运行互不干扰,是声明式 UI 框架的核心优势——开发者无需手动协调不同更新来源的渲染时序,框架自动处理状态合并和渲染调度。
七、全屏浮动 emoji 特效层
@Builder fxLayer() {
Column() {
ForEach(FX_EMOJIS, (f: FxEmoji) => {
Text(f.icon)
.fontSize(f.size)
.opacity(((this.tick + f.drift) % 8 < 4) ? 0.45 : 0.15)
.position({
x: `${(f.x0 + this.tick * f.xSpeed) % 100}%`,
y: `${(f.y0 + this.tick * f.ySpeed) % 100}%`
})
}, (f: FxEmoji) => f.icon)
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 是一个 @Builder 装饰的方法,用于构建全屏浮动 emoji 特效层。@Builder 是 ArkTS 中声明可复用 UI 片段的装饰器,与 @Component 不同,@Builder 方法不需要独立的组件声明,可以直接在父组件内部定义和调用。
这段代码的核心逻辑是通过 ForEach 遍历 FX_EMOJIS 数组,为每个 emoji 创建一个 Text 组件。每个 Text 的 fontSize 来自数据中的 f.size 属性,使得不同 emoji 呈现不同大小。opacity 属性使用了一个复合表达式:((this.tick + f.drift) % 8 < 4) ? 0.45 : 0.15,这个表达式利用 tick 计数器和 drift 偏移量计算一个 0-7 的循环值,当前 4 时透明度为 0.45(较亮),大于等于 4 时透明度为 0.15(较暗),实现了 emoji 的闪烁效果。由于每个 emoji 的 drift 值不同,它们的闪烁周期错开,避免了所有 emoji 同步闪烁的单调感。
position 属性是 emoji 移动的核心。x 和 y 坐标都使用模板字符串生成百分比值:(f.x0 + this.tick * f.xSpeed) % 100。初始位置 f.x0 加上 tick 乘以速度 f.xSpeed 的累积位移,再取模 100 确保位置始终在 0-100% 范围内。当位置超过 100% 时取模回到 0%,实现了 emoji 在屏幕边缘的循环穿越效果。xSpeed 有正有负,使得一部分 emoji 向右移动、一部分向左移动,配合 ySpeed 的正负变化,emoji 在屏幕上呈现出多维度的漂浮轨迹。
最关键的设计是 hitTestBehavior(HitTestMode.None)。这个属性设置整个特效层的命中测试行为为 None,意味着这层不拦截任何触摸事件,所有触摸穿透到下层的内容区。如果没有这个设置,覆盖全屏的特效层会阻挡用户与下层内容的所有交互,使得应用完全不可用。HitTestMode.None 是实现"装饰层不影响交互"的标准做法。
八、静态渐变头部
@Builder header() {
Column() {
// 顶部:品牌 + 通知
Row() {
Row() {
Text('💅')
.fontSize(26)
Column() {
Text('NailStar')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('美甲星球 · 指尖灵感社区')
.fontSize(10)
.fontColor('rgba(255,255,255,0.85)')
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
Row() {
Text('🔔')
.fontSize(18)
.padding(6)
.backgroundColor('rgba(255,255,255,0.25)')
.borderRadius(14)
Text('📷')
.fontSize(18)
.padding(6)
.backgroundColor('rgba(255,255,255,0.25)')
.borderRadius(14)
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 10 })
.alignItems(VerticalAlign.Center)
header Builder 构建了应用的静态头部区域。头部使用 linearGradient 实现从樱粉到薰衣草紫的三色渐变背景,配合 borderRadius({ bottomLeft: 24, bottomRight: 24 }) 的底部圆角,呈现出柔和的品牌视觉。
头部的第一行分为左右两部分。左侧是品牌标识,由一个 26 号的指甲 emoji 和一个 Column 组成,Column 内部纵向排列品牌名"NailStar"(18号加粗白色)和副标题"美甲星球 · 指尖灵感社区"(10号半透明白色)。右侧是通知和拍照两个图标按钮,每个按钮使用 rgba(255,255,255,0.25) 的半透明白色背景配 14 的圆角,呈现出磨砂玻璃效果。左右两侧通过 justifyContent(FlexAlign.SpaceBetween) 实现两端对齐。
这种头部设计遵循了移动端社区类应用的标准布局——左侧品牌、右侧功能入口。使用 emoji 作为图标避免了图片资源的依赖,同时半透明背景的圆角按钮在渐变背景上呈现出统一的玻璃质感。
8.1 搜索条与胶囊入口
// 搜索条(静态展示)
Row() {
Text('🔍')
.fontSize(14)
Text('搜一搜"猫眼美甲" / "樱花小巷"')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ left: 6 })
}
.width('100%')
.height(36)
.borderRadius(18)
.backgroundColor('rgba(255,255,255,0.28)')
.padding({ left: 14, right: 14 })
.alignItems(VerticalAlign.Center)
.margin({ top: 12 })
// 静态胶囊入口
Row() {
Text('🔥 今日爆款')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#D6336C')
.backgroundColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
Text('✨ 新品首发')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.3)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin({ left: 8 })
Text('🎁 限时闪购')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.3)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin({ left: 8 })
Text('👑 会员礼遇')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.3)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 10 })
.padding({ left: 16, right: 16 })
搜索条是一个静态展示的 Row 组件,使用半透明白色背景配 18 的圆角(高度 36 的一半),模拟搜索框的外观。内部放置搜索 emoji 和占位提示文本,提示文本使用了具体的搜索关键词示例(“猫眼美甲”、“樱花小巷”),这种设计比通用的"搜索"提示更具引导性,帮助用户了解可以搜什么。
胶囊入口行排列了四个快捷入口标签。"今日爆款"使用白底粉色文字突出显示(主推入口),其余三个(“新品首发”、“限时闪购”、“会员礼遇”)使用半透明白色背景配白色文字,视觉上从属于主推入口。四个入口通过不同的 emoji 前缀区分类别,虽然当前是静态展示,但在视觉上形成了运营活动入口的氛围。
8.2 头部数据行与渐变背景
// 头部数据行(静态)
Row() {
Text('今日上新 128 款')
.fontSize(10)
.fontColor('rgba(255,255,255,0.9)')
Text('·').fontSize(10).fontColor('rgba(255,255,255,0.6)').margin({ left: 6, right: 6 })
Text('在线美甲师 96 人')
.fontSize(10)
.fontColor('rgba(255,255,255,0.9)')
Text('·').fontSize(10).fontColor('rgba(255,255,255,0.6)').margin({ left: 6, right: 6 })
Text('今日已预约 1,024 单')
.fontSize(10)
.fontColor('rgba(255,255,255,0.9)')
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 14 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.linearGradient({
angle: 140,
colors: [['#FF9EC7', 0.0], ['#E8A8E0', 0.5], ['#B89AE6', 1.0]]
})
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
}
头部数据行展示三个运营指标:“今日上新 128 款”、“在线美甲师 96 人”、“今日已预约 1,024 单”。三个数据项之间用半透明点号分隔,字体大小 10 号、颜色 90% 透明度的白色,既不喧宾夺主又提供了活跃度信息。这种设计在电商和社区类应用中很常见,通过展示实时运营数据营造平台的活跃氛围。
头部最外层 Column 的 linearGradient 属性设置了 140 度角度的三色渐变:0% 位置是樱粉色 #FF9EC7,50% 位置是过渡色 #E8A8E0,100% 位置是薰衣草紫 #B89AE6。140 度的渐变角度使得色彩从左上向右下过渡,配合底部 24 的圆角,呈现出柔和的品牌渐变效果。这三色渐变是整个应用的视觉基调,后续多个组件(如款式榜单横幅、底部发布按钮、弹框确认按钮等)都复用了相同或相似的渐变配色,保持了视觉一致性。
九、内容 Tab 栏
@Builder tabBar() {
Scroll() {
Row() {
ForEach(TAB_TITLES, (t: string, i: number) => {
if (this.curTab === i) {
Text(t)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#E0547A')
.borderRadius(14)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ left: 6, right: 6 })
} else {
Text(t)
.fontSize(13)
.fontColor('#8A7A90')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ left: 6, right: 6 })
.onClick(() => { this.curTab = i })
}
}, (t: string) => t)
}
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
tabBar Builder 构建了七个内容页的标签栏。外层使用 Scroll 组件包裹 Row,设置 scrollable(ScrollDirection.Horizontal) 启用水平滚动,scrollBar(BarState.Off) 隐藏滚动条,使得七个 Tab 在小屏幕上可以横向滑动浏览。
ForEach 遍历 TAB_TITLES 数组,为每个标题创建一个 Text 组件。这里使用 if-else 条件分支实现了选中态和未选中态的视觉区分:当前选中的 Tab(this.curTab === i)使用白色文字配粉色背景 #E0547A,字重加粗;未选中的 Tab 使用灰色文字 #8A7A90 配白色背景。两种状态的字体大小(13号)、圆角(14)、内边距和左右间距完全一致,确保了切换 Tab 时标签位置不跳动。
只有未选中的 Tab 绑定了 onClick 事件——点击未选中 Tab 时将 curTab 设为对应索引 i,触发条件分支重新渲染,选中的 Tab 变为选中态,原选中的 Tab 变为未选中态。选中的 Tab 没有绑定 onClick,因为点击已选中的 Tab 不需要做任何操作。这种细粒度的事件绑定设计避免了不必要的重复触发。
ForEach 的键值生成器使用 (t: string) => t,即标题文本本身作为键。由于七个标题互不相同,这保证了每个 Tab 的唯一标识。当 Tab 顺序调整或标题修改时,框架能够通过键值精确识别哪些 Tab 发生了变化,执行最小化的 DOM 更新。
十、页面一:款式图鉴(四列色板宫格)
10.1 热门榜单横幅
@Builder pageStyles() {
Column() {
Row() {
Column() {
Text('🔥 本周热门款式榜')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('每周一 10:00 更新 · 依据收藏+预约热度')
.fontSize(10)
.fontColor('rgba(255,255,255,0.85)')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🏆')
.fontSize(34)
}
.width('100%')
.padding(16)
.borderRadius(16)
.linearGradient({
angle: 120,
colors: [['#FF7EB3', 0.0], ['#A78BFA', 1.0]]
})
.alignItems(VerticalAlign.Center)
款式图鉴页面的顶部是一个热门榜单横幅。横幅使用从粉到紫的双色渐变背景(#FF7EB3 -> #A78BFA),这是与头部渐变一脉相承的品牌配色。横幅左侧是标题"本周热门款式榜"和更新说明"每周一 10:00 更新 · 依据收藏+预约热度",右侧是一个 34 号的奖杯 emoji 作为视觉点缀。
横幅的设计采用了 layoutWeight(1) 实现弹性布局:左侧 Column 设置 layoutWeight(1) 占据剩余空间,右侧 emoji 固定大小。justifyContent 默认为 SpaceBetween 的效果由 layoutWeight 实现——左侧弹性占满空间后,右侧自然被推到最右边。这种布局方式在 ArkTS 中比 justifyContent(FlexAlign.SpaceBetween) 更常用,因为它更直观地表达了"左弹性、右固定"的布局意图。
10.2 榜单前三胶囊
Row() {
ForEach(TOP_BANNERS, (b: TipItem) => {
Row() {
Text(b.icon)
.fontSize(12)
Text(b.label)
.fontSize(10)
.fontColor('#B34A6E')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 4 })
}
.backgroundColor('#FFF0F6')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.margin({ right: 6 })
}, (b: TipItem) => b.label)
}
.width('100%')
.margin({ top: 10 })
榜单前三胶囊使用 ForEach 遍历 TOP_BANNERS 数组,为每个榜单项创建一个 Row 组件,内部包含奖牌 emoji 和款式名文本。胶囊使用浅粉背景 #FFF0F6 配深粉文字 #B34A6E,圆角 10。maxLines(1) 配 textOverflow({ overflow: TextOverflow.Ellipsis }) 确保文本超出宽度时显示省略号而非换行,保持了胶囊的单行布局。
maxLines 和 textOverflow 的组合是 ArkTS 中处理文本溢出的标准方案。maxLines(1) 限制文本最多一行,TextOverflow.Ellipsis 指定溢出时用省略号"…"代替。这种声明式的文本截断方案比手动计算字符数截断更加准确,框架会根据实际渲染宽度自动截断,适应不同屏幕尺寸。
10.3 分区标题行
Row() {
Text('🎨 全部款式')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Text(`(${MOCK_STYLES.length})`)
.fontSize(12)
.fontColor('#B09BB8')
.margin({ left: 4 })
Column()
.layoutWeight(1)
Text('按热度排序')
.fontSize(11)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 16, bottom: 10 })
分区标题行展示"全部款式"标题、款式总数和排序方式。总数通过 MOCK_STYLES.length 动态计算并以模板字符串拼接在括号内展示。中间使用一个空的 Column().layoutWeight(1) 作为弹性分隔符,将标题和排序方式分别推到左右两端,实现了类似 SpaceBetween 的效果。
这种"空弹性组件"的技巧在 ArkTS 中非常常用。当需要在 Row 中实现两端对齐但不想使用 justifyContent(FlexAlign.SpaceBetween) 时(例如需要精确控制间距),可以在中间插入一个 layoutWeight(1) 的空组件占满剩余空间。MOCK_STYLES.length 的动态计算确保了总数显示总是与实际数据一致,即使将来增减款式数据也不需要手动修改显示文本。
10.4 四列色板宫格
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(MOCK_STYLES, (s: StyleData) => {
Column() {
Stack() {
Column()
.width('100%')
.height(64)
.borderRadius(12)
.linearGradient({
angle: 130,
colors: [[s.color, 0.0], [s.color2, 1.0]]
})
Text(s.emoji)
.fontSize(18)
.position({ x: '70%', y: 6 })
}
.width('100%')
Text(s.name)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#4A3A50')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
Row() {
Text(s.tag)
.fontSize(9)
.fontColor('#C77DA6')
.backgroundColor('#FBEAF2')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
Text(s.heat)
.fontSize(9)
.fontColor('#E08A3C')
.margin({ left: 4 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 4 })
}
.width('23.5%')
.alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(7)
.margin({ top: 6 })
.shadow({ radius: 6, color: 'rgba(224,84,122,0.08)', offsetY: 2 })
.onClick(() => {
this.editTarget = s.name + ' · 待选美甲师'
this.editOpen = true
})
}, (s: StyleData) => s.id.toString())
}
.width('100%')
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
这是款式图鉴页面的核心——四列色板宫格。外层使用 Flex 组件设置 direction: FlexDirection.Row(水平排列)、wrap: FlexWrap.Wrap(允许换行)和 justifyContent: FlexAlign.SpaceBetween(两端对齐)。FlexWrap.Wrap 是实现网格布局的关键属性,它允许子元素在一行排满后自动换行。
每个款式卡片设置 width('23.5%'),略小于 25%(100/4),留出的 1.5% 空间通过 SpaceBetween 分布在卡片之间,形成间距。卡片内部从上到下依次是色板色块、款式名称、标签和热度。色板色块使用 Stack 叠加了一个 linearGradient 渐变背景 Column 和一个 position 定位的 emoji,emoji 位于色块右上角(x:70%, y:6),不遮挡渐变色的主体展示。
卡片的 shadow 属性设置了 radius: 6, color: 'rgba(224,84,122,0.08)', offsetY: 2,这是一个粉色系的柔和投影。所有卡片使用相同的阴影参数,确保了视觉一致性。卡片的 onClick 事件将款式名拼接为 editTarget 文本并打开编辑弹框,实现了从款式浏览到预约发起的转化流程。ForEach 的键值使用 s.id.toString(),确保每个款式卡片的唯一标识。
Flex 组件的 SpaceBetween 对齐方式使得每行的四个卡片均匀分布,最后一行如果不满四个,卡片会左对齐排列(因为 SpaceBetween 只在行内分配空间)。这种布局效果在数据量恰好是列数整数倍时最佳——20 条数据恰好 5 行,每行 4 个,排列整齐美观。
十一、页面二:预约美甲师(大卡列表)
@Builder pageArtists() {
Column() {
Row() {
Text('💅 在线美甲师')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('共 96 位 · 今日可约 38 位')
.fontSize(11)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
ForEach(MOCK_ARTISTS, (a: ArtistData) => {
Column() {
Row() {
// 头像 + 认证标
Stack() {
Column() {
Text(a.avatar)
.fontSize(26)
}
.width(56)
.height(56)
.borderRadius(28)
.linearGradient({
angle: 130,
colors: [['#FFD9E6', 0.0], ['#E3D4F6', 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Text('✔')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor('#2FA36B')
.borderRadius(8)
.padding(2)
.position({ x: 38, y: 38 })
}
.width(56)
.height(56)
预约美甲师页面使用 ForEach 遍历 MOCK_ARTISTS 数组,为每个美甲师生成一张大卡片。每张卡片的左侧是 56x56 的圆形头像区域,使用 Stack 叠加了一个渐变背景 Column(#FFD9E6 -> #E3D4F6)和一个绿色认证勾标。认证勾标通过 position({ x: 38, y: 38 }) 定位在头像的右下角,使用绿色背景 #2FA36B 配白色勾号,传达"已认证"的可信标识。
头像区域的设计巧妙地解决了无图片资源的问题。使用 emoji 作为头像内容,配合从粉到紫的渐变背景,每个美甲师的头像都有统一的视觉风格但内容不同(不同的 emoji)。认证勾标的位置 x: 38, y: 38 精确地放在 56x56 头像的右下角(56-18=38),18 是勾标的大致尺寸。
11.1 美甲师信息中部区域
Column() {
Row() {
Text(a.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Text(a.status)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(statusBadgeColor(a.status))
.backgroundColor(statusBadgeBg(a.status))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 6 })
Column()
.layoutWeight(1)
Text(ratingStars(a.rating))
.fontSize(10)
.fontColor('#F2A93B')
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
ForEach(a.skills, (sk: string) => {
Text('#' + sk)
.fontSize(10)
.fontColor('#C77DA6')
.backgroundColor('#FBEAF2')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 3, bottom: 3 })
.margin({ right: 6 })
}, (sk: string) => a.id.toString() + sk)
}
.margin({ top: 6 })
Row() {
Text(`粉丝 ${a.fans}`)
.fontSize(10)
.fontColor('#B09BB8')
Text('·').fontSize(10).fontColor('#D8CDDD').margin({ left: 5, right: 5 })
Text(`案例 ${a.cases} 例`)
.fontSize(10)
.fontColor('#B09BB8')
Text('·').fontSize(10).fontColor('#D8CDDD').margin({ left: 5, right: 5 })
Text(a.shop)
.fontSize(10)
.fontColor('#B09BB8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.margin({ top: 6 })
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
美甲师卡片的中部信息区使用 layoutWeight(1) 弹性占据中间空间,内部纵向排列三行信息。第一行是美甲师名称、状态徽章和星级评分。状态徽章的颜色通过 statusBadgeColor(a.status) 和 statusBadgeBg(a.status) 两个纯函数动态计算,实现了状态到颜色的映射。星级评分通过 ratingStars(a.rating) 函数将数值映射为星号字符串。第一行使用 Column().layoutWeight(1) 在状态徽章和星级之间创建弹性间隔,将星级推到行尾。
第二行使用内层 ForEach 遍历 a.skills 数组,为每个技能标签创建一个带 # 前缀的胶囊标签。键值生成器使用 a.id.toString() + sk 组合美甲师 ID 和技能名作为唯一键,确保不同美甲师的同名技能不会冲突。第三行展示粉丝数、案例数和所属店铺,三项之间用点号分隔,店铺名使用 maxLines(1) 和 textOverflow 防止文本溢出。
11.2 价格与预约按钮
Column() {
Text('¥' + a.price)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0547A')
Text('起/次')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ top: 1 })
Text('约TA')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#E0547A')
.borderRadius(13)
.padding({ left: 14, right: 14, top: 5, bottom: 5 })
.margin({ top: 8 })
.onClick(() => {
this.editTarget = a.name + ' · 档期预约'
this.editOpen = true
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding(14)
.margin({ top: 10 })
.shadow({ radius: 6, color: 'rgba(139,111,216,0.08)', offsetY: 2 })
}, (a: ArtistData) => a.id.toString())
Text('— 到底啦,下拉刷新看看有没有新老师 —')
.fontSize(10)
.fontColor('#C9BBCF')
.margin({ top: 16, bottom: 20 })
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
卡片右侧是价格和预约按钮。价格使用 17 号加粗粉色字体突出展示,下方是 9 号灰色的"起/次"单位说明。预约按钮"约TA"使用粉色背景配白色文字,圆角 13,通过 onClick 将美甲师名拼接为 editTarget 并打开编辑弹框。卡片整体使用白色背景、16 的圆角和紫色系柔和投影 rgba(139,111,216,0.08),与款式卡片的粉色投影形成微妙的色调区分。
列表底部有一条"到底啦"的提示文案,使用 10 号灰色字体居中展示。这种"到底提示"是列表页面的常见设计,传达了"列表已加载完毕"的信息,避免用户误以为还有更多内容未加载。文案的语气亲切自然(“下拉刷新看看有没有新老师”),符合社区应用的调性。
十二、页面三:探店(店铺左图右文)
12.1 店铺卡片左侧图块
@Builder pageShops() {
Column() {
Row() {
Text('🏪 广州美甲探店榜')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('按评分排序')
.fontSize(11)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
ForEach(MOCK_SHOPS, (sh: ShopData) => {
Row() {
Stack() {
Column() {
Text(sh.emoji)
.fontSize(30)
Text('NO.' + sh.id)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('rgba(0,0,0,0.25)')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 4 })
}
.width(104)
.height(104)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [['#FFD9E6', 0.0], ['#DCD3F5', 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width(104)
.height(104)
探店页面使用左图右文的布局展示店铺信息。左侧图块是 104x104 的正方形区域,使用 Stack 叠加了渐变背景 Column 和内容。内容从上到下是店铺 emoji(30 号)和排名标签"NO.X"(9 号白色文字配半透明黑色背景)。排名标签的设计传达了"探店榜"的排名语义,半透明黑色背景确保了白色文字在渐变色上的可读性。
图块的渐变背景使用 135 度角度从 #FFD9E6 到 #DCD3F5(粉到浅紫),与美甲师头像的渐变配色一致,保持了视觉统一。justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 确保内容居中显示。
12.2 店铺信息右侧文字区
Column() {
Text(sh.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(ratingStars(sh.rating))
.fontSize(11)
.fontColor('#F2A93B')
Text(sh.rating.toFixed(1))
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#E08A3C')
.margin({ left: 4 })
Text(sh.comments)
.fontSize(10)
.fontColor('#B09BB8')
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 4 })
Row() {
Text('📍')
.fontSize(10)
Text(sh.area)
.fontSize(10)
.fontColor('#8A7A90')
.margin({ left: 2 })
.maxLines(1)
}
.margin({ top: 4 })
.alignItems(VerticalAlign.Center)
右侧文字区从上到下展示店铺名、评分行和地址行。评分行使用 ratingStars 函数生成星级,配合 toFixed(1) 将评分数值格式化为一位小数,再加上评价数量。toFixed(1) 是 JavaScript/TypeScript 的 Number 方法,ArkTS 完整支持。地址行使用定位 emoji 前缀,配合 maxLines(1) 确保地址文本不换行。
12.3 人均价格进度条
Column() {
Row() {
Text('人均')
.fontSize(10)
.fontColor('#B09BB8')
Text('¥' + sh.avgPrice)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#E0547A')
.margin({ left: 4 })
Column()
.layoutWeight(1)
Text((sh.avgPrice > 110) ? '轻奢档' : (sh.avgPrice > 85 ? '中档' : '平价档'))
.fontSize(9)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Column()
.width(pricePct(sh.avgPrice))
.height(5)
.borderRadius(3)
.linearGradient({
angle: 0,
colors: [['#FF9EC7', 0.0], ['#B89AE6', 1.0]]
})
Column()
.layoutWeight(1)
.height(5)
.borderRadius(3)
.backgroundColor('#F1E9F2')
}
.width('100%')
.margin({ top: 5 })
.alignItems(VerticalAlign.Center)
}
.width('100%')
.margin({ top: 6 })
人均价格区域是探店卡片的设计亮点。上方行展示"人均"标签、价格数值和档位标签,档位通过嵌套三元表达式 (sh.avgPrice > 110) ? '轻奢档' : (sh.avgPrice > 85 ? '中档' : '平价档') 动态计算。下方是进度条,使用双 Column 模拟:前一个 Column 的宽度通过 pricePct(sh.avgPrice) 函数计算百分比,后一个 Column 使用 layoutWeight(1) 填充剩余空间。两个 Column 都设置 height(5) 和 borderRadius(3),前一个使用粉紫渐变背景,后一个使用浅灰色背景,组合成一个进度条。
这种双 Column 模拟进度条的技巧在 ArkTS 中很常用。相比于系统 Progress 组件,双 Column 方式的优势在于可以完全自定义进度条的颜色渐变、高度和圆角。pricePct 函数将价格映射为百分比,angle: 0 的渐变方向使得进度条从左到右呈现粉到紫的色彩过渡,与应用整体配色统一。
12.4 标签胶囊与卡片容器
Row() {
ForEach(sh.tags, (tg: string) => {
Text(tg)
.fontSize(9)
.fontColor('#8B6FD8')
.backgroundColor('#F0ECFB')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ right: 6 })
}, (tg: string) => sh.id.toString() + tg)
}
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding(12)
.margin({ top: 10 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 6, color: 'rgba(224,84,122,0.08)', offsetY: 2 })
.onClick(() => {
this.bizShop = sh.name
this.bizOpen = true
})
}, (sh: ShopData) => sh.id.toString())
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
右侧文字区底部使用内层 ForEach 遍历 sh.tags 数组,为每个标签创建紫色系胶囊(#8B6FD8 文字配 #F0ECFB 背景)。键值使用 sh.id.toString() + tg 组合店铺 ID 和标签名。整个右侧 Column 使用 layoutWeight(1) 弹性占据图块右侧的剩余空间,margin({ left: 12 }) 与左侧图块保持间距。
卡片整体的 onClick 将店铺名赋值给 bizShop 状态并打开到店排队弹框,实现了从探店浏览到排队取号的转化流程。卡片的阴影参数使用 rgba(224,84,122,0.08) 的粉色系投影,与款式卡片一致,保持了卡片投影的统一视觉风格。
十三、页面四:时段表(一周时段预约表)
13.1 星期选择条与图例
@Builder pageSlots() {
Column() {
Row() {
Text('🗓️ 一周时段余量')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('绿色=充足 · 橙色=紧张 · 粉色=少量')
.fontSize(9)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
Scroll() {
Row() {
ForEach(WEEK_DAYS, (w: WeekDayItem, i: number) => {
Column() {
Text(w.label)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(this.slotDay === i ? '#FFFFFF' : (w.rest ? '#E0547A' : '#8A7A90'))
Text(w.date)
.fontSize(9)
.fontColor(this.slotDay === i ? 'rgba(255,255,255,0.85)' : '#B09BB8')
.margin({ top: 2 })
}
.borderRadius(12)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ left: 4, right: 4 })
.backgroundColor(this.slotDay === i ? '#E0547A' : '#FFFFFF')
.onClick(() => { this.slotDay = i })
}, (w: WeekDayItem) => w.label)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
时段表页面的顶部是标题行和星期选择条。星期选择条使用水平 Scroll 包裹 ForEach 生成的七个日期项,每个项内部展示星期标签和日期数字。选中态通过 this.slotDay === i 判断:选中时使用粉色背景配白色文字,未选中时使用白色背景,其中休息日(w.rest === true)的星期标签使用粉色文字 #E0547A 强调,工作日使用灰色文字 #8A7A90。
这种多层条件渲染的设计——选中态优先于休息日判断——确保了选中态的视觉优先级最高。即使选中了休息日,也会显示粉色背景白色文字而非粉色文字,避免了颜色冲突。onClick 将 slotDay 设为对应索引,触发条件分支重新渲染,更新选中态。
13.2 颜色图例行
Row() {
Row() {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor('#E1F5EB')
Text('充足(6+)')
.fontSize(9)
.fontColor('#8A7A90')
.margin({ left: 4 })
}
.alignItems(VerticalAlign.Center)
.margin({ right: 10 })
Row() {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor('#FFF1DE')
Text('紧张(3-5)')
.fontSize(9)
.fontColor('#8A7A90')
.margin({ left: 4 })
}
.alignItems(VerticalAlign.Center)
.margin({ right: 10 })
Row() {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor('#FFE3EA')
Text('少量(1-2)')
.fontSize(9)
.fontColor('#8A7A90')
.margin({ left: 4 })
}
.alignItems(VerticalAlign.Center)
.margin({ right: 10 })
Row() {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor('#F2F0F3')
Text('约满(0)')
.fontSize(9)
.fontColor('#8A7A90')
.margin({ left: 4 })
}
.alignItems(VerticalAlign.Center)
}
.width('100%')
.margin({ top: 12 })
图例行展示了四种颜色对应的余量区间:绿色"充足(6+)“、橙色"紧张(3-5)”、粉色"少量(1-2)“、灰色"约满(0)”。每个图例项由一个 10x10 的圆点(borderRadius(5))和说明文字组成。图例的颜色值与 slotBg 函数的返回值完全一致,确保了图例与表格格子的颜色对应关系准确无误。
图例的作用是帮助用户理解表格中颜色的语义。在没有图例的情况下,用户需要通过格子内的文字(“余 N 位"或"约满”)来判断余量,有了图例后,用户可以通过颜色快速扫描整个表格找到可用时段,提升了信息获取效率。这种"颜色编码 + 图例说明"的设计是数据可视化领域的最佳实践。
13.3 时段表格主体
Column() {
// 表头行
Row() {
Text('店铺')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('30%')
Text('上午')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.layoutWeight(1)
.textAlign(TextAlign.Center)
Text('下午')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.layoutWeight(1)
.textAlign(TextAlign.Center)
Text('晚上')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.layoutWeight(1)
.textAlign(TextAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.backgroundColor('#C79BC8')
.borderRadius({ topLeft: 14, topRight: 14 })
// 数据行
ForEach(MOCK_SCHEDULES, (sc: ShopScheduleData) => {
Row() {
Column() {
Text(sc.shop)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#4A3A50')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(sc.area)
.fontSize(8)
.fontColor('#B09BB8')
.margin({ top: 2 })
}
.width('30%')
.alignItems(HorizontalAlign.Start)
ForEach(sc.cells, (c: SlotCell) => {
Column() {
Text(c.period)
.fontSize(8)
.fontColor('#B09BB8')
Text(slotLabel(c.remain))
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(slotText(c.remain))
.margin({ top: 2 })
Text(`/${c.total}位`)
.fontSize(8)
.fontColor('#D0C4D6')
.margin({ top: 1 })
}
.layoutWeight(1)
.borderRadius(10)
.backgroundColor(slotBg(c.remain))
.padding({ top: 6, bottom: 6 })
.margin({ left: 4, right: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.editTarget = sc.shop + ' · ' + c.period
this.editOpen = true
})
}, (c: SlotCell) => sc.shop + c.period)
}
.width('100%')
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
.backgroundColor('#FFFFFF')
Divider()
.color('#F5EFF6')
.margin({ left: 8, right: 8 })
}, (sc: ShopScheduleData) => sc.shop)
时段表格的主体由表头行和数据行组成。表头行使用紫色背景 #C79BC8 配白色文字,只有顶部圆角 borderRadius({ topLeft: 14, topRight: 14 }),与表格底部的底部圆角配合形成完整的圆角表格。表头包含四列:店铺(固定宽度 30%)、上午、下午、晚上(各 layoutWeight(1) 等分剩余空间),后三列使用 textAlign(TextAlign.Center) 居中对齐。
数据行通过外层 ForEach 遍历 MOCK_SCHEDULES 数组生成,每行包含店铺信息和三个时段格子。时段格子通过内层 ForEach 遍历 sc.cells 数组生成。每个格子内部展示时段名称(8号灰色)、余量标签(11号加粗,颜色由 slotText 函数计算)和总位数(8号浅灰色)。格子背景由 slotBg 函数根据 remain 值动态计算。每行之间的分隔使用 Divider 组件,颜色为浅紫 #F5EFF6。
格子的 onClick 将店铺名和时段拼接为 editTarget 并打开编辑弹框,实现了从时段表到预约改期的转化。键值生成器使用 sc.shop + c.period 组合店铺名和时段名,确保每个格子的唯一标识。这种嵌套 ForEach 的设计是表格渲染的标准模式——外层遍历行,内层遍历列。
13.4 热门时段提醒卡
Row() {
Text('ℹ️')
.fontSize(12)
Text('余量实时变动,点击格子直接跳转预约改期;晚上时段部分店铺营业至24点')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ left: 6 })
}
.width('100%')
.padding(10)
.backgroundColor('#FDF8FE')
.borderRadius({ bottomLeft: 14, bottomRight: 14 })
.alignItems(VerticalAlign.Center)
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.margin({ top: 12 })
.shadow({ radius: 6, color: 'rgba(139,111,216,0.08)', offsetY: 2 })
Column() {
Text('⚡ 热门时段提醒')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#8B6FD8')
Text('周六下午为全城高峰,建议提前3天锁定;工作日上午人少、出片率高,还能享受早鸟价。')
.fontSize(10)
.fontColor('#8A7A90')
.margin({ top: 6 })
.lineHeight(16)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.backgroundColor('#F0ECFB')
.borderRadius(12)
.padding(12)
.margin({ top: 14, bottom: 20 })
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
表格底部汇总行使用浅紫色背景 #FDF8FE 配底部圆角,展示使用说明文本。时段表底部有一张热门时段提醒卡,使用浅紫色背景 #F0ECFB 配紫色标题 #8B6FD8。提醒文本使用 lineHeight(16) 控制行间距,确保多行文本的可读性。提醒内容提供了实用的预约建议——“周六下午为全城高峰,建议提前3天锁定”,这种基于数据的运营建议提升了用户体验,帮助用户做出更好的预约决策。
十四、页面五:色彩趋势(流行色横滑 + 文章卡)
14.1 流行色横滑卡片
@Builder pageTrends() {
Column() {
Row() {
Text('🌈 本季流行色')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('左滑查看全部 8 色')
.fontSize(10)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
Scroll() {
Row() {
ForEach(MOCK_TRENDS, (t: TrendData) => {
Column() {
Column() {
Text(t.code)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('rgba(255,255,255,0.95)')
Text(t.hex)
.fontSize(8)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 2 })
Column()
.layoutWeight(1)
Text(t.rise)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.3)')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
.width(140)
.height(190)
.borderRadius(18)
.linearGradient({
angle: 150,
colors: [[t.hex, 0.0], [t.hex2, 1.0]]
})
.alignItems(HorizontalAlign.Start)
.padding(12)
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
.margin({ top: 8 })
Text(t.desc)
.fontSize(9)
.fontColor('#B09BB8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
}
.width(140)
.alignItems(HorizontalAlign.Start)
.margin({ right: 12 })
}, (t: TrendData) => t.id.toString())
}
.padding({ left: 2, right: 2 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
色彩趋势页面的顶部是流行色横滑区域。使用水平 Scroll 包裹 ForEach 生成的八张色卡。每张色卡宽度 140vp,内部包含一个 190vp 高的大色块、色彩名称和一句话描述。大色块使用 linearGradient 渲染从 t.hex 到 t.hex2 的渐变背景,角度 150 度使得色彩从左上向右下过渡。
色块内部从上到下展示 PANTONE 色号、十六进制色值、弹性间隔和涨幅标签。涨幅标签使用半透明白色背景配白色文字,圆角 10。色块内部使用 Column().layoutWeight(1) 在色号信息和涨幅标签之间创建弹性间隔,将涨幅推到色块底部。色块下方的色彩名称使用 13 号加粗深色字体,描述文本使用 9 号灰色字体配 maxLines(1) 和省略号截断。
色卡的设计将色彩信息以视觉化的方式呈现——用户不仅看到色号文字,更直观地看到色彩渐变效果。PANTONE 色号的引用增加了专业性,涨幅标签传达了趋势热度。八张色卡横向排列总宽度约 1216vp(1408 + 127),需要滑动 2-3 次浏览完毕,配合隐藏滚动条的设计,呈现出流畅的横滑浏览体验。
14.2 趋势文章卡片
Row() {
Text('📖 趋势编辑部精选')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('查看全部')
.fontSize(11)
.fontColor('#E0547A')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 18, bottom: 10 })
ForEach(MOCK_ARTICLES, (ar: ArticleData) => {
Row() {
Column() {
Text(ar.cover)
.fontSize(28)
Text(ar.tag)
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.35)')
.borderRadius(7)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 4 })
}
.width(90)
.height(90)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [[ar.c1, 0.0], [ar.c2, 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text(ar.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(18)
Text(ar.summary)
.fontSize(10)
.fontColor('#9C8CA4')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(14)
.margin({ top: 4 })
Row() {
Text('@' + ar.author)
.fontSize(9)
.fontColor('#B09BB8')
Column()
.layoutWeight(1)
Text('👍 ' + ar.likes)
.fontSize(9)
.fontColor('#E0547A')
Text(ar.days)
.fontSize(9)
.fontColor('#C9BBCF')
.margin({ left: 8 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding(12)
.margin({ top: 10 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 6, color: 'rgba(224,84,122,0.08)', offsetY: 2 })
}, (ar: ArticleData) => ar.id.toString())
Column()
.height(20)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
趋势文章使用左封面右文字的布局,与探店卡片的布局模式一致但尺寸不同。封面图块是 90x90 的正方形,使用 linearGradient 渲染从 ar.c1 到 ar.c2 的渐变背景,内部居中展示封面 emoji 和分类标签。右侧文字区使用 layoutWeight(1) 弹性占据剩余空间,内部从上到下展示标题(最多2行)、摘要(最多2行)和底部信息行。
标题和摘要都使用 maxLines(2) 配合 textOverflow({ overflow: TextOverflow.Ellipsis }) 限制为两行,超出部分显示省略号。lineHeight 属性分别设置为 18 和 14,确保多行文本的行间距舒适。底部信息行展示作者、点赞数和发布时间,三者通过 Column().layoutWeight(1) 实现左右分布。
文章卡片的设计模拟了小红书的内容流布局——左封面右文字是内容类应用最常用的卡片布局之一,适合在有限宽度内展示足够的信息。封面渐变色与文章主题相关,如趋势预测文章用粉色调,猫眼干货用蓝色调,帮助用户通过色彩快速识别内容类型。
十五、页面六:护理知识(图文问答 + 小贴士标签云)
15.1 护理问答卡片
@Builder pageCare() {
Column() {
Row() {
Text('🫧 甲面护理指南')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('美甲师审核版')
.fontSize(10)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ bottom: 10 })
ForEach(MOCK_QAS, (qa: CareQAData) => {
Column() {
Row() {
Column() {
Text(qa.icon)
.fontSize(18)
}
.width(38)
.height(38)
.borderRadius(19)
.backgroundColor('#FBEAF2')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text(qa.q)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(qa.views + ' 次浏览 · 美甲师已认证')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('Q')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E8B4CF')
}
.width('100%')
.alignItems(VerticalAlign.Center)
Divider()
.color('#F7EFF4')
.margin({ top: 10, bottom: 10 })
Text(qa.a)
.fontSize(11)
.fontColor('#7A6A80')
.lineHeight(17)
Row() {
Text('阅读全文')
.fontSize(10)
.fontColor('#E0547A')
Text('›')
.fontSize(12)
.fontColor('#E0547A')
.margin({ left: 2 })
Column()
.layoutWeight(1)
Text('有用 👍')
.fontSize(10)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 8 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding(14)
.margin({ top: 10 })
.shadow({ radius: 6, color: 'rgba(139,111,216,0.08)', offsetY: 2 })
}, (qa: CareQAData) => qa.id.toString())
护理问答卡片使用三段式布局:顶部问题行、中间分隔线、底部回答和操作行。顶部问题行由左侧圆形图标(38x38,borderRadius(19) 形成正圆)、中间问题文本和浏览量、右侧"Q"字母组成。圆形图标使用浅粉背景 #FBEAF2 居中展示问题分类 emoji,右侧的"Q"字母使用浅粉色 #E8B4CF 作为装饰性标识。
中间的 Divider 组件创建一条浅粉色的分隔线,将问题行和回答文本视觉分离。回答文本使用 11 号灰色字体 #7A6A80,lineHeight(17) 确保行间距舒适。底部操作行展示"阅读全文"链接(配右尖括号">")和"有用 点赞"按钮,通过 Column().layoutWeight(1) 实现左右分布。
15.2 护理小贴士标签云
Row() {
Text('🔖 护理小贴士')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('长按可收藏')
.fontSize(10)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 18, bottom: 10 })
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(MOCK_TIPS, (tp: TipItem, i: number) => {
Row() {
Text(tp.icon)
.fontSize(11)
Text(tp.label)
.fontSize(10)
.fontColor(tipColor(i))
.margin({ left: 4 })
}
.backgroundColor(tipBg(i))
.borderRadius(13)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (tp: TipItem) => tp.label)
}
.width('100%')
Column()
.height(20)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
小贴士标签云使用 Flex 的 Wrap 换行模式排列 17 个标签。每个标签的背景色和文字色分别通过 tipBg(i) 和 tipColor(i) 函数计算,索引 i 取模 6 实现六色循环。标签内部由 emoji 图标和文字组成,圆角 13,左右间距 8、底部间距 8。
Flex 的 Wrap 模式与 SpaceBetween 不同——Wrap 模式下标签按自然顺序排列,换行时不拉伸间距,适合标签云的紧凑展示。17 个标签在屏幕宽度内自动换行排列,配合六色循环的背景和文字色,呈现出色彩斑斓的标签云效果。每个标签的内容简洁明了(如"做家务戴手套"、“补充生物素”),提供了一份实用的指甲护理清单。
十六、页面七:我的收藏(两列网格 + 预约时间线)
16.1 个人卡片
@Builder pageFav() {
Column() {
Row() {
Column() {
Text('🐰')
.fontSize(28)
}
.width(60)
.height(60)
.borderRadius(30)
.linearGradient({
angle: 130,
colors: [['#FFD9E6', 0.0], ['#D9CDF5', 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text('奶油小兔纸')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Text('Lv.6 甲油胶收藏家 · 加入星球 428 天')
.fontSize(10)
.fontColor('#B09BB8')
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
Column() {
Text('32')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E0547A')
Text('收藏')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Center)
Column() {
Text('18')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#8B6FD8')
Text('预约')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Center)
.margin({ left: 16 })
Column() {
Text('9')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E08A3C')
Text('关注')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Center)
.margin({ left: 16 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding(14)
.alignItems(VerticalAlign.Center)
.shadow({ radius: 6, color: 'rgba(224,84,122,0.08)', offsetY: 2 })
个人卡片是"我的收藏"页面的顶部区域,展示用户头像、昵称和三个统计数字。头像是 60x60 的圆形区域(borderRadius(30)),使用粉紫渐变背景配居中的兔子 emoji。昵称"奶油小兔纸"使用 15 号加粗字体,下方是等级和加入天数信息。右侧三个统计数字(收藏32、预约18、关注9)使用不同的颜色(粉色、紫色、橙色)区分,每个数字下方有 9 号灰色标签说明。
个人卡片的设计模仿了小红书的个人主页头部——头像、昵称、统计数字的三段式布局。三个统计数字使用不同颜色是精心的设计选择:收藏用粉色(与品牌色一致)、预约用紫色(与预约相关组件一致)、关注用橙色(与社交互动相关),这种颜色编码帮助用户快速区分不同统计项的含义。
16.2 收藏款式两列网格
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(MOCK_FAVS, (f: FavData) => {
Column() {
Stack() {
Column()
.width('100%')
.height(78)
.borderRadius(12)
.linearGradient({
angle: 130,
colors: [[f.color, 0.0], [f.color2, 1.0]]
})
Text('❤')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('rgba(0,0,0,0.18)')
.borderRadius(11)
.padding(4)
.position({ x: '78%', y: 6 })
.onClick(() => {
this.delTarget = f.name
this.delOpen = true
})
}
.width('100%')
Text(f.name)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#4A3A50')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
Row() {
Text(f.artist)
.fontSize(9)
.fontColor('#B09BB8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Column()
.layoutWeight(1)
Text(f.heart)
.fontSize(9)
.fontColor('#E0547A')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 3 })
}
.width('48.5%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(8)
.margin({ top: 8 })
.alignItems(HorizontalAlign.Start)
.shadow({ radius: 6, color: 'rgba(139,111,216,0.08)', offsetY: 2 })
}, (f: FavData) => f.id.toString())
}
.width('100%')
收藏款式使用 Flex 的 Wrap + SpaceBetween 模式实现两列网格。每张卡片宽度 48.5%,略小于 50%,留出的 3% 空间通过 SpaceBetween 分布为列间间距。卡片内部包含色板色块、款式名称和底部信息行。色板色块使用 Stack 叠加渐变背景 Column 和一个右上角的心形按钮,心形按钮使用半透明黑色背景配白色心形,点击后打开卸甲警示弹框。
色块高度 78vp(比款式图鉴的 64vp 更高),因为收藏卡片宽度更大(48.5% vs 23.5%),需要更大的色块面积保持比例协调。底部信息行展示美甲师名和收藏热度,通过 Column().layoutWeight(1) 实现左右分布。收藏卡片的阴影使用紫色系 rgba(139,111,216,0.08),与预约美甲师卡片一致,形成了"内容相关卡片使用紫色阴影"的隐含规则。
16.3 预约记录时间线
Row() {
Text('🕒 预约记录')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Column()
.layoutWeight(1)
Text('共 7 条')
.fontSize(11)
.fontColor('#B09BB8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 18, bottom: 10 })
Column() {
ForEach(MOCK_RECORDS, (r: RecordData) => {
Row() {
Column() {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor(recordStatusColor(r.status))
Column()
.width(2)
.height(64)
.backgroundColor('#F3DCF0')
.margin({ top: 4 })
}
.width(14)
.alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(r.date + ' ' + r.time)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#8A7A90')
Column()
.layoutWeight(1)
Text(r.status)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(recordStatusColor(r.status))
.backgroundColor(recordStatusBg(r.status))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(r.styleName)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#4A3A50')
.margin({ top: 5 })
Row() {
Text('👤 ' + r.artist)
.fontSize(9)
.fontColor('#B09BB8')
Text('·')
.fontSize(9)
.fontColor('#D8CDDD')
.margin({ left: 4, right: 4 })
Text('🏪 ' + r.shop)
.fontSize(9)
.fontColor('#B09BB8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 4 })
Row() {
Text('¥' + r.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#E0547A')
Column()
.layoutWeight(1)
if (r.status === '待到店') {
Text('改期')
.fontSize(10)
.fontColor('#8B6FD8')
.backgroundColor('#F0ECFB')
.borderRadius(11)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick(() => {
this.editTarget = r.styleName + ' · ' + r.artist
this.editOpen = true
})
Text('取消预约')
.fontSize(10)
.fontColor('#E0547A')
.backgroundColor('#FFE3EA')
.borderRadius(11)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.margin({ left: 8 })
.onClick(() => {
this.delTarget = r.styleName
this.delOpen = true
})
} else {
Text('再来一单')
.fontSize(10)
.fontColor('#C77DA6')
.backgroundColor('#FBEAF2')
.borderRadius(11)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick(() => {
this.editTarget = r.styleName + ' · 再来一单'
this.editOpen = true
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
}
.layoutWeight(1)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(12)
.margin({ left: 10 })
.shadow({ radius: 4, color: 'rgba(139,111,216,0.08)', offsetY: 2 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}, (r: RecordData) => r.id.toString())
}
.width('100%')
Column()
.height(20)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })
.alignItems(HorizontalAlign.Start)
}
预约记录时间线是"我的收藏"页面的核心组件。每条记录由左侧的时间线轴和右侧的记录卡片组成。时间线轴是一个 14vp 宽的 Column,内部包含一个 10x10 的圆点(borderRadius(5) 形成正圆)和一根 2x64 的竖线。圆点的颜色通过 recordStatusColor(r.status) 函数动态计算,不同状态的记录有不同的节点颜色。竖线颜色为浅紫 #F3DCF0,连接相邻的节点形成时间线。
记录卡片内部从上到下展示日期时间行、款式名称、美甲师和店铺信息行、价格和操作按钮行。操作按钮行使用 if-else 条件渲染:当状态为"待到店"时显示"改期"和"取消预约"两个按钮,其他状态显示"再来一单"按钮。这种基于状态的条件渲染是声明式 UI 的经典应用——同一个位置根据数据状态呈现不同的操作选项,无需手动切换 DOM。
"改期"按钮使用紫色系(#8B6FD8 文字配 #F0ECFB 背景),"取消预约"使用粉色系(#E0547A 文字配 #FFE3EA 背景),"再来一单"使用浅粉色系(#C77DA6 文字配 #FBEAF2 背景)。三个按钮的颜色设计传达了操作的性质:改期是中性操作(紫色),取消是警示操作(粉色),再来一单是积极操作(浅粉色)。每个按钮的 onClick 都设置了相应的状态变量并打开对应的弹框,实现了从记录列表到弹框的交互流程。
十七、弹框遮罩与模态系统
@Builder modalOverlay() {
Column() {
if (this.addOpen) {
this.modalBodyAdd()
} else if (this.editOpen) {
this.modalBodyEdit()
} else if (this.delOpen) {
this.modalBodyDel()
} else if (this.bizOpen) {
this.modalBodyBiz()
}
}
.width('100%')
.height('100%')
.backgroundColor('rgba(52,32,64,0.55)')
.justifyContent(FlexAlign.Center)
.onClick(() => { this.closeAllModals() })
}
modalOverlay 是弹框系统的统一遮罩层。它使用一个全屏 Column 作为遮罩,背景色为 rgba(52,32,64,0.55) 的半透明深紫色,justifyContent(FlexAlign.Center) 使弹框内容居中显示。内部使用 if-else if 链根据四个弹框状态变量的值选择渲染对应的弹框 Builder。
这种"单遮罩 + 条件分支"的弹框管理方式是 ArkTS 中实现自定义弹框的常用模式。相比于系统的 CustomDialog 或 bindSheet,这种方式的优势在于完全的视觉自定义能力——遮罩颜色、弹框尺寸、内容布局、动画效果都可以自由控制。onClick(() => { this.closeAllModals() }) 绑定在遮罩层上,实现了"点击遮罩关闭弹框"的标准交互——但子弹框内部会通过 onClick 拦截事件传播(设置 this.addOpen = true 等),防止点击弹框内部时意外关闭。
if-else if 链的设计确保了同一时刻只有一个弹框被渲染,这与"应用同时只允许一个弹框打开"的业务规则一致。当用户在预约改期弹框中操作时,如果点击了"取消预约"按钮,editOpen 会被设为 false、delOpen 被设为 true,条件链重新渲染时 modalBodyDel 替代 modalBodyEdit 被渲染,实现了弹框的切换效果。
十八、弹框一:款式 DIY 设计器
18.1 标题行与款式名称输入
@Builder modalBodyAdd() {
Column() {
Row() {
Column() {
Text('🎨 款式DIY设计器')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Text('自由搭配主色 / 风格 / 装饰件,生成专属款式')
.fontSize(10)
.fontColor('#B09BB8')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('✕').fontSize(16).fontColor('#B09BB8').padding(6)
.onClick(() => { this.addOpen = false })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 18, right: 18, top: 16 })
Divider()
.color('#F7EFF4')
.margin({ top: 12 })
Scroll() {
Column() {
Text('款式名称')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
.margin({ top: 12 })
TextInput({ placeholder: '给你的款式起个名字,如"银河碎钻冰美式"' })
.placeholderColor('#C9BBCF')
.fontSize(13)
.width('100%')
.height(42)
.backgroundColor('#F9F4FA')
.borderRadius(12)
.margin({ top: 6 })
.onChange((v: string) => { this.addName = v })
款式 DIY 设计器弹框的标题行由左侧的标题+副标题和右侧的关闭按钮组成。关闭按钮绑定 this.addOpen = false,点击后关闭弹框。标题下方使用 Divider 分隔标题区和内容区。
内容区使用 Scroll 包裹,配合 layoutWeight(1) 和 constraintSize({ maxHeight: '52%' }) 限制内容区最大高度为屏幕的 52%,超出部分可滚动。这种设计确保了弹框在内容较多时不会超出屏幕,保持底部按钮始终可见。
第一个表单项是款式名称输入框,使用 TextInput 组件。placeholder 属性提供了输入提示,placeholderColor 设置占位文本颜色,onChange 回调将输入值同步到 addName 状态变量。TextInput 是 ArkTS 的标准输入组件,onChange 是其值变化事件。
18.2 主色色块选择器
Row() {
Text('主色选择')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
Column()
.layoutWeight(1)
Text(PALETTES[this.addColorIdx].name)
.fontSize(10)
.fontColor('#E0547A')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Scroll() {
Row() {
ForEach(PALETTES, (p: ColorPalette, i: number) => {
Stack() {
Column()
.width(34)
.height(34)
.borderRadius(17)
.backgroundColor(p.hex)
if (this.addColorIdx === i) {
Text('✓')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
}
.width(34)
.height(34)
.margin({ right: 10 })
.onClick(() => { this.addColorIdx = i })
}, (p: ColorPalette) => p.name)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 8 })
主色选择器由标题行和横滑色块列表组成。标题行右侧通过 PALETTES[this.addColorIdx].name 动态显示当前选中的颜色名称。色块列表使用水平 Scroll 包裹 ForEach 生成的十个色块,每个色块是 34x34 的圆形(borderRadius(17)),背景色为 p.hex。选中态通过 if (this.addColorIdx === i) 条件渲染一个白色勾号叠加在色块上。
Stack 的使用使得勾号可以叠加在色块上方居中显示。onClick 将 addColorIdx 设为对应索引,触发条件分支重新渲染,选中的色块显示勾号,标题行的颜色名称同步更新。这种"单选+即时反馈"的交互模式是颜色选择器的标准设计。
18.3 风格标签多选
Row() {
Text('风格标签(可多选)')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
Column()
.layoutWeight(1)
Text(`已选 ${this.addStyleSel.length} 项`)
.fontSize(10)
.fontColor('#E0547A')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(ADD_STYLE_TAGS, (s: string) => {
if (this.addStyleSel.indexOf(s) >= 0) {
Text(s)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#E0547A')
.borderRadius(13)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.toggleAddStyle(s) })
} else {
Text(s)
.fontSize(11)
.fontColor('#8A7A90')
.backgroundColor('#F9F4FA')
.borderRadius(13)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.toggleAddStyle(s) })
}
}, (s: string) => s)
}
.width('100%')
.margin({ top: 8 })
风格标签多选使用 Flex 的 Wrap 模式排列八个标签。每个标签使用 if-else 条件渲染选中态和未选中态:选中的标签使用粉色背景配白色加粗文字,未选中的标签使用浅灰背景配灰色文字。两种状态的 onClick 都调用 this.toggleAddStyle(s) 方法,该方法通过 indexOf 检查标签是否在已选列表中,在则移除、不在则添加。
标题行右侧通过 `已选 ${this.addStyleSel.length} 项` 动态显示已选数量,这个数量会随着用户的点击操作实时更新。indexOf 和 splice/push 的组合是 JavaScript/TypeScript 中实现多选切换的标准模式,ArkTS 完整支持这些数组操作方法。
18.4 装饰件列表勾选
Row() {
Text('装饰件(勾选计入报价)')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
Column()
.layoutWeight(1)
Text(`已勾 ${this.getDecoCount()} 件`)
.fontSize(10)
.fontColor('#E0547A')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Column() {
ForEach(DECOS, (d: DecoItem, i: number) => {
Row() {
Stack() {
Column()
.width(20)
.height(20)
.borderRadius(10)
.backgroundColor(this.addDecoSel[i] ? '#E0547A' : '#F1E9F2')
if (this.addDecoSel[i]) {
Text('✓')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
}
.width(20)
.height(20)
.onClick(() => { this.toggleDeco(i) })
Text(d.icon)
.fontSize(15)
.margin({ left: 10 })
Text(d.name)
.fontSize(12)
.fontColor('#4A3A50')
.margin({ left: 6 })
.layoutWeight(1)
Text('¥' + d.price)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#E08A3C')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 9, bottom: 9 })
if (i < DECOS.length - 1) {
Divider()
.color('#F7EFF4')
}
}, (d: DecoItem) => d.name)
}
.width('100%')
.backgroundColor('#FDF9FE')
.borderRadius(12)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.margin({ top: 8 })
Column()
.height(10)
}
.width('100%')
.padding({ left: 18, right: 18 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.constraintSize({ maxHeight: '52%' })
装饰件列表使用 ForEach 遍历 DECOS 数组生成八行勾选项。每行由勾选圆、emoji 图标、名称和价格组成。勾选圆是 20x20 的圆形(borderRadius(10)),背景色通过三元表达式 this.addDecoSel[i] ? '#E0547A' : '#F1E9F2' 动态切换——选中时粉色,未选中时浅灰色。选中时通过条件渲染叠加一个白色勾号。
每行之间的分隔线通过 if (i < DECOS.length - 1) 条件渲染 Divider,确保最后一行下方没有多余的分隔线。这种"非末行显示分隔线"的模式是列表渲染的常用技巧。onClick 调用 this.toggleDeco(i) 方法,通过 this.addDecoSel[i] = !this.addDecoSel[i] 翻转布尔值实现勾选切换。
标题行右侧通过 this.getDecoCount() 方法动态显示"已勾 N 件",该方法遍历 addDecoSel 数组统计 true 的数量。由于 addDecoSel 是 @State 装饰的状态变量,其元素值变化会触发 getDecoCount() 的重新计算和已勾数量的更新。
18.5 底部按钮
Row() {
Text('重置')
.fontSize(13)
.fontColor('#8A7A90')
.backgroundColor('#F1E9F2')
.borderRadius(21)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.resetAddForm() })
Text('✨ 生成我的款式')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.borderRadius(21)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.linearGradient({
angle: 90,
colors: [['#FF7EB3', 0.0], ['#A78BFA', 1.0]]
})
.onClick(() => { this.addOpen = false })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 14, bottom: 16 })
}
.width('92%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor('#FFFFFF')
.borderRadius(20)
.alignItems(HorizontalAlign.Start)
.onClick(() => { this.addOpen = true })
}
弹框底部是"重置"和"生成我的款式"两个按钮。"重置"按钮使用灰色系,调用 this.resetAddForm() 方法恢复表单初始状态。"生成我的款式"按钮使用粉紫渐变背景(#FF7EB3 -> #A78BFA),调用 this.addOpen = false 关闭弹框。
弹框整体宽度 92%,constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%,borderRadius(20) 设置大圆角,backgroundColor('#FFFFFF') 白色背景。最外层的 onClick(() => { this.addOpen = true }) 是关键的事件拦截设计——它阻止了点击弹框内部时事件冒泡到遮罩层的 closeAllModals,确保用户在弹框内部操作时不会意外关闭弹框。
十九、弹框二:预约改期
19.1 原预约信息与日历周条
@Builder modalBodyEdit() {
Column() {
Row() {
Column() {
Text('📅 预约改期')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#4A3A50')
Text(this.editTarget)
.fontSize(10)
.fontColor('#B09BB8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('✕').fontSize(16).fontColor('#B09BB8').padding(6)
.onClick(() => { this.editOpen = false })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 18, right: 18, top: 16 })
Divider()
.color('#F7EFF4')
.margin({ top: 12 })
Scroll() {
Column() {
Row() {
Text('当前:')
.fontSize(10)
.fontColor('#B09BB8')
Text('08-24 (周一) 14:00 · 指尖宇宙美甲(天河店)')
.fontSize(10)
.fontColor('#8B6FD8')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.backgroundColor('#F0ECFB')
.borderRadius(10)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
.margin({ top: 12 })
Text('选择新日期')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
.margin({ top: 14 })
Row() {
ForEach(WEEK_DAYS, (w: WeekDayItem, i: number) => {
Column() {
Text(w.label)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor(this.editDay === i ? '#FFFFFF' : (w.rest ? '#E0547A' : '#8A7A90'))
Text(w.date)
.fontSize(9)
.fontColor(this.editDay === i ? 'rgba(255,255,255,0.85)' : '#B09BB8')
.margin({ top: 2 })
if (i === 1) {
Text('休')
.fontSize(7)
.fontColor('#E0547A')
.backgroundColor('#FFE3EA')
.borderRadius(5)
.padding({ left: 4, right: 4 })
.margin({ top: 2 })
}
}
.layoutWeight(1)
.borderRadius(12)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.editDay === i ? '#8B6FD8' : '#F9F4FA')
.alignItems(HorizontalAlign.Center)
.margin({ left: 3, right: 3 })
.onClick(() => { this.editDay = i })
}, (w: WeekDayItem) => w.label)
}
.width('100%')
.margin({ top: 8 })
预约改期弹框的标题行与 DIY 设计器类似,但副标题展示 editTarget 动态内容(从列表点击时设置的款式名和美甲师信息)。内容区顶部是原预约信息条,使用浅紫色背景 #F0ECFB 展示当前预约的日期、时间和店铺。
日历周条使用 ForEach 遍历 WEEK_DAYS 数组生成七个日期项,每个项使用 layoutWeight(1) 等分宽度。选中态使用紫色背景 #8B6FD8 配白色文字(与 DIY 设计器的粉色选中态形成区分),未选中态使用浅灰背景 #F9F4FA。星期二的日期项额外通过 if (i === 1) 条件渲染一个"休"字标签,这是一个特殊设计——可能是为了标注某个特定的休息安排。
19.2 时段格选择与备注输入
Row() {
Text('选择时段')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
Column()
.layoutWeight(1)
Text('灰色=已约满')
.fontSize(9)
.fontColor('#C9BBCF')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
ForEach(EDIT_SLOTS, (es: EditSlotItem, i: number) => {
if (es.state === 0) {
Text(es.label)
.fontSize(11)
.fontColor('#C9BBCF')
.backgroundColor('#F4F0F5')
.borderRadius(10)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.margin({ right: 8, bottom: 8 })
} else if (this.editSlot === i) {
Text(es.label + ' ✓')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#E0547A')
.borderRadius(10)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.editSlot = i })
} else if (es.state === 1) {
Text(es.label)
.fontSize(11)
.fontColor('#E08A3C')
.backgroundColor('#FFF1DE')
.borderRadius(10)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.editSlot = i })
} else {
Text(es.label)
.fontSize(11)
.fontColor('#8A7A90')
.backgroundColor('#FFFFFF')
.borderRadius(10)
.border({ width: 1, color: '#EBDFEE' })
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.editSlot = i })
}
}, (es: EditSlotItem) => es.label)
}
.width('100%')
.margin({ top: 8 })
时段格选择使用四分支条件渲染,是应用中最复杂的条件逻辑之一。四个分支分别处理四种情况:state === 0(约满)显示灰色不可选格子,editSlot === i(当前选中)显示粉色加勾号格子,state === 1(紧张)显示橙色格子,else(充足)显示带边框的白色格子。约满的格子没有 onClick,用户无法选择已约满的时段。
这种多条件分支的设计确保了时段格子的视觉状态与数据状态精确对应。用户通过颜色可以一眼识别哪些时段可选(橙色/白色)、哪些已约满(灰色)、哪个已选中(粉色加勾号)。Flex 的 Wrap 模式使得 12 个时段格自动换行排列,配合 margin({ right: 8, bottom: 8 }) 的间距,形成整齐的网格布局。
19.3 备注输入与底部按钮
Text('备注(选填)')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#8A7A90')
.margin({ top: 14 })
Row() {
Text('📝')
.fontSize(13)
TextInput({ placeholder: '如:甲床偏短想做微延长 / 对胶轻微敏感请备注' })
.placeholderColor('#C9BBCF')
.fontSize(12)
.layoutWeight(1)
.height(40)
.backgroundColor(Color.Transparent)
.margin({ left: 6 })
.onChange((v: string) => { this.editNote = v })
}
.width('100%')
.backgroundColor('#F9F4FA')
.borderRadius(12)
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
Column()
.height(10)
}
.width('100%')
.padding({ left: 18, right: 18 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.constraintSize({ maxHeight: '52%' })
Row() {
Text('保持原时间')
.fontSize(13)
.fontColor('#8A7A90')
.backgroundColor('#F1E9F2')
.borderRadius(21)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.onClick(() => { this.editOpen = false })
Text('确认改期')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#8B6FD8')
.borderRadius(21)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.editOpen = false })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 14, bottom: 16 })
}
.width('92%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor('#FFFFFF')
.borderRadius(20)
.alignItems(HorizontalAlign.Start)
.onClick(() => { this.editOpen = true })
}
备注输入区使用 Row 包裹一个 emoji 图标和 TextInput,整体使用浅灰色背景 #F9F4FA 配 12 的圆角,形成一个带前缀图标的输入框。TextInput 设置 backgroundColor(Color.Transparent) 透明背景,与外层 Row 的背景色融为一体。layoutWeight(1) 使输入框弹性占据 emoji 右侧的剩余空间。onChange 将输入值同步到 editNote 状态变量。
底部按钮区域与 DIY 设计器类似,但按钮文案和颜色不同:"保持原时间"使用灰色系,"确认改期"使用紫色背景 #8B6FD8(与日历选中态颜色一致),两个按钮都调用 this.editOpen = false 关闭弹框。
二十、弹框三:卸甲警示
@Builder modalBodyDel() {
Column() {
Text('💔')
.fontSize(44)
.margin({ top: 26 })
Text('确认卸甲吗?')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#B34A6E')
.margin({ top: 10 })
Text('「' + this.delTarget + '」')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#8A5A72')
.margin({ top: 6 })
Text('一旦卸除,精心搭配的碎钻与手绘花朵将不复存在。\n美甲师看到会心碎的,再考虑一下嘛~')
.fontSize(11)
.fontColor('#A97B8F')
.textAlign(TextAlign.Center)
.lineHeight(18)
.margin({ top: 10 })
卸甲警示弹框是四个弹框中视觉风格最独特的一个。弹框整体使用柔粉色背景 #FFF5F8 和 22 的大圆角,呈现出温暖的"柔粉底"视觉。顶部是一个 44 号的大号心碎 emoji,传达"遗憾"的情感基调。标题"确认卸甲吗?"使用 18 号加粗深粉色 #B34A6E,下方是 delTarget 动态内容包裹在中文书名号中展示,再下方是情感化的劝阻文案。
劝阻文案"一旦卸除,精心搭配的碎钻与手绘花朵将不复存在。美甲师看到会心碎的,再考虑一下嘛~“使用了 \n 换行符控制文本断行,配合 textAlign(TextAlign.Center) 居中对齐和 lineHeight(18) 行间距,呈现出柔和的劝阻效果。这种情感化文案设计是社区类应用的特色——不是冷冰冰的"确认删除?”,而是带有温度的劝阻,符合美甲社区的用户情感预期。
20.1 警示清单与按钮
Column() {
Row() {
Text('⚠️')
.fontSize(12)
Text('卸甲后甲面需要约 7 天休整期')
.fontSize(10)
.fontColor('#A97B8F')
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 8 })
Row() {
Text('⚠️')
.fontSize(12)
Text('收藏夹将同步移除该款式,操作不可撤销')
.fontSize(10)
.fontColor('#A97B8F')
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
Row() {
Text('⚠️')
.fontSize(12)
Text('已支付的预约定金将按规则退还')
.fontSize(10)
.fontColor('#A97B8F')
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.backgroundColor('#FFE9F0')
.borderRadius(12)
.padding(12)
.margin({ top: 14 })
Column() {
Text('确认卸甲')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#F0426B')
.borderRadius(22)
.padding({ left: 60, right: 60, top: 11, bottom: 11 })
.onClick(() => { this.delOpen = false })
Text('再留 7 天 💕')
.fontSize(12)
.fontColor('#C77DA6')
.backgroundColor('#FFF0F6')
.borderRadius(18)
.padding({ left: 30, right: 30, top: 8, bottom: 8 })
.margin({ top: 10 })
.onClick(() => { this.delOpen = false })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.margin({ top: 18, bottom: 22 })
}
.width('82%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor('#FFF5F8')
.borderRadius(22)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.delOpen = true })
}
警示清单使用浅粉背景 #FFE9F0 配 12 圆角的 Column,内部三行警示文案分别说明卸甲的三个后果:甲面需要休整、收藏夹移除不可撤销、定金退还。每行以警告 emoji 前缀,文案使用 10 号深粉色 #A97B8F,传达严肃但不冰冷的警示效果。
底部按钮区域使用纵向排列的两个按钮:"确认卸甲"使用大号粉色背景 #F0426B 配宽内边距(左右各 60vp),是主要的确认按钮;"再留 7 天"使用浅粉色系,文案带有爱心 emoji,传达"挽留"的情感。两个按钮都调用 this.delOpen = false 关闭弹框,但在实际应用中,"确认卸甲"应该触发删除逻辑,"再留 7 天"应该保持现状。
弹框整体宽度 82%(比其他弹框的 92% 或 86% 更窄),使用 22 的大圆角和柔粉背景 #FFF5F8,视觉上更加精致和温暖。onClick(() => { this.delOpen = true }) 的事件拦截确保点击弹框内部不会意外关闭弹框。
二十一、弹框四:到店排队叫号
21.1 票据风排队号牌
@Builder modalBodyBiz() {
Column() {
Column() {
Row() {
Column() {
Text('NAIL·STAR')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#B34A6E')
Text('排队号牌')
.fontSize(10)
.fontColor('#A97B8F')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Column() {
Text(this.bizNo)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#B34A6E')
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 12 })
Row() {
Text('🏪 ' + this.bizShop)
.fontSize(10)
.fontColor('#8A5A72')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ left: 16, right: 16, top: 6 })
Row() {
Text('💅 款式:' + this.bizStyle)
.fontSize(10)
.fontColor('#A97B8F')
}
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 12 })
}
.width('100%')
.backgroundColor('#FFF0F6')
.borderRadius(16)
.border({ width: 1.5, color: '#F3B6CF', style: BorderStyle.Dashed })
.margin({ top: 16 })
到店排队弹框的顶部是一个票据风格的排队号牌卡。卡使用浅粉背景 #FFF0F6 配 16 圆角,最关键的是 border({ width: 1.5, color: '#F3B6CF', style: BorderStyle.Dashed }) 设置了 1.5vp 宽的粉色虚线边框,模拟票据的撕边效果。BorderStyle.Dashed 是 ArkTS 的边框样式枚举值,创建虚线边框。
票据卡内部从上到下展示品牌名"NAIL·STAR"和"排队号牌"标签、排队号码 bizNo(26号加粗深粉色)、店铺名和款式信息。品牌名和号码通过 justifyContent(FlexAlign.SpaceBetween) 两端对齐,模拟了真实排队小票的布局。店铺名使用 maxLines(1) 配省略号截断,防止长店铺名破坏布局。
21.2 前面人数与等待进度条
Column() {
Text('前面还有')
.fontSize(12)
.fontColor('#8A7A90')
.margin({ top: 18 })
Row() {
Text(this.bizAhead.toString())
.fontSize(58)
.fontWeight(FontWeight.Bold)
.fontColor('#E0547A')
Text('桌')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E0547A')
.margin({ left: 4, bottom: 10 })
}
.alignItems(VerticalAlign.Bottom)
.margin({ top: 2 })
Text('前面有 2 位做镶钻 + 4 位做纯色')
.fontSize(10)
.fontColor('#B09BB8')
.margin({ top: 2 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text('预计等待')
.fontSize(11)
.fontColor('#8A7A90')
Column()
.layoutWeight(1)
Text(this.bizWait + ' 分钟')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#8B6FD8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Column()
.width(`${Math.round(this.bizWait / 40 * 100)}%`)
.height(8)
.borderRadius(4)
.linearGradient({
angle: 0,
colors: [['#FF7EB3', 0.0], ['#A78BFA', 1.0]]
})
Column()
.layoutWeight(1)
.height(8)
.borderRadius(4)
.backgroundColor('#F1E9F2')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 8 })
Row() {
Text('0 分钟')
.fontSize(8)
.fontColor('#C9BBCF')
Column()
.layoutWeight(1)
Text('40 分钟')
.fontSize(8)
.fontColor('#C9BBCF')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 4 })
}
.width('100%')
.margin({ top: 16 })
前面人数展示使用一个大数字"58 号加粗粉色"配一个小号"桌"字,alignItems(VerticalAlign.Bottom) 使"桌"字与数字底部对齐。58 号是应用中最大的字体大小,传达了"这是最重要的信息"的视觉优先级。下方的小字说明前面排队的用户构成(“2 位做镶钻 + 4 位做纯色”),提供了排队进度的参考信息。
等待进度条使用双 Column 模拟,与探店页面的价格进度条设计模式一致。进度条宽度通过 `${Math.round(this.bizWait / 40 * 100)}%` 动态计算——bizWait(25分钟)除以 40 分钟(满格基准)再乘以 100 得到百分比,Math.round 四舍五入取整。进度条下方展示"0 分钟"和"40 分钟"的刻度标签,帮助用户理解进度条的含义。
21.3 叫号提示与底部按钮
Row() {
Text('🔔')
.fontSize(12)
Text('过号作废,请留意短信与到号提醒;离店 1 公里内会自动推送预备提醒')
.fontSize(9)
.fontColor('#B09BB8')
.margin({ left: 6 })
.layoutWeight(1)
}
.width('100%')
.backgroundColor('#FDF8FE')
.borderRadius(10)
.padding(10)
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Row() {
Text('取消排队')
.fontSize(13)
.fontColor('#8A7A90')
.backgroundColor('#F1E9F2')
.borderRadius(21)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.bizOpen = false })
Text('🔔 提醒我')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#E0547A')
.borderRadius(21)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.bizOpen = false })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 18, bottom: 20 })
}
.width('86%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor('#FFFFFF')
.borderRadius(20)
.alignItems(HorizontalAlign.Start)
.padding({ left: 16, right: 16 })
.onClick(() => { this.bizOpen = true })
}
叫号提示使用浅紫色背景 #FDF8FE 配 10 圆角,内部展示铃铛 emoji 和提示文案。提示文案"过号作废,请留意短信与到号提醒;离店 1 公里内会自动推送预备提醒"提供了重要的排队注意事项,使用 layoutWeight(1) 弹性占据 emoji 右侧的剩余空间,确保长文本自动换行。
底部按钮是"取消排队"和"提醒我"两个,分别使用灰色系和粉色系。"提醒我"按钮带有铃铛 emoji 前缀,传达"设置到号提醒"的功能语义。弹框整体宽度 86%,使用白色背景和 20 圆角,onClick(() => { this.bizOpen = true }) 拦截事件冒泡。
二十二、底部导航栏
22.1 底部导航项 Builder
@Builder bottomItem(icon: string, label: string, idx: number, target: number) {
Column() {
Text(icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.mainTab === idx ? '#E0547A' : '#9C8CA4')
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.mainTab === idx) {
Column()
.width(16)
.height(3)
.backgroundColor('#E0547A')
.borderRadius(2)
.margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 5, bottom: 4 })
.onClick(() => {
this.mainTab = idx
if (target >= 0) { this.curTab = target }
})
}
bottomItem 是一个带参数的 @Builder 方法,用于构建底部导航栏的单个导航项。参数包括图标 emoji、标签文本、导航项索引 idx 和目标内容页索引 target。选中态通过 this.mainTab === idx 判断:选中时图标不透明度 1.0、标签粉色加粗、底部显示 16x3 的粉色指示条;未选中时图标半透明 0.45、标签灰色常规字重、无指示条。
@Builder 方法接收参数是 ArkTS 的一个重要能力——它使得 Builder 可以像函数一样被复用,不同参数生成不同内容的组件。onClick 同时更新 mainTab(底部高亮)和 curTab(内容页路由),当 target 为 -1 时不更新 curTab(如"消息"页没有对应的内容Tab)。
22.2 底部导航栏与中央发布按钮
@Builder bottomBar() {
Row() {
this.bottomItem('🏠', '首页', 0, 0)
this.bottomItem('🧭', '发现', 1, 4)
// 中央发布按钮(打开款式DIY设计器)
Column() {
Text('➕')
.fontSize(24)
.fontColor('#FFFFFF')
}
.width(52)
.height(52)
.borderRadius(26)
.linearGradient({
angle: 135,
colors: [['#FF7EB3', 0.0], ['#A78BFA', 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.shadow({ radius: 10, color: 'rgba(224,84,122,0.35)', offsetY: 3 })
.margin({ top: -16 })
.onClick(() => { this.addOpen = true })
this.bottomItem('💬', '消息', 2, -1)
this.bottomItem('👤', '我的', 3, 6)
}
.width('100%')
.backgroundColor('#FFFFFF')
.alignItems(VerticalAlign.Center)
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 10, color: 'rgba(139,111,216,0.12)', offsetY: -3 })
}
底部导航栏使用 Row 横向排列五个元素:两个导航项、中央发布按钮、两个导航项。中央发布按钮是 52x52 的圆形(borderRadius(26)),使用粉紫渐变背景(与头部渐变一致的配色),配合 shadow({ radius: 10, color: 'rgba(224,84,122,0.35)', offsetY: 3 }) 的粉色阴影,呈现出浮起的视觉效果。margin({ top: -16 }) 使按钮向上凸出 16vp,突破了导航栏的顶部边界,形成了"悬浮按钮"的设计效果。
onClick(() => { this.addOpen = true }) 打开款式 DIY 设计器弹框。导航项之间没有使用 layoutWeight(只有 bottomItem 内部使用了),因为中央按钮固定宽度 52vp,四个导航项通过自身的 layoutWeight(1) 等分剩余空间。底部导航栏的 shadow 使用 offsetY: -3(向上偏移),使得阴影出现在导航栏上方,模拟了导航栏浮在内容之上的视觉效果。
二十三、主构建方法
build() {
Stack() {
Column() {
this.header()
this.tabBar()
Scroll() {
Column() {
if (this.curTab === 0) {
this.pageStyles()
} else if (this.curTab === 1) {
this.pageArtists()
} else if (this.curTab === 2) {
this.pageShops()
} else if (this.curTab === 3) {
this.pageSlots()
} else if (this.curTab === 4) {
this.pageTrends()
} else if (this.curTab === 5) {
this.pageCare()
} else {
this.pageFav()
}
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
this.bottomBar()
}
.width('100%')
.height('100%')
this.fxLayer()
if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
this.modalOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor('#F7F2F8')
}
}
build 方法是整个组件的核心,定义了应用的最终视图结构。最外层是一个 Stack 容器,允许子元素叠加。Stack 内部按顺序叠加三层:
第一层是主布局 Column,从上到下依次是 header(头部)、tabBar(Tab栏)、Scroll(可滚动内容区)和 bottomBar(底部导航栏)。Scroll 设置 layoutWeight(1) 占据中间剩余空间,scrollBar(BarState.Off) 隐藏滚动条,edgeEffect(EdgeEffect.Spring) 设置滚动到边缘时的弹性回弹效果(模拟 iOS 的弹性滚动),align(Alignment.Top) 确保内容从顶部开始排列。内容区内部使用 if-else if 链根据 curTab 的值选择渲染对应的页面 Builder。这种"条件路由"的实现虽然看起来是一长串的 if-else,但在 ArkTS 的差分渲染机制下,只有条件分支对应的 Builder 会被实际执行和渲染,切换 Tab 时旧的页面会被卸载、新的页面会被挂载。
第二层是 fxLayer 浮动 emoji 特效层,叠加在内容之上。由于设置了 hitTestBehavior(HitTestMode.None),这层不影响下层交互。
第三层是条件渲染的 modalOverlay 弹框层。当四个弹框状态中任一为 true 时,modalOverlay 被渲染并叠加在最顶层,确保弹框遮罩覆盖在所有内容(包括特效层)之上。条件判断使用 || 短路或——只要任一弹框开启就渲染遮罩层。
整个 Stack 的背景色设置为 #F7F2F8(浅紫灰色),这是应用的基础背景色。这种三层 Stack 结构是"内容层—装饰层—弹框层"的经典分层模式,在复杂应用中非常常见。
二十四、关键技术选型对比分析
下面通过对比表格来分析本应用中关键技术选型的考量。
| 技术维度 | 本应用选型 | 替代方案 | 选型理由分析 |
|---|---|---|---|
| 状态管理 | @State + @Observed | @Provide/@Consume 或全局Store | 应用规模适中,集中式状态管理足够覆盖需求;@Observed使得数据模型可观察,@State管理组件级状态,两者配合实现了完整的状态响应链 |
| 数据模型 | interface + @Observed class | 纯interface或纯class | interface定义类型契约供编译期检查,class提供运行时可观察实例;两层设计既保证类型安全又支持状态响应 |
| 列表渲染 | ForEach + 逐条展开 | LazyForEach | 数据量在8-20条级别,ForEach的性能足够;LazyForEach适用于大数据量场景但API更复杂,此处不需要 |
| 布局系统 | Row/Column + layoutWeight | Flex/GridContainer | Row/Column是最基础的布局组件,配合layoutWeight可实现弹性布局;代码可读性高,调试方便 |
| 网格布局 | Flex + Wrap + SpaceBetween | Grid组件 | Flex的Wrap模式实现网格更灵活,可控制间距和对齐;Grid组件API更严格但灵活性不足 |
| 动画实现 | setInterval + @State tick | animateTo / animation | 定时器驱动方式实现简单直观,适合位置随时间线性变化的场景;animateTo提供更丰富的动画曲线但代码更复杂 |
| 弹框管理 | 条件渲染 + 单遮罩 | bindSheet / CustomDialog | 条件渲染方式灵活度高,可完全自定义弹框外观和交互;bindSheet受限于系统样式,CustomDialog需要额外组件声明 |
| 颜色管理 | 全局纯函数 + 内联色值 | 资源文件(color.json) | 纯函数封装颜色映射逻辑,可复用可测试;资源文件方式适合多文件项目,支持暗色模式切换 |
| 进度条 | 双Column模拟 + linearGradient | Progress组件 | 双Column方式可完全自定义渐变色和圆角;Progress组件样式固定但代码更简洁 |
| 筛选过滤 | ForEach内if-else | 数据层预过滤 | 渲染层过滤实现简单直观,数据量小时性能可接受;数据层过滤适合大数据量但需要额外的状态管理 |
| 图标系统 | emoji字符 | 图标字体或图片资源 | emoji零资源依赖,跨平台兼容,色彩丰富;但无法精细控制颜色和大小,在专业图标场景有局限 |
| 阴影效果 | shadow方法 | elevation属性 | shadow方法参数更丰富(radius、color、offsetY),可实现精细阴影;elevation是Material Design标准但定制性较弱 |
| 渐变背景 | linearGradient方法 | 叠加半透明色块 | linearGradient一步到位,性能好,支持多色停止点;叠加色块方式代码冗长且性能差 |
| 文本溢出 | maxLines + textOverflow | 字符串截断 | maxLines+Ellipsis是声明式方案,框架自动处理截断和省略号;手动截断需要计算字符数,不准确 |
| 多选切换 | indexOf + splice/push | Set数据结构 | 数组的indexOf+splice/push是ArkTS中最直观的多选实现;Set数据结构在ArkTS中支持有限 |
| 表单管理 | @State变量 + reset方法 | 表单状态库 | @State变量管理表单简单直接,reset方法一键恢复;表单状态库适合复杂表单但引入额外复杂度 |
| 时间线 | Column节点+竖线 | Canvas绘制 | Column组件实现时间线简单直观,节点颜色可动态计算;Canvas绘制更灵活但代码复杂度高 |
| 边框样式 | borderRadius+borderWidth方法 | 叠加Shape组件 | borderRadius和borderWidth是ArkTS原生属性方法,链式调用简洁直观;叠加Shape组件方式代码冗长但可实现非矩形边框 |
| 页面路由 | if-else条件分支 | Navigation/Router路由 | 单页面应用内通过if-else切换内容区即可满足需求,状态驱动的条件渲染天然与声明式范式契合;Navigation路由适合多页面栈管理场景但引入额外复杂度 |
| 生命周期 | aboutToAppear | onPageShow/onPageHide | aboutToAppear在组件创建时触发,适合一次性初始化操作如启动定时器;onPageShow/onPageHide适合页面级可见性监听但本应用为单页面无需此能力 |
二十五、全文总结与架构反思
架构设计的核心思想回顾
纵观整个美甲星球社区应用的代码实现,我们可以清晰地看到 HarmonyOS ArkTS API 24 声明式编程范式的核心理念——状态驱动视图、数据流向单向、组件职责分离——在这个超过三千行的单文件应用中得到了系统而完整的实践。从最顶层的 @Entry 组件到最底层的 @Builder 方法,每一行代码都在践行"声明状态,框架渲染"的开发哲学。@State 装饰器管理着组件内部的可变状态,每当状态值发生变化时,ArkTS 框架的差分渲染引擎会自动计算新旧视图树的差异,并仅更新发生变化的 UI 节点,这种细粒度的更新机制既保证了渲染性能,又让开发者无需手动操作 DOM 或关心视图同步问题。
@Observed 装饰器则为数据模型层提供了可观察的能力,使得普通的 TypeScript class 实例变成响应式数据源。当被 @Observed 标注的类的属性被修改时,所有引用该实例的 @State 变量都会收到变更通知,进而触发依赖该状态的 @Builder 方法重新执行。这种"数据模型可观察——组件状态响应——视图自动更新"的三层链路,构成了 ArkTS 状态管理体系的基石。在本应用中,从美甲款式列表的筛选过滤到预约时段的余量计算,从弹框表单的双向绑定到底部导航的 Tab 切换,所有交互响应都建立在这条链路之上。
分层架构的工程价值
本应用虽然采用了单文件组织方式,但其内部的分层架构思想与大型多文件项目一脉相承。类型契约层(interface 定义)确保了编译期的类型安全,在代码编写阶段就能捕获类型不匹配的错误;数据模型层(@Observed class)提供了运行时的可变状态容器,将静态类型与动态行为有机结合;静态数据层(常量数组)模拟了真实场景下的后端数据源,使得应用可以在无网络环境下完整运行和调试;纯函数工具层(全局函数)封装了与业务逻辑相关的计算逻辑,如颜色映射、价格格式化、时段计算等,这些函数无副作用、可复用、可测试,是整个应用中最稳定的代码资产。
状态管理层集中在 @Entry 组件内部,通过约二十个 @State 变量统一管理应用的所有可变状态。这种集中式状态管理的优势在于状态的可追溯性——任何一个 UI 变化都可以追溯到某个 @State 变量的修改,而每个 @State 变量的修改都可以追溯到某个用户交互或生命周期事件。在应用规模适中(状态变量在数十个级别)的场景下,集中式管理的收益远大于其成本。当然,如果应用进一步膨胀,状态变量达到数百个级别,就需要考虑引入 @Provide/@Consume 跨组件传递或拆分为多个子组件各自管理状态。
视图层的技术亮点分析
视图构建层是本应用代码量最大的部分,通过约二十个 @Builder 方法将状态映射为界面。每个 @Builder 方法都遵循"单一职责"原则,聚焦于一个具体的 UI 区域或交互流程。例如 header 方法只负责渲染渐变头部,tabBar 方法只负责内容 Tab 栏,pageStyles 方法只负责款式图鉴页面,modalBodyAdd 方法只负责 DIY 设计器弹框。这种拆分方式使得每个 Builder 方法的代码量控制在可读范围内,修改某个页面时不会影响其他页面的逻辑。
在布局技术上,本应用展示了 ArkTS 布局体系的丰富能力。基础的 Row 和 Column 组件配合 layoutWeight 实现了弹性分配,Flex 组件的 Wrap 模式实现了自动换行的网格布局,Stack 组件实现了多层叠加的 Z 轴布局,Scroll 组件实现了可滚动内容区。这些布局组件的组合使用覆盖了从线性布局到网格布局、从平面布局到立体布局的全部场景。特别是在美甲色板宫格、美甲师卡片列表、时段预约热力表等复杂界面中,多种布局组件的嵌套使用展示了 ArkTS 在复杂视觉排列方面的出色能力。
交互系统的设计哲学
本应用的交互系统设计体现了"渐进式增强"的设计哲学。最基础层是静态内容展示——用户可以浏览款式图鉴、查看美甲师档案、阅读护理知识,这些功能不依赖任何状态变更,是应用的"只读"基线。在此基础上,第一层交互增强是筛选与切换——用户可以通过 Tab 切换内容页面、通过筛选标签过滤款式列表、通过收藏按钮标记偏好,这些交互需要 @State 变量来记录用户选择。第二层交互增强是表单输入——用户可以在 DIY 设计器中输入款式名称、选择颜色和贴饰,在预约改期弹框中选择新的时段,这些交互需要表单状态的临时存储和重置机制。最高层交互增强是动态效果——全屏浮动 emoji 特效层通过 setInterval 驱动 tick 状态递增,让 emoji 以抛物线轨迹飘落,为应用注入了生命力。
这四个层次的交互能力层层叠加,从简单到复杂、从被动到主动,构成了一个完整的用户交互体验闭环。每一层交互都建立在下层交互的基础上,但又不会破坏下层的功能。例如,弹框系统出现时会叠加遮罩层,但遮罩层下方的页面内容依然完整保留,关闭弹框后用户可以无缝继续浏览。这种设计确保了交互的连贯性和可逆性,用户永远不会因为某个操作而"迷失"在应用中。
性能与可维护性的平衡取舍
在性能方面,本应用做出了多项合理的取舍。列表渲染使用 ForEach 而非 LazyForEach,是因为数据量在 8-20 条级别,ForEach 的性能开销可以忽略不计,而其代码简洁性优势更明显。动画使用 setInterval 驱动而非 animateTo,是因为 emoji 飘落是位置随时间线性变化的场景,定时器方式实现更直观,且 120ms 的间隔已经足够流畅。弹框使用条件渲染而非 CustomDialog,是因为需要完全自定义弹框的外观和交互,条件渲染提供了最大的灵活度。这些取舍都遵循了"够用就好"的工程原则——不为了使用高级 API 而使用,而是在满足需求的前提下选择最简单直接的实现方式。
在可维护性方面,单文件组织方式虽然导致文件体量庞大,但所有相关逻辑集中在一处,便于全局搜索和跨段引用。颜色管理使用全局纯函数而非资源文件,虽然不支持暗色模式切换,但在单文件场景下保证了颜色逻辑的内聚性。静态数据直接以常量数组内联,虽然不便于后端对接,但使得应用可以独立运行和演示。这些取舍体现了原型/演示类应用的工程取向——优先保证功能的完整性和代码的可读性,架构的扩展性可以后续迭代中逐步提升。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// NailStar 美甲星球 —— 小红书风格美甲款式社区App(单文件)
// 场景:款式图鉴 / 美甲师预约 / 色板收藏 / 店铺探店 / 护理知识
// 布局主轴:色板宫格 + 时段预约表
// ============================================================
// ============ 类型定义(所有对象字面量均配套interface) ============
interface StyleItem {
id: number
name: string
color: string
color2: string
emoji: string
heat: string
tag: string
}
interface ArtistItem {
id: number
name: string
avatar: string
skills: string[]
price: number
status: string
rating: number
fans: string
shop: string
cases: number
}
// ============ 主构建 ============
build() {
Stack() {
Column() {
this.header()
this.tabBar()
Scroll() {
Column() {
if (this.curTab === 0) {
this.pageStyles()
} else if (this.curTab === 1) {
this.pageArtists()
} else if (this.curTab === 2) {
this.pageShops()
} else if (this.curTab === 3) {
this.pageSlots()
} else if (this.curTab === 4) {
this.pageTrends()
} else if (this.curTab === 5) {
this.pageCare()
} else {
this.pageFav()
}
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
this.bottomBar()
}
.width('100%')
.height('100%')
this.fxLayer()
if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
this.modalOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor('#F7F2F8')
}
}

总结:
从这份代码中,我们可以看到 HarmonyOS 6.1.1 搭载的 HarmonyOS ArkTS API 24 在声明式 UI 编程领域已经具备了相当成熟的能力。@Entry、@Component、@State、@Observed、@Builder 等装饰器构成了完整的声明式开发工具链,linearGradient、shadow、hitTestBehavior、position 等属性方法提供了丰富的视觉定制能力,aboutToAppear 等生命周期钩子确保了组件初始化时机的可控性。这些能力组合起来,足以支撑一个功能完整、视觉精美、交互流畅的垂直社区类应用从零到一的构建。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)