ArkUI 声明式实战:状态驱动操作 —— 预约记录页的时间线与差异化按钮
2026/9/4 0:00:38 网站建设 项目流程

ArkUI 声明式实战:状态驱动操作 —— 预约记录页的时间线与差异化按钮

App 14「运动场地预约」预约 Tab(Func2Tab),是预约记录的历史列表页。整页用 Header + 4 个胶囊筛选 Tab(全部/待确认/已完成/已取消)+ 3 栏统计概览 + 6 条记录的时间线组成,每条记录根据状态(待确认/已完成/已取消)显示不同操作按钮,是本系列第一个"状态驱动操作"的页面。本篇基于14-sports-booking/entry/src/main/ets/pages/Func2Tab.ets(约 218 行)逐段拆解,附 4 张实机截图。

一、整体结构:筛选 + 时间线

Func2Tab 的骨架是"Header + TabBar 固定,Scroll 装统计和时间线":

build() { Column() { this.Header() this.TabBar() Scroll() { Column({ space: 16 }) { this.StatsOverview() this.SectionTitle('预约记录') this.RecordTimeline() } .width('100%') .padding({ left: D.pad, right: D.pad, top: 16, bottom: D.pad + this.safeBottom + 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width('100%').height('100%').backgroundColor(C.bg) }

Header + TabBar 固定(切换筛选时筛选条保持原位),Scroll 内是 StatsOverview(统计概览)+ SectionTitle("预约记录"标题)+ RecordTimeline(时间线)。

与 App 13 歌单页(Func2Tab)的差异

  • App 13 是"统计卡 + 下划线 Tab + 记录卡"三段固定
  • App 14 是"胶囊 Tab + 统计卡 + 时间线"三段,Tab 在顶部(筛选后列表更新)

App 14 的 TabBar 在 Header 下面(筛选优先),App 13 的 TabBar 在统计卡下面(统计优先)——布局顺序反映产品优先级

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

二、Header + TabBar:4 个胶囊筛选

Header 单行标题"我的预约",TabBar 是 4 个胶囊标签(全部/待确认/已完成/已取消):

@Builder Header() { Row() { Text('我的预约') .fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) } .width('100%').height(this.safeTop + 56) .padding({ top: this.safeTop, left: D.pad, right: D.pad }) .backgroundColor(C.card) .alignItems(VerticalAlign.Bottom) } @Builder TabBar() { Row() { ForEach(this.tabs, (t: FilterTab) => { Text(t.label) .fontSize(13) .fontColor(this.activeTab === t.id ? C.primary : C.textSub) .fontWeight(this.activeTab === t.id ? FontWeight.Bold : FontWeight.Normal) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(this.activeTab === t.id ? C.primarySoft : 'transparent') .borderRadius(16) .onClick(() => { this.activeTab = t.id; }) }, (t: FilterTab) => t.id.toString()) } .width('100%') .padding({ left: D.pad, right: D.pad, top: 12, bottom: 12 }) .backgroundColor(C.card) }

4 个 Tab 的 id 与状态值绑定

private tabs: FilterTab[] = [ { id: 0, label: '全部' }, { id: 1, label: '待确认' }, { id: 2, label: '已完成' }, { id: 3, label: '已取消' } ];

t.id就是"筛选状态值":id=0 全部、id=1 待确认(records 里 status=1)、id=2 已完成(status=2)、id=3 已取消(status=3)。id 直接映射 status,过滤逻辑r.status === this.activeTab一行搞定——"Tab id = 状态值"是筛选设计的精髓,避免维护两套映射。

胶囊选中态三件套:背景(C.primarySoft浅绿 vstransparent)、文字(绿加粗 vs 灰常规)、圆角(16 胶囊)。与 App 13 歌单页的"下划线指示器"不同,App 14 用"胶囊"——胶囊更适合 4 个以上的 Tab(每个 Tab 有独立背景),下划线更适合 2-3 个 Tab。

三、StatsOverview:3 栏统计概览

StatsOverview 是 3 个统计数字(总预约 14 / 已完成 11 / 待确认 2):

@Builder StatsOverview() { Row() { ForEach(this.stats, (s: StatItem) => { Column({ space: 4 }) { Text(s.value).fontSize(22).fontWeight(FontWeight.Bold).fontColor(C.text) Text(s.label).fontSize(11).fontColor(C.textDim) } .layoutWeight(1) .alignItems(HorizontalAlign.Center) }, (s: StatItem) => s.label) } .width('100%') .padding({ top: 16, bottom: 16 }) .backgroundColor(C.card).borderRadius(D.rMd) .border({ width: 1, color: C.stroke }) }

3 栏数据总预约 14 / 已完成 11 / 待确认 2。与 App 13 歌单页的"彩色统计"不同,App 14 的统计全部同色C.text深色)——不引入多余颜色,把颜色留给状态标签(待确认橙/已完成绿/已取消灰)。

"颜色留给状态"的设计原则:统计数字用中性色,状态用语义色。如果统计数字也花花绿绿,用户反而分不清"哪些颜色是状态、哪些是装饰"。

alignItems(HorizontalAlign.Center)让 3 个数字水平居中。3 栏之间没有分隔线(space由 layoutWeight 均分)——统计概览的"数字+标签"两行组合不需要分隔线,间距足够。

四、RecordTimeline:状态驱动的时间线(核心)

RecordTimeline 是整页最复杂的 @Builder——6 条记录组成时间线,每条记录根据状态显示不同操作

@Builder RecordTimeline() { Column({ space: 0 }) { ForEach(this.filtered(), (r: Record, idx: number) => { Column({ space: 0 }) { if (idx > 0) { Row() .width(2).height(12) .backgroundColor(C.stroke) .margin({ left: 18 }) } Row({ space: 12 }) { Column({ space: 0 }) { Row() { Text(r.emoji).fontSize(18) } .width(36).height(36) .backgroundColor(C.primarySoft).borderRadius(18) .justifyContent(FlexAlign.Center) } .width(36) Column({ space: 8 }) { Row({ space: 8 }) { Text(r.venue).fontSize(14).fontWeight(FontWeight.Medium).fontColor(C.text) Text(r.date).fontSize(12).fontColor(C.textDim) Blank() Text(r.statusText) .fontSize(11).fontColor(r.statusColor) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(r.statusColor.toString().replace('#FF', '#1A')) .borderRadius(8) } .width('100%') Row({ space: 6 }) { Text('🕐').fontSize(11) Text(r.time + ' · ' + r.duration).fontSize(12).fontColor(C.textSub) } if (r.status === 1) { Row({ space: 8 }) { Button('取消') .fontSize(12).fontColor(C.textSub).backgroundColor(C.cardSoft) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '取消预约' }); }) Button('查看详情') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '查看 ' + r.venue }); }) } .width('100%') } if (r.status === 2) { Row({ space: 8 }) { Button('再次预约') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '再次预约 ' + r.venue }); }) Text('已使用').fontSize(11).fontColor(C.textDim) } .width('100%') } if (r.status === 3) { Text('预约已取消').fontSize(11).fontColor(C.textDim) } } .alignItems(HorizontalAlign.Start).layoutWeight(1) } .width('100%') .padding({ left: 14, right: 14, top: 14, bottom: 14 }) .backgroundColor(C.card).borderRadius(D.rMd) .border({ width: 1, color: C.stroke }) } .width('100%') }, (r: Record) => r.id.toString()) } .width('100%') }

4.1 时间线连接线

if (idx > 0) { Row() .width(2).height(12) .backgroundColor(C.stroke) .margin({ left: 18 }) }

每条记录(除第一条)上方画一条 2×12vp 的竖线C.stroke浅灰),margin({ left: 18 })让它与下方头像圆点(36vp 半径 18,中心在 18vp)对齐——竖线连接相邻记录的圆点,形成"时间线"视觉。这是"圆点 + 竖线"时间线的标准实现。

竖线高度 12vp是固定值,假设每条记录高度约 12vp 间隔——如果某条记录高度变化(如"已取消"没有按钮区),竖线可能"悬空"或"穿透"。真实项目应让竖线layoutWeight(1)跟随右侧内容高度(把竖线放进与记录卡片等高的容器),避免"断线"。

4.2 记录头部:场地 + 日期 + 状态标签

Row({ space: 8 }) { Text(r.venue).fontSize(14).fontWeight(FontWeight.Medium).fontColor(C.text) Text(r.date).fontSize(12).fontColor(C.textDim) Blank() Text(r.statusText) .fontSize(11).fontColor(r.statusColor) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(r.statusColor.toString().replace('#FF', '#1A')) .borderRadius(8) }

状态标签的"文字色 = 状态色" + "背景色 = 状态色降透明"

Text(r.statusText) .fontSize(11).fontColor(r.statusColor) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(r.statusColor.toString().replace('#FF', '#1A')) .borderRadius(8)

r.statusColor.toString().replace('#FF', '#1A')是这条代码的精华——把状态色(如#FF9F1C橙)的透明度从FF(不透明)替换为1A(约 10% 透明度),得到#1A9F1C作为背景色。文字色用实色(如 #FF9F1C),背景用同色 10% 透明——"同色系浅底深字"的状态标签,比"白字彩底"更柔和、更现代。

3 种状态色:

  • 待确认 →C.warn#FF9F1C→ 文字橙、背景#1A9F1C淡橙(replace 生效,因为#FF9F1C#FF开头)
  • 已完成 →C.ok绿#2BB673→ 文字绿、背景#2BB673实色绿(replace不生效#2BB673不含#FF子串)
  • 已取消 →C.textDim#9AA3BC→ 文字灰、背景#9AA3BC实色灰(replace不生效#9AA3BC不含#FF子串)

replace('#FF', '#1A')是 ArkTS 的字符串替换——toString()ResourceColor转字符串(#FF9F1C),replace只替换第一个#FF注意这个技巧的前提是颜色格式是 8 位 ARGB#FFxxxxxx),如果是 6 位#xxxxxx就不生效。Demo 里 C.warn 都是 6 位十六进制……这里其实是个潜在 bugC.warn的值是'#FF9F1C'(6 位),replace('#FF', '#1A')会把它变成#1A9F1C(这是正确的),但C.ok'#2BB673'——'#2BB673'.replace('#FF', '#1A')找不到#FF子串,背景色就是#2BB673实色(不是淡色)!所以只有"待确认"标签有淡色背景,其他两个状态是实色背景+实色文字(白字?不,文字也是#2BB673绿,绿字绿底,对比度可能不够)。

这个"半透明背景色"技巧的正确写法应该是用rgba()backgroundColor(r.statusColor + '1A')或显式写rgba(43, 182, 115, 0.1)用字符串替换做透明度转换是不可靠的——颜色格式一变就失效。这是值得写进"避坑笔记"的教训。

4.3 三态操作区(核心差异)

if (r.status === 1) { Row({ space: 8 }) { Button('取消') .fontSize(12).fontColor(C.textSub).backgroundColor(C.cardSoft) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '取消预约' }); }) Button('查看详情') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '查看 ' + r.venue }); }) } .width('100%') } if (r.status === 2) { Row({ space: 8 }) { Button('再次预约') .fontSize(12).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(30) .onClick(() => { promptAction.showToast({ message: '再次预约 ' + r.venue }); }) Text('已使用').fontSize(11).fontColor(C.textDim) } .width('100%') } if (r.status === 3) { Text('预约已取消').fontSize(11).fontColor(C.textDim) }

3 个状态分支,3 种操作

  1. 待确认(status=1)取消(灰底次级按钮)+查看详情(绿底主按钮)——"还可以反悔,也可以查看"
  2. 已完成(status=2)再次预约(绿底主按钮)+ "已使用"(灰色文字)——"做完了,想再来一次"
  3. 已取消(status=3)预约已取消(灰色文字)——"没了,无操作可做"

"状态 → 操作"的映射是业务逻辑的体现

状态主操作次操作语义
待确认查看详情取消可反悔
已完成再次预约已使用标记可回购
已取消终态

"已完成 → 再次预约"是复购逻辑——用户运动完觉得不错,一键再约同一场地同一时段。"待确认 → 取消"是反悔通道——未确认的预约可取消(已确认的就不能随便取消了)。业务状态机通过 UI 操作呈现,是"状态驱动"的最高级形式。

主次按钮的视觉差异:主按钮绿底白字(C.primary),次按钮灰底灰字(C.cardSoft+C.textSub)。两个按钮layoutWeight均分宽度(这里其实没写 layoutWeight,Row 会按内容宽度排,但如果两个按钮不等宽会不整齐——真实项目应加layoutWeight(1)让两个按钮等宽)。

4.4 数据与筛选

filtered()方法是本页第一个"真实筛选"(App 13 歌单页的 Tab 切换没有联动过滤,App 14 实现了):

private filtered(): Record[] { if (this.activeTab === 0) return this.records; return this.records.filter((r: Record) => r.status === this.activeTab); }
  • activeTab === 0(全部)→ 返回全部 6 条
  • 否则 →filterstatus === activeTab的记录(待确认 1 条、已完成 4 条、已取消 1 条)

@State activeTab变化 →filtered()重算 → ForEach 重渲染——声明式数据流完整闭环。6 条数据中"待确认 1 + 已完成 4 + 已取消 1",点击"待确认" Tab 只显示 1 条,点击"已完成"显示 4 条——筛选是真实生效的(对比 App 13 的"假筛选",这是本系列第一次"真筛选")。

6 条记录的时间线编排:08-14 篮球(待确认)→ 08-12 羽毛球(已完成)→ 08-10 足球(已完成)→ 08-08 网球(已取消)→ 08-06 乒乓球(已完成)→ 08-03 篮球(已完成)——按日期倒序(最新在前),时间线自然呈现"最近 → 最远"的阅读顺序。

五、状态色在 UI 中的"三通道"呈现

本页对"状态"的呈现做了三通道冗余

  1. 文字statusText(待确认/已完成/已取消)
  2. 文字色statusColor(橙/绿/灰)
  3. 背景色statusColor降透明(淡橙/淡绿/淡灰)

三通道让色弱用户也能识别(靠文字),视觉层次丰富(颜色强化)。这是"无障碍"与"美观"兼得的标准做法。

六、RecordTimeline 的扩展方向

时间线目前是"静态渲染",真实项目应扩展:

  1. 筛选联动:点击 Tab 后时间线动态过滤(已实现filtered()
  2. 操作真实化:取消按钮应调 API(DELETE /appointments/{id}),再次预约应跳转预约页
  3. 加载更多:6 条记录是 demo 数据,真实项目应分页(上滑加载更多)
  4. 详情页:"查看详情"应跳转到预约详情页(显示场地图片、二维码入场等)
  5. 状态流转:待确认 → 确认后变已完成,需要后端回调更新 records

"时间线 + 状态筛选 + 状态操作"是订单/预约/工单类应用的核心模板。App 11 快递的取件时间轴是"纯展示",App 14 的预约时间线是"可操作"——从"看"到"用"的进化

七、与 App 13 歌单记录页的对比

维度App 13 歌单记录App 14 预约记录
布局统计卡 + 下划线 Tab + 记录卡胶囊 Tab + 统计卡 + 时间线
筛选假筛选(不联动)真筛选(filtered())
记录形态独立卡片时间线(圆点+竖线连接)
状态操作统一"查看详情/更多"按状态差异化(取消/再次预约/无)
状态呈现实色标签浅色背景+同色文字

App 14 是 App 13 的"升级版":真筛选、时间线、状态驱动操作、半透明状态标签——每一处都比 App 13 更进一步。系列化 demo 的价值正在于此:同一个模板(记录列表),每个版本加一个能力,读者能直观看到"如何从初级进化到高级"。

八、总结:本页的可复用模板

Header(我的预约)+ 胶囊筛选 Tab(4 个)+ Scroll( 统计概览(3 栏)+ 区块标题 + 时间线(记录卡片 + 状态操作) )

核心可复用点

  1. "Tab id = 状态值"的筛选设计activeTab直接当 status 过滤)
  2. 时间线组件(圆点 + 竖线 + 记录卡)
  3. 状态驱动操作if (r.status === N)分支渲染不同按钮)
  4. 半透明状态标签(文字色实色 + 背景色降透明)

这套模板可复用到:订单列表、工单列表、维修记录、快递记录、请假记录——任何"多状态 + 时间线 + 操作"的场景

九、时间线 vs 列表:两种记录形态的取舍

App 11 快递取件页、App 13 歌单页、App 14 预约页都有"记录列表",但形态不同:

页面形态连接方式适用场景
App 11 取件横向时间轴(圆点+竖线)每条记录左列竖线物流跟踪(时间顺序)
App 13 歌单独立卡片(无连接)任务清单(平级)
App 14 预约纵向时间线(圆点+竖线)记录间竖线历史记录(时间顺序)

"是否用连接线"的判断标准记录之间是否有"时间先后"的因果/顺序关系。物流(先揽收→运输→派送→签收)有严格顺序,历史预约(08-14→08-03)有时间顺序,用连接线强化"先后感";而歌单的任务(项目一/二/三)是平级的,不需要连接线。

App 14 用的是**"记录间竖线"**(if (idx > 0) Row().width(2).height(12)),即每条记录上方加一小段竖线连接上一条的圆点——比"左列整条竖线"更灵活(记录间距离可变化),但竖线高度固定 12vp 有"断线"风险。真实项目若记录高度不固定,建议:

  1. 左列独立时间轴:每行左侧固定宽度的"圆点+竖线"列,竖线layoutWeight(1)撑满整行高度——竖线永远连续不断
  2. 按需显示连接:只有相邻记录时间间隔合理时才画竖线(避免"08-14 和 08-03 隔 11 天还连着")

"时间线要表达顺序感,列表要表达平级感"——选择形态前先想清楚数据关系。

十、Record 数据模型的状态枚举化

当前Record接口用status: number(1/2/3)+statusText: string+statusColor: ResourceColor三字段表达状态:

interface Record { id: number; emoji: string; venue: string; date: string; time: string; status: number; statusText: string; statusColor: ResourceColor; duration: string; }

真实项目建议用 TypeScript 枚举替代裸数字

enum BookingStatus { Pending = 1, // 待确认 Completed = 2, // 已完成 Cancelled = 3 // 已取消 }

好处有三:

  1. 可读性r.status === BookingStatus.Pendingr.status === 1明确
  2. 类型安全:枚举值拼错会编译报错,裸数字拼错(r.status === 4)编译通过但逻辑错误
  3. 可维护:新增状态(如"已过期")只加枚举成员,编译器帮你找出所有引用点

statusTextstatusColor也应该从 status 推导(而不是每条数据都写):

function statusMeta(status: BookingStatus): { text: string; color: ResourceColor } { switch (status) { case BookingStatus.Pending: return { text: '待确认', color: C.warn }; case BookingStatus.Completed: return { text: '已完成', color: C.ok }; case BookingStatus.Cancelled: return { text: '已取消', color: C.textDim }; } }

"数据存 id,样式靠推导"——RecordItem 只存 status 数字,UI 层根据 status 查表得到文字和颜色。这样新增状态只需改一个函数,且状态语义(text/color)集中在单一位置,不会出现"这条记录手写待确认橙色、那条手写已完成绿色"的数据不一致。

十一、状态操作的"业务状态机"

本页 3 个状态分支(待确认/已完成/已取消)背后是预约业务的状态机

创建 → 待确认 → 已完成 ↘ 已取消
  • 待确认:用户提交预约,等待管理员确认(可取消)
  • 已完成:确认且已使用(可再次预约 → 创建新预约)
  • 已取消:用户或管理员取消(终态,无操作)

状态机的 UI 呈现:每个状态对应一组可用操作(待确认→取消/详情,已完成→再次预约,已取消→无)。"状态 → 操作集"的映射是状态机在 UI 层的落地

真实项目里,状态机应该做"状态流转校验"——例如"已完成"的记录不能"取消"(取消只允许在待确认时),"已取消"的记录不能"再次预约"(只能新建)。UI 的按钮渲染只是状态机的一半,另一半是后端的状态流转校验(防止用户绕过 UI 直接调 API 把已取消的改成已完成)。

状态机的三个要素:当前状态、可触发的事件(操作)、事件导致的状态变更。UI 层展示"当前状态 + 可用操作",逻辑层执行"操作 → 校验 → 状态变更"。把状态机画出来再写代码,是订单/预约类业务的标准开发流程。

十二、记录页与个人中心的"数据呼应"

预约记录页的统计(总预约 14/已完成 11/待确认 2)与我的页统计(预约 14)数字一致——"总预约 14"同时出现在两个页面。跨页数据一致是"单一数据源"原则的体现:如果我的页写"预约 15"、预约页写"总预约 14",用户会怀疑"哪个是对的"。

demo 里这些数字是手写一致的,真实项目应来自同一个用户画像接口(GET /api/user/profile返回totalAppointments: 14),各页只展示不重复存储。"数据源唯一,各页是视图"——这是贯穿本系列的数据架构原则。

待确认 2 还与我的页菜单角标"预约日历 2"呼应——2 个待确认预约 = 预约日历里有 2 条待处理。demo 的每一处数字都有"因果链",这种数据自洽让整个应用显得"真实可信"。

到此,App 14「运动场地预约」预约记录页解析完毕。真筛选 + 时间线 + 状态操作让这个页面成为本系列"列表页"的进阶范本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询