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

发布时间:2026/9/4 0:00:44
ArkUI 声明式实战:状态驱动操作 —— 预约记录页的时间线与差异化按钮 ArkUI 声明式实战状态驱动操作 —— 预约记录页的时间线与差异化按钮App 14「运动场地预约」预约 TabFunc2Tab是预约记录的历史列表页。整页用 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 TabBar4 个胶囊筛选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就是筛选状态值id0 全部、id1 待确认records 里 status1、id2 已完成status2、id3 已取消status3。id 直接映射 status过滤逻辑r.status this.activeTab一行搞定——Tab id 状态值是筛选设计的精髓避免维护两套映射。胶囊选中态三件套背景C.primarySoft浅绿 vstransparent、文字绿加粗 vs 灰常规、圆角16 胶囊。与 App 13 歌单页的下划线指示器不同App 14 用胶囊——胶囊更适合 4 个以上的 Tab每个 Tab 有独立背景下划线更适合 2-3 个 Tab。三、StatsOverview3 栏统计概览StatsOverview 是 3 个统计数字总预约 14 / 已完成 11 / 待确认 2Builder 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转字符串#FF9F1Creplace只替换第一个#FF。注意这个技巧的前提是颜色格式是 8 位 ARGB#FFxxxxxx如果是 6 位#xxxxxx就不生效。Demo 里 C.warn 都是 6 位十六进制……这里其实是个潜在 bugC.warn的值是#FF9F1C6 位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 种操作待确认status1取消灰底次级按钮查看详情绿底主按钮——还可以反悔也可以查看已完成status2再次预约绿底主按钮 已使用灰色文字——做完了想再来一次已取消status3预约已取消灰色文字——没了无操作可做状态 → 操作的映射是业务逻辑的体现状态主操作次操作语义待确认查看详情取消可反悔已完成再次预约已使用标记可回购已取消无无终态已完成 → 再次预约是复购逻辑——用户运动完觉得不错一键再约同一场地同一时段。待确认 → 取消是反悔通道——未确认的预约可取消已确认的就不能随便取消了。业务状态机通过 UI 操作呈现是状态驱动的最高级形式。主次按钮的视觉差异主按钮绿底白字C.primary次按钮灰底灰字C.cardSoftC.textSub。两个按钮layoutWeight均分宽度这里其实没写 layoutWeightRow 会按内容宽度排但如果两个按钮不等宽会不整齐——真实项目应加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 条否则 →filter出status 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 中的三通道呈现本页对状态的呈现做了三通道冗余文字statusText待确认/已完成/已取消文字色statusColor橙/绿/灰背景色statusColor降透明淡橙/淡绿/淡灰三通道让色弱用户也能识别靠文字视觉层次丰富颜色强化。这是无障碍与美观兼得的标准做法。六、RecordTimeline 的扩展方向时间线目前是静态渲染真实项目应扩展筛选联动点击 Tab 后时间线动态过滤已实现filtered()操作真实化取消按钮应调 APIDELETE /appointments/{id}再次预约应跳转预约页加载更多6 条记录是 demo 数据真实项目应分页上滑加载更多详情页查看详情应跳转到预约详情页显示场地图片、二维码入场等状态流转待确认 → 确认后变已完成需要后端回调更新 records时间线 状态筛选 状态操作是订单/预约/工单类应用的核心模板。App 11 快递的取件时间轴是纯展示App 14 的预约时间线是可操作——从看到用的进化。七、与 App 13 歌单记录页的对比维度App 13 歌单记录App 14 预约记录布局统计卡 下划线 Tab 记录卡胶囊 Tab 统计卡 时间线筛选假筛选不联动真筛选filtered()记录形态独立卡片时间线圆点竖线连接状态操作统一查看详情/更多按状态差异化取消/再次预约/无状态呈现实色标签浅色背景同色文字App 14 是 App 13 的升级版真筛选、时间线、状态驱动操作、半透明状态标签——每一处都比 App 13 更进一步。系列化 demo 的价值正在于此同一个模板记录列表每个版本加一个能力读者能直观看到如何从初级进化到高级。八、总结本页的可复用模板Header我的预约 胶囊筛选 Tab4 个 Scroll( 统计概览3 栏 区块标题 时间线记录卡片 状态操作 )核心可复用点Tab id 状态值的筛选设计activeTab直接当 status 过滤时间线组件圆点 竖线 记录卡状态驱动操作if (r.status N)分支渲染不同按钮半透明状态标签文字色实色 背景色降透明这套模板可复用到订单列表、工单列表、维修记录、快递记录、请假记录——任何多状态 时间线 操作的场景。九、时间线 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 有断线风险。真实项目若记录高度不固定建议左列独立时间轴每行左侧固定宽度的圆点竖线列竖线layoutWeight(1)撑满整行高度——竖线永远连续不断按需显示连接只有相邻记录时间间隔合理时才画竖线避免08-14 和 08-03 隔 11 天还连着时间线要表达顺序感列表要表达平级感——选择形态前先想清楚数据关系。十、Record 数据模型的状态枚举化当前Record接口用status: number1/2/3statusText: stringstatusColor: 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 // 已取消 }好处有三可读性r.status BookingStatus.Pending比r.status 1明确类型安全枚举值拼错会编译报错裸数字拼错r.status 4编译通过但逻辑错误可维护新增状态如已过期只加枚举成员编译器帮你找出所有引用点statusText和statusColor也应该从 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「运动场地预约」预约记录页解析完毕。真筛选 时间线 状态操作让这个页面成为本系列列表页的进阶范本。