在 HarmonyOS 的 ArkTS 声明式开发框架中,构建一个功能完备的应用远不止堆砌 UI 组件那么简单。本文将以一个"时光博物馆"应用为案例,深入剖析从接口类型定义、全局静态数据管理、多 Tab 子组件封装、主组件状态编排,到十五种弹框交互的全链路实现。读者将看到 @Component@State@Builder@Entry 等装饰器的实战用法,以及 ColumnRowScrollForEachFlexAlignStack 等核心布局容器如何协同工作,最终产出一个结构清晰、交互丰富的完整页面。

一、技术背景与整体架构概述

HarmonyOS 的应用开发语言 ArkTS 是在 TypeScript 基础上扩展而来的。它保留了 TypeScript 的类型系统优势,同时引入了声明式 UI 范式。在 ArkTS 中,开发者使用 @Component 装饰器标注一个自定义组件,使用 @Entry 标注入口组件,使用 @State 管理组件内部可变状态,使用 @Builder 定义可复用的 UI 构建函数。这些装饰器构成了 ArkTS 声明式 UI 的核心基石。

本应用的整体架构可以概括为"一个主组件 + 七个子 Tab + 十五个弹框"的模式。主组件 Index 作为应用的入口,持有全局状态并负责协调各 Tab 之间的切换、弹框的弹出与关闭、Toast 消息的展示。七个 Tab 子组件(首页、展厅、藏品、年代、旧物、活动、我的)各自封装独立的列表或网格视图,通过回调函数与主组件通信。十五个弹框则覆盖了从详情查看、表单填写、警示确认到结果展示的完整交互流程。

主组件 Index @Entry

首页 HomeTab

展厅 HallTab

藏品 RelicTab

年代 EraTab

旧物 OldGoodsTab

活动 EventTab

我的 MineTab

弹框系统 x15

Toast 通知

在这里插入图片描述

从数据流的角度看,本应用采用了"全局常量数据 + 子组件回调通信"的模式。所有业务数据以 const 数组的形式定义在文件顶层,通过辅助函数进行分片和过滤后传入各子组件。子组件不直接修改全局数据,而是通过回调函数(如 onOpenReliconBookonDel)将用户操作事件上报给主组件,由主组件修改 @State 状态来驱动 UI 更新。这种单向数据流的设计确保了状态的可预测性。

在视觉设计上,整个应用采用了一套统一的复古棕色调色方案。从 #3E2723(最深棕)到 #EFEBE9(最浅棕),六个色阶贯穿所有页面。配合 linearGradient 线性渐变、shadow 阴影投射、borderRadius 圆角处理,营造出温暖、怀旧、有质感的视觉氛围。每个数据项还通过 Emoji 字符作为图标替代方案,既省去了图片资源的加载开销,又保持了跨平台的一致性。

值得注意的是,本应用没有使用任何动画 API(如 animateToanimation),仅在弹框出现/消失时使用了 TransitionEffect.OPACITY 过渡效果。这说明即使没有复杂动画,通过精心设计的布局、色彩和层次结构,依然可以产出视觉表现力强的页面。

二、类型定义:接口驱动的数据模型

在 ArkTS 中,interface 是定义数据结构的标准方式。与 TypeScript 的 interface 不同,ArkTS 的 interface 更接近于结构化类型的严格约束。本应用定义了九个接口,分别对应九种业务实体。

2.1 展厅与藏品类型

interface HallItem {
  id: number
  name: string
  theme: string
  intro: string
  open: string
  fee: number
}

interface RelicItem {
  id: number
  name: string
  era: string
  cat: string
  desc: string
  hot: number
}

在这里插入图片描述

HallItem 接口定义了展厅实体的结构。其中 id 是唯一标识,name 是展厅名称,theme 是主题标签(如"老电影"、“童年零食”),intro 是简介文本,open 是开放时间描述,fee 是门票价格(0 表示免费)。这六个字段共同刻画了一个展厅的完整信息画像。

RelicItem 接口定义了藏品(老物件)实体的结构。era 字段记录年代信息(如"70年代"),cat 是分类标签(如"家用电器"、“交通工具”),desc 是描述性文字,hot 是热度值(一个数字,用于排序或展示)。通过这些字段,每件藏品都能被精确定位到它的历史坐标和分类体系。

在 ArkTS 的类型系统中,interface 的字段必须显式声明类型,不支持 TypeScript 中的可选属性(?)语法。这意味着所有字段在构造对象时必须全部赋值。这种严格约束虽然增加了初始化代码量,但有效避免了运行时的 undefined 访问错误,特别适合大型应用的数据管理。

2.2 年代、旧物与活动类型

interface EraItem {
  id: number
  name: string
  year: string
  desc: string
  color: string
}

interface OldGood {
  id: number
  name: string
  cat: string
  price: number
  owner: string
  status: string
  desc: string
}

interface RetroEvent {
  id: number
  title: string
  date: string
  place: string
  quota: number
  joined: number
  reward: string
}

在这里插入图片描述

EraItem 代表一个年代节点。color 字段存储了该年代对应的主题色(十六进制颜色值),用于在时间轴和柱状图中做视觉区分。这种"数据携带颜色"的设计使得 UI 层只需读取字段即可完成着色,无需额外的映射逻辑。

OldGood 接口描述了旧物集市中的流转物品。owner 记录物品主人,status 表示流转状态(“在售”、“已售”、“已下架”),price 是价格。RetroEvent 则定义了怀旧活动,quota 是名额上限,joined 是已报名人数,reward 是参与奖励。这两个接口共同支撑了集市和活动两个核心功能模块。

通过将 quotajoined 都设计为 number 类型,应用可以在 UI 层直接进行数值比较(如 ev.joined >= ev.quota 来判断是否已满),这比字符串比较更高效也更安全。同时,reward 作为字符串字段允许自由描述奖励内容,灵活性更高。

2.3 零食、收藏、预约与访问统计类型

interface SnackItem {
  id: number
  name: string
  price: number
  taste: string
  stock: string
}

interface CollectItem {
  id: number
  name: string
  cat: string
  date: string
}

interface BookItem {
  id: number
  hall: string
  date: string
  slot: string
  status: string
}

interface EraVisit {
  era: string
  count: number
}

在这里插入图片描述

SnackItem 描述童年零食,taste 是口味标签,stock 是库存状态文本。CollectItem 是用户收藏记录,date 是收藏日期。BookItem 是参观预约记录,slot 是时间段(如"14:00-15:30"),status 是预约状态。EraVisit 则是年代访问统计数据,era 是年代简写,count 是参观人次。

这些接口的设计有一个共同特点:字段精简但语义完整。每个字段都有明确的业务含义,没有冗余的通用字段。这种"一个接口对应一个业务场景"的设计原则,使得代码的可读性和可维护性都很高。当需求变更时,开发者可以快速定位到对应的接口进行修改,而不会影响到其他业务模块。

在 ArkTS 的开发实践中,建议在项目初期就完成所有 interface 的定义。这不仅有助于理清数据模型,还能在编写组件代码时获得完整的类型提示,大幅提升开发效率。

三、全局静态数据:以常量数组构建数据源

本应用的所有业务数据都以 const 数组的形式定义在文件顶层。这种"硬编码数据"的方式在原型开发和演示场景中非常实用——无需后端 API、无需数据库,打开应用即可看到完整内容。

3.1 展厅与藏品数据

const HALL_LIST: HallItem[] = [
  { id: 1, name: '时光放映厅', theme: '老电影', intro: '老式放映机与胶片的摩登回响', open: '每日 10:00-20:00', fee: 30 },
  { id: 2, name: '街角杂货铺', theme: '童年零食', intro: '复刻 80 年代小卖部的玻璃柜台', open: '每日 10:00-20:00', fee: 20 },
  { id: 3, name: '收音机博物馆', theme: '广播记忆', intro: '从矿石机到录音机的岁月留声', open: '每周三-周日', fee: 25 },
  { id: 4, name: '老照片展廊', theme: '城市影像', intro: '泛黄底片里的城市变迁', open: '每日 9:00-21:00', fee: 0 },
  { id: 5, name: '连环画长廊', theme: '小人书', intro: '两万册小人书与儿时英雄梦', open: '周末 10:00-18:00', fee: 15 },
  { id: 6, name: '缝纫机工作坊', theme: '手作记忆', intro: '脚踩缝纫机与外婆的针线盒', open: '周末 14:00-17:00', fee: 0 }
]

在这里插入图片描述

HALL_LIST 数组包含六个展厅的完整信息。每个展厅的 theme 字段值会被 getHallEmoji 函数映射为对应的 Emoji 图标。fee 为 0 的展厅(老照片展廊、缝纫机工作坊)在 UI 中会显示"免费",而其他展厅则显示"¥" + 价格。

在 ArkTS 中,const 声明的数组虽然不可重新赋值(不能 HALL_LIST = [...]),但数组元素本身是可读的。由于本应用不修改这些数据(所有修改操作只产生 Toast 提示而不真正变更数据),使用 const 是安全的。如果未来需要支持数据的增删改,可以考虑使用 @State@Provide/@Consume 来管理可变数据。

const RELIC_LIST: RelicItem[] = [
  { id: 1, name: '牡丹牌缝纫机', era: '70年代', cat: '家用电器', desc: '外婆的嫁妆,机身漆面依旧油亮。', hot: 96 },
  { id: 2, name: '永久牌自行车', era: '80年代', cat: '交通工具', desc: '二八大杠,后座载过整个童年。', hot: 98 },
  { id: 3, name: '海鸥牌照相机', era: '80年代', cat: '影像设备', desc: '手动过片,按下快门有清脆回响。', hot: 88 },
  { id: 4, name: '红灯牌收音机', era: '70年代', cat: '影音设备', desc: '拧动旋钮的滋滋声是最早的广播记忆。', hot: 90 },
  { id: 5, name: '英雄牌钢笔', era: '60年代', cat: '文具', desc: '爸爸的办公桌抽屉里总有一支。', hot: 82 },
  { id: 6, name: '老式搪瓷缸', era: '60年代', cat: '生活用品', desc: '磕掉瓷的地方露出黑色铁胎。', hot: 78 },
  { id: 7, name: '黑白电视机', era: '80年代', cat: '影音设备', desc: '全村围坐看《西游记》的夏夜。', hot: 99 },
  { id: 8, name: '磁带电唱机', era: '90年代', cat: '影音设备', desc: 'A 面听完翻 B 面的仪式感。', hot: 86 },
  { id: 9, name: '粮票与布票', era: '60年代', cat: '票证', desc: '计划经济时代的硬通货。', hot: 84 },
  { id: 10, name: 'BP 机', era: '90年代', cat: '通讯设备', desc: '腰间震动一下,回个电话亭电话。', hot: 80 },
  { id: 11, name: '复读机', era: '00年代', cat: '影音设备', desc: '英语磁带倒带机的咔哒声。', hot: 75 },
  { id: 12, name: '大哥大', era: '90年代', cat: '通讯设备', desc: '一块砖头,一份排面。', hot: 83 }
]

RELIC_LIST 包含十二件藏品,覆盖了从 60 年代到 00 年代的时间跨度。hot 字段(热度值)在 75 到 99 之间分布,在首页"镇馆之宝"模块中用于展示热门藏品。cat 字段的值被 getRelicEmoji 函数映射为不同 Emoji,如"家用电器"映射为缝纫机图标,"交通工具"映射为自行车图标,实现了无需图片资源的视觉区分。

3.2 年代、旧物与活动数据

const ERA_LIST: EraItem[] = [
  { id: 1, name: '1960s', year: '1960-1969', desc: '粮票布票,集体生活的朴素年代', color: '#6D4C41' },
  { id: 2, name: '1970s', year: '1970-1979', desc: '三转一响,自行车缝纫机手表收音机', color: '#8D6E63' },
  { id: 3, name: '1980s', year: '1980-1989', desc: '改革开放,黑白电视与迪斯科', color: '#A1887F' },
  { id: 4, name: '1990s', year: '1990-1999', desc: '港片黄金时代,磁带走四方', color: '#BCAAA4' },
  { id: 5, name: '2000s', year: '2000-2009', desc: 'MP3 与网吧,互联网呼啸而至', color: '#D7CCC8' },
  { id: 6, name: '2010s', year: '2010-2019', desc: '智能手机普及,生活方式剧变', color: '#EFEBE9' }
]

ERA_LIST 定义了六个年代节点。值得注意的是 color 字段从 #6D4C41(深棕)到 #EFEBE9(近白棕)逐渐变浅,形成了一个渐变色阶。这种设计使得年代时间轴在视觉上呈现出从"沉厚历史"到"现代轻盈"的色彩过渡,非常有表现力。

在 ArkTS 的颜色使用中,十六进制色值是最常用的格式。通过规划一套色阶体系(如本应用从 #3E2723#EFEBE9 的棕色系),可以确保整个应用的视觉一致性,避免随意取色造成的杂乱感。

const OLD_GOODS: OldGood[] = [
  { id: 1, name: '老式木质收音机', cat: '影音', price: 120, owner: '老周', status: '在售', desc: '外观完好,通电有杂音可修' },
  { id: 2, name: '凤凰牌二八大杠', cat: '出行', price: 680, owner: '阿明', status: '在售', desc: '原装车架,可正常骑行' },
  { id: 3, name: '海鸥双反相机', cat: '摄影', price: 980, owner: '小林', status: '已售', desc: '快门正常,缺皮套' },
  { id: 4, name: '全套小人书 60 本', cat: '书籍', price: 300, owner: '大刘', status: '在售', desc: '品相七成新,含三国系列' },
  { id: 5, name: '磁带 30 盘', cat: '影音', price: 99, owner: '小陈', status: '在售', desc: '邓丽君/小虎队/四大天王' },
  { id: 6, name: '搪瓷缸套装', cat: '生活', price: 45, owner: '王姐', status: '在售', desc: '带盖搪瓷缸,字迹清晰' },
  { id: 7, name: '老式台灯', cat: '生活', price: 88, owner: '老张', status: '已下架', desc: '绿色灯罩,需换灯线' },
  { id: 8, name: '飞人牌缝纫机', cat: '家用', price: 520, owner: '李阿姨', status: '在售', desc: '可正常缝纫,带原装木架' },
  { id: 9, name: '军用水壶', cat: '户外', price: 60, owner: '退伍老兵', status: '在售', desc: '1960 年代制式,包浆自然' },
  { id: 10, name: '老式座钟', cat: '生活', price: 350, owner: '赵叔', status: '在售', desc: '整点报时清脆,走时准确' }
]

OLD_GOODS 数组定义了十件旧物集市商品。status 字段有三种取值:“在售”(绿色 #43A047)、“已售”(灰色 #9E9E9E)、“已下架”(橙色 #F57C00)。这些颜色通过 getGoodStatusColor 函数映射,在列表中以标签形式展示,让用户一眼就能分辨物品的流转状态。

const EVENT_LIST: RetroEvent[] = [
  { id: 1, title: '怀旧电影放映夜', date: '每周五 19:30', place: '时光放映厅', quota: 60, joined: 48, reward: '老电影海报' },
  { id: 2, title: '童年零食品鉴会', date: '9 月第一个周六', place: '街角杂货铺', quota: 40, joined: 36, reward: '零食盲盒' },
  { id: 3, title: '老照片修复工作坊', date: '每周日 14:00', place: '老照片展廊', quota: 20, joined: 12, reward: '修复套装' },
  { id: 4, title: '连环画交换市集', date: '每月第三个周末', place: '连环画长廊', quota: 80, joined: 55, reward: '签名画册' },
  { id: 5, title: '磁带交换日', date: '每月第二个周五', place: '收音机博物馆', quota: 50, joined: 30, reward: '定制磁带' },
  { id: 6, title: '缝纫机手作课', date: '每周六 15:00', place: '缝纫机工作坊', quota: 15, joined: 15, reward: '布艺作品' },
  { id: 7, title: '年代歌会', date: '10 月 1 日 19:00', place: '园区草坪', quota: 200, joined: 168, reward: '复古徽章' },
  { id: 8, title: '老物件鉴定会', date: '9 月 15 日 10:00', place: '大礼堂', quota: 100, joined: 76, reward: '鉴定证书' }
]

EVENT_LIST 定义了八场怀旧活动。注意第 6 条"缝纫机手作课"的 joined 等于 quota(15/15),表示名额已满。在活动列表和活动详情弹框中,应用会通过 ev.joined >= ev.quota 判断来展示"已满"而非"报名"按钮,并使用灰色背景替代棕色背景,实现了基于数据状态的 UI 自适应。

3.3 零食、收藏、预约与访问统计数据

const SNACK_LIST: SnackItem[] = [
  { id: 1, name: '大大泡泡糖', price: 0.5, taste: '果味', stock: '热销' },
  { id: 2, name: '无花果丝', price: 1, taste: '酸甜', stock: '热销' },
  { id: 3, name: '跳跳糖', price: 0.8, taste: '爆炸', stock: '补货中' },
  { id: 4, name: '麦丽素', price: 2, taste: '巧克力', stock: '热销' },
  { id: 5, name: '辣条·卫龙', price: 1.5, taste: '香辣', stock: '热销' },
  { id: 6, name: '小浣熊干脆面', price: 1, taste: '烤肉味', stock: '限量' },
  { id: 7, name: '北冰洋汽水', price: 4, taste: '桔子', stock: '热销' },
  { id: 8, name: '大白兔奶糖', price: 3, taste: '奶香', stock: '热销' }
]

const COLLECT_LIST: CollectItem[] = [
  { id: 1, name: '黑白电视机', cat: '藏品', date: '2026-08-18' },
  { id: 2, name: '永久牌自行车', cat: '藏品', date: '2026-08-10' },
  { id: 3, name: '海鸥双反相机', cat: '旧物', date: '2026-08-02' },
  { id: 4, name: '磁带 30 盘', cat: '旧物', date: '2026-07-28' },
  { id: 5, name: '粮票与布票', cat: '藏品', date: '2026-07-20' },
  { id: 6, name: '搪瓷缸套装', cat: '旧物', date: '2026-07-15' },
  { id: 7, name: '英雄牌钢笔', cat: '藏品', date: '2026-07-08' },
  { id: 8, name: '老式台灯', cat: '旧物', date: '2026-06-30' }
]

SNACK_LISTprice 字段使用了小数(如 0.5、0.8),这在 ArkTS 中是完全合法的——number 类型可以同时容纳整数和浮点数。在 UI 展示时通过 '¥' + String(sn.price) 拼接,会自动将数字转为字符串。COLLECT_LISTcat 字段只有"藏品"和"旧物"两种值,分别映射为不同的 Emoji 图标。

const BOOK_LIST: BookItem[] = [
  { id: 1, hall: '时光放映厅', date: '2026-09-12', slot: '19:30-21:30', status: '已确认' },
  { id: 2, hall: '街角杂货铺', date: '2026-09-13', slot: '15:00-16:30', status: '已确认' },
  { id: 3, hall: '收音机博物馆', date: '2026-08-30', slot: '14:00-15:30', status: '已完成' },
  { id: 4, hall: '老照片展廊', date: '2026-08-22', slot: '10:00-11:30', status: '已完成' },
  { id: 5, hall: '连环画长廊', date: '2026-09-21', slot: '10:00-12:00', status: '待确认' },
  { id: 6, hall: '缝纫机工作坊', date: '2026-08-15', slot: '14:00-16:00', status: '已取消' },
  { id: 7, hall: '老照片展廊', date: '2026-07-25', slot: '16:00-17:30', status: '已完成' },
  { id: 8, hall: '时光放映厅', date: '2026-07-18', slot: '19:30-21:30', status: '已完成' }
]

const ERA_VISITS: EraVisit[] = [
  { era: '60', count: 180 },
  { era: '70', count: 240 },
  { era: '80', count: 420 },
  { era: '90', count: 380 },
  { era: '00', count: 260 },
  { era: '10', count: 150 }
]

BOOK_LISTstatus 有四种状态:已确认、已完成、待确认、已取消。只有"待确认"状态的预约才显示"取消"按钮(其他状态显示空字符串),这是通过三元运算符 bk.status === '待确认' ? '取消' : '' 实现的。ERA_VISITS 则是六个年代的参观人次数据,80 年代以 420 人次居首,用于在首页绘制柱状图。

四、全局辅助函数:数据分片与工具映射

4.1 数据分片函数

本应用的一个显著设计模式是通过辅助函数对全局数据进行分片。这是因为某些 Tab 页面需要将数据分成多列或多组展示(如三列网格、两组列表),而这些分片逻辑被封装为独立函数以提高代码复用性。

function getHallRows(): HallItem[] {
  return [HALL_LIST[0], HALL_LIST[1], HALL_LIST[2]];
}

function getHallRows2(): HallItem[] {
  return [HALL_LIST[3], HALL_LIST[4], HALL_LIST[5]];
}

getHallRowsgetHallRows2 将六个展厅分为两组,每组三个。这种分片方式在展厅 Tab 中通过两次 ForEach 调用分别渲染。虽然也可以用一个函数返回全部数据再在 UI 中分列,但分成两个独立函数使得数据流更加清晰——读者一眼就能看出第一组包含哪些展厅,第二组包含哪些。

function getRelicRows(): RelicItem[] {
  return [RELIC_LIST[0], RELIC_LIST[3], RELIC_LIST[6], RELIC_LIST[9]];
}

function getRelicRows2(): RelicItem[] {
  return [RELIC_LIST[1], RELIC_LIST[4], RELIC_LIST[7], RELIC_LIST[10]];
}

function getRelicRows3(): RelicItem[] {
  return [RELIC_LIST[2], RELIC_LIST[5], RELIC_LIST[8], RELIC_LIST[11]];
}

藏品的分片采用了"列式分片"策略:十二件藏品按索引取模分为三列(0,3,6,9 / 1,4,7,10 / 2,5,8,11),每列四件。这种方式确保了三列网格在视觉上的均衡分布。在 RelicTab 组件中,三个 Column 各自 layoutWeight(1) 等分宽度,每个 Column 内部通过 ForEach 渲染对应分片的数据,形成三列四行的瀑布流布局。

在 ArkTS 的 ForEach 渲染中,数据分片是一种优化策略。当数据量较大时,将单一列表拆分为多个 ForEach 可以减少单个渲染列表的复杂度,有助于提升滚动性能。但需要注意,分片后每个 ForEach 仍然需要提供唯一的 key 生成函数。

4.2 Emoji 映射函数

function getRelicEmoji(c: string): string {
  if (c === '家用电器') {
    return '🧵';
  }
  if (c === '交通工具') {
    return '🚲';
  }
  if (c === '影像设备') {
    return '📷';
  }
  if (c === '影音设备') {
    return '📻';
  }
  if (c === '文具') {
    return '🖋';
  }
  if (c === '生活用品') {
    return '🫖';
  }
  if (c === '票证') {
    return '🎟';
  }
  if (c === '通讯设备') {
    return '📟';
  }
  return '📼';
}

getRelicEmoji 函数接收分类字符串,返回对应的 Emoji 字符。这里使用了连续的 if 语句而非 switch 或对象映射表。在 ArkTS 中,这种写法虽然冗长但可读性强——每个映射关系一目了然。函数的默认返回值是 '📼'(录像带),用于兜底未匹配的分类。

Emoji 映射函数在本应用中有多个变体:getHallEmoji(按展厅主题映射)、getGoodEmoji(按旧物分类映射)、getEventEmoji(按活动标题关键词匹配,使用 indexOf 实现模糊匹配)、getSnackEmoji(按零食名称关键词匹配)等。这些函数共同构成了应用的"图标系统",用纯文本字符替代了图片资源,大大减小了应用体积。

4.3 颜色与状态映射函数

function getBarHeight(v: number): number {
  return 16 + v * 0.16;
}

function getEraColor(c: string): string {
  if (c === '60') {
    return '#6D4C41';
  }
  if (c === '70') {
    return '#8D6E63';
  }
  if (c === '80') {
    return '#A1887F';
  }
  if (c === '90') {
    return '#BCAAA4';
  }
  if (c === '00') {
    return '#D7CCC8';
  }
  return '#EFEBE9';
}

function getGoodStatusColor(s: string): string {
  if (s === '在售') {
    return '#43A047';
  }
  if (s === '已售') {
    return '#9E9E9E';
  }
  return '#F57C00';
}

function getBookStatusColor(s: string): string {
  if (s === '已确认' || s === '已完成') {
    return '#8D6E63';
  }
  if (s === '待确认') {
    return '#F57C00';
  }
  return '#BDBDBD';
}

getBarHeight 是一个数学转换函数,将参观人次(如 420)映射为柱状图的高度像素值。公式 16 + v * 0.16 确保了最小高度为 16vp(即使参观人次为 0 也有一个可见的柱子),最大高度约为 16 + 420 * 0.16 ≈ 83vp,控制在 100vp 的容器高度内。

getEraColorgetGoodStatusColorgetBookStatusColor 都是状态到颜色的映射函数。这种设计将"颜色决策"从组件代码中抽离出来,使得组件代码更专注于布局和结构。如果未来需要调整配色方案,只需修改这些函数即可,无需在组件代码中逐个搜索替换。

查看详情

表单提交

删除/取消

用户操作

操作类型?

设置 selXxx + showXxxDetail = true

设置表单状态 + showXxxForm = false

弹出警示弹框

弹框渲染

Toast 提示

用户关闭弹框

showXxx = false

弹框消失

1.6秒后 toast 清空

4.4 选项列表函数

function getSlotList(): string[] {
  return ['10:00-11:30', '14:00-15:30', '16:00-17:30', '19:30-21:30'];
}

function getQuotaList(): number[] {
  return [1, 2, 3, 4];
}

function getPayLevels(): number[] {
  return [20, 50, 100, 200];
}

function getPayGifts(): string[] {
  return ['纪念书签', '老照片明信片', '怀旧零食包', '定制胶卷钥匙扣'];
}

这些函数返回的是表单选项列表。在预约弹框中,getSlotList 提供四个可选时段,getQuotaList 提供四个可选参观人数。在积分兑换弹框中,getPayLevelsgetPayGifts 分别提供四个档位的积分值和对应奖励。这些选项通过 ForEach 渲染为一行可选按钮,当前选中项使用深色背景,未选中项使用浅色背景。

将选项数据封装为函数而非直接内联在组件 build 方法中,有两个好处:一是保持 build 方法的简洁性,二是这些选项可以在多个组件间复用。在 ArkTS 中,build 方法应当只包含 UI 声明代码,业务逻辑和数据准备应当抽取到方法或函数中。

五、首页 Tab 组件:信息聚合与可视化

5.1 首页胶片横幅

@Component
struct HomeTab {
  onOpenRelic: (id: number) => void = () => {
  }
  onOpenEvent: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 胶片横幅
          Column() {
            Row() {
              Text('🎞')
                .fontSize(28)
              Text('')
                .layoutWeight(1)
              Text('1960 → 2019')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD54F')
            }
            .width('100%')
            Text('时光博物馆')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .width('100%')
              .margin({ top: 10 })
            Text('员工怀旧馆 · 把回忆装进橱窗')
              .fontSize(12)
              .fontColor('#EFEBE9')
              .width('100%')
              .margin({ top: 6 })
            Row() {
              Text('本周开放 6 个展厅')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor('rgba(255,255,255,0.15)')
                .borderRadius(12)
              Text('')
                .layoutWeight(1)
              Text('🎫 预约参观')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor('#5D4037')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor('#FFD54F')
                .borderRadius(12)
                .onClick(() => {
                  this.onToast('前往展厅预约');
                })
            }
            .width('100%')
            .margin({ top: 14 })
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 18, bottom: 18 })
          .linearGradient({ angle: 135, colors: [['#3E2723', 0], ['#5D4037', 0.6], ['#795548', 1]] })
          .borderRadius(16)
          .margin({ left: 16, right: 16, top: 12 })
          .shadow({ radius: 10, color: 'rgba(62,39,35,0.35)', offsetY: 5 })

HomeTab 是首页子组件,使用 @Component 装饰器声明。它定义了三个回调函数属性:onOpenReliconOpenEventonToast,这些回调由父组件 Index 在创建 HomeTab 实例时传入。这是 ArkTS 中子组件向父组件通信的标准模式——子组件不持有对父组件的引用,而是通过回调函数上报事件。

在 ArkTS 中,@Component 标注的自定义组件可以定义任意属性和方法。当这些属性是函数类型时,它们就充当了"回调接口"的角色。父组件在实例化子组件时传入具体的函数实现,实现了依赖反转——子组件不依赖父组件的具体类型,只依赖函数签名。

横幅区域的视觉设计非常考究。linearGradient 使用了三段渐变(#3E2723#5D4037#795548),角度为 135 度(从左上到右下),营造了从深到浅的立体感。shadow 属性添加了 offsetY: 5 的向下偏移阴影,配合 borderRadius: 16 的圆角,使横幅看起来像一张悬浮的卡片。

横幅内部的布局使用了多个 RowColumn 的嵌套。顶部 Row 包含胶片图标、占位 TextlayoutWeight(1) 撑开空间)和年代范围文本。中间是两行标题文字。底部 Row 包含状态标签和预约按钮,按钮使用 #FFD54F 黄色背景与深棕色文字形成强对比。Text('').layoutWeight(1) 是 ArkTS 中实现"弹性占位"的常用技巧——一个空文本占据剩余空间,将两侧元素推向两端。

5.2 快捷入口宫格

          // 宫格
          Row() {
            ForEach(getQuickEntries(), (qe: string, qi: number) => {
              Column() {
                Text(getQuickIcon(qi))
                  .fontSize(22)
                Text(qe)
                  .fontSize(10)
                  .fontColor('#616161')
                  .margin({ top: 5 })
              }
              .layoutWeight(1)
              .padding({ top: 10, bottom: 10 })
              .onClick(() => {
                this.onToast(qe + ' 功能开发中');
              })
            }, (qe: string) => qe)
          }
          .width('100%')
          .padding({ left: 8, right: 8, top: 12, bottom: 12 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 12 })

快捷入口宫格通过 Row + ForEach + Column 的组合实现。getQuickEntries() 返回八个入口名称,ForEach 为每个入口渲染一个 Column(图标 + 文字纵向排列),每个 Column 通过 layoutWeight(1) 等分宽度。这就是典型的"横向均分网格"布局模式。

ForEach 的第三个参数是 key 生成函数 (qe: string) => qe,使用入口名称作为唯一标识。在 ArkTS 中,ForEachkey 函数用于 Diff 算法——当数据变化时,框架通过 key 判断哪些元素是新增的、哪些被删除的,从而进行最小化更新。使用业务字段(如名称或 ID)作为 key 是最佳实践。

ForEach 是 ArkTS 中最核心的列表渲染指令。它接收三个参数:数据源数组、子项渲染函数(itemGenerator)、键值生成函数(keyGenerator)。当数据源变化时,ForEach 会根据 key 进行 Diff,仅更新变化的子项,避免全量重绘,这是保证列表滚动性能的关键机制。

5.3 镇馆之宝横滑列表

          Scroll() {
            Row() {
              ForEach(getHomeRelics(), (rl: RelicItem) => {
                Column() {
                  Text(getRelicEmoji(rl.cat))
                    .fontSize(30)
                    .width('100%')
                    .height(72)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#EFEBE9')
                    .borderRadius(12)
                  Text(rl.name)
                    .fontSize(12)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#333333')
                    .width('100%')
                    .margin({ top: 6 })
                    .maxLines(1)
                  Text(rl.era + ' · 热度 ' + String(rl.hot))
                    .fontSize(10)
                    .fontColor('#9E9E9E')
                    .width('100%')
                    .margin({ top: 3 })
                }
                .width(140)
                .padding(10)
                .backgroundColor('#FFFFFF')
                .borderRadius(12)
                .margin({ right: 10 })
                .onClick(() => {
                  this.onOpenRelic(rl.id);
                })
              }, (rl: RelicItem) => String(rl.id))
            }
            .padding({ left: 16 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .margin({ top: 10 })

这是一个典型的横向滚动列表。外层 Scroll 设置 scrollable(ScrollDirection.Horizontal) 启用横向滚动,scrollBar(BarState.Off) 隐藏滚动条。内部是一个 Row 容器,通过 ForEach 渲染藏品卡片。每张卡片固定宽度 140vp,不会随屏幕变化——这是横向滚动列表的标准做法,子项必须有固定宽度才能正确计算滚动范围。

点击卡片时调用 this.onOpenRelic(rl.id),将藏品 ID 上报给父组件。父组件收到后通过 findRelic(id) 查找完整数据并打开详情弹框。这种"子组件只传 ID,父组件查找完整数据"的设计,避免了子组件需要持有全部数据的冗余。

maxLines(1) 是一个实用的文本属性,限制文本最多一行,超出部分自动省略。在卡片式布局中,名称长度不可控时使用 maxLines(1) 可以防止文本换行导致的布局错乱。

5.4 年代人气柱状图

          Row() {
            ForEach(getVisits(), (v: EraVisit) => {
              Column() {
                Text(String(v.count))
                  .fontSize(9)
                  .fontColor('#5D4037')
                Column() {
                }
                .width(20)
                .height(getBarHeight(v.count))
                .backgroundColor(getEraColor(v.era))
                .borderRadius({ topLeft: 4, topRight: 4 })
                .margin({ top: 2 })
                Text(v.era + 's')
                  .fontSize(9)
                  .fontColor('#9E9E9E')
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.End)
              .height(100)
            }, (v: EraVisit) => v.era)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 10 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 10 })

这是一个纯 CSS/ArkTS 实现的简易柱状图。每个柱子是一个空 ColumnColumn() {}),通过 height(getBarHeight(v.count)) 动态设置高度,backgroundColor(getEraColor(v.era)) 设置对应年代的颜色。柱子顶部使用 borderRadius({ topLeft: 4, topRight: 4 }) 只圆角化上方两角,模拟真实柱状图的视觉效果。

每个柱子的外层 Column 设置了 alignItems(HorizontalAlign.Center) 实现水平居中,justifyContent(FlexAlign.End) 实现垂直方向从底部对齐——这样矮柱子也会贴底显示,高柱子则顶到上方。FlexAlign 枚举在 ArkTS 中控制 Flex 容器的主轴对齐方式,FlexAlign.End 表示子项向主轴终点对齐。

FlexAlign 是 ArkTS 中 Flex 布局的核心枚举,包含 StartCenterEndSpaceBetweenSpaceAroundSpaceEvenly 等值。在 Column 容器中,主轴方向是垂直的,因此 FlexAlign.End 使子元素向底部对齐。在 Row 容器中,主轴方向是水平的,FlexAlign.End 则使子元素向右对齐。

5.5 近期活动列表

          Column() {
            ForEach(getHomeEvents(), (ev: RetroEvent) => {
              Row() {
                Text(getEventEmoji(ev.title))
                  .fontSize(20)
                  .width(42)
                  .height(42)
                  .textAlign(TextAlign.Center)
                  .backgroundColor('#EFEBE9')
                  .borderRadius(10)
                Column() {
                  Text(ev.title)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#333333')
                    .maxLines(1)
                  Text(ev.date + ' · ' + ev.place)
                    .fontSize(11)
                    .fontColor('#9E9E9E')
                    .margin({ top: 3 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 10 })
                Text('去看看')
                  .fontSize(10)
                  .fontColor('#5D4037')
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .backgroundColor('#EFEBE9')
                  .borderRadius(10)
                  .onClick(() => {
                    this.onOpenEvent(ev.id);
                  })
              }
              .width('100%')
              .padding(10)
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
              .margin({ bottom: 8 })
            }, (ev: RetroEvent) => String(ev.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 16 })

活动列表项使用了经典的"左图标 + 中信息 + 右按钮"三段式布局。Row 作为容器,左侧是一个 42x42vp 的方形 Emoji 图标区域(浅棕背景 + 圆角),中间是 Column 包裹的标题和副标题(layoutWeight(1) 撑满剩余空间),右侧是"去看看"按钮。

这种三段式布局在 ArkTS 中极为常见,适用于任何"图标 + 内容 + 操作"的场景。核心技巧是用 Text('').layoutWeight(1) 或带有 layoutWeight(1)Column 作为"弹性中间段",将左右两侧的固定宽度元素推向两端。

六、展厅 Tab 组件:大图卡列表

6.1 展厅列表结构

@Component
struct HallTab {
  onOpenHall: (id: number) => void = () => {
  }
  onBook: (id: number) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🏛 展厅导览')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('6 个展厅')
          .fontSize(11)
          .fontColor('#9E9E9E')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column() {
          Column() {
            ForEach(getHallRows(), (hl: HallItem) => {
              this.hallCard(hl)
            }, (hl: HallItem) => String(hl.id))
            ForEach(getHallRows2(), (hl: HallItem) => {
              this.hallCard(hl)
            }, (hl: HallItem) => String(hl.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F3EE')
  }

HallTab 的结构相对简单:顶部标题行 + 可滚动内容区。标题行使用"左标题 + 占位 + 右辅助文字"的标准模式。内容区使用 Scroll 包裹 Column,通过 layoutWeight(1) 让滚动区域填满除标题行外的所有空间。

这里调用了两次 ForEach——分别传入 getHallRows()getHallRows2() 的返回值,每个 ForEach 内部调用 this.hallCard(hl) 渲染卡片。hallCard 是一个 @Builder 方法,实现了 UI 的复用。在 ArkTS 中,@Builder 装饰的方法可以被多次调用以渲染相同的 UI 结构,类似其他框架中的"模板"或"渲染函数"。

6.2 展厅卡片 Builder

  @Builder
  hallCard(hl: HallItem) {
    Column() {
      Row() {
        Text(getHallEmoji(hl.theme))
          .fontSize(36)
        Text('')
          .layoutWeight(1)
        Text('¥' + String(hl.fee))
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFD54F')
      }
      .width('100%')
      Text(hl.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .margin({ top: 8 })
      Text(hl.intro)
        .fontSize(12)
        .fontColor('#EFEBE9')
        .width('100%')
        .margin({ top: 6 })
      Row() {
        Text(hl.open)
          .fontSize(11)
          .fontColor('#FFD54F')
        Text('')
          .layoutWeight(1)
        Text('预约参观')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5D4037')
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor('#FFD54F')
          .borderRadius(14)
          .onClick(() => {
            this.onBook(hl.id);
          })
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(16)
    .linearGradient({ angle: 135, colors: [['#4E342E', 0], ['#795548', 1]] })
    .borderRadius(14)
    .margin({ bottom: 12 })
    .shadow({ radius: 8, color: 'rgba(62,39,35,0.2)', offsetY: 4 })
    .onClick(() => {
      this.onOpenHall(hl.id);
    })
  }

hallCard 是一个使用 @Builder 装饰的方法,接收一个 HallItem 参数。它构建了一张深棕色渐变背景的大图卡片,包含四层信息:顶部行(Emoji 图标 + 价格)、展厅名称、简介文字、底部行(开放时间 + 预约按钮)。

@Builder 是 ArkTS 中实现 UI 复用的核心机制。与独立组件不同,@Builder 方法可以访问宿主组件的 this 上下文(包括状态变量和方法),因此可以直接在 @Builder 内部使用 this.onBook(hl.id) 调用组件方法。这使得 @Builder 既是"模板"又是"内联组件",兼具灵活性和便利性。

@Builder 方法与 @Component 子组件的选择标准:如果 UI 片段需要在多个不同组件间复用,应封装为独立的 @Component;如果仅在当前组件内部复用,使用 @Builder 更简洁。本应用中,卡片渲染逻辑仅在单个 Tab 内使用,因此选择 @Builder 是恰当的。

卡片的整体点击事件调用 this.onOpenHall(hl.id) 打开展厅详情,而内部"预约参观"按钮的点击事件调用 this.onBook(hl.id) 打开预约弹框。两个 onClick 嵌套但不会冲突——内层按钮的 onClick 会先触发,且不会冒泡到外层卡片的 onClick(ArkTS 的事件不会自动冒泡)。

七、藏品 Tab 组件:三列网格布局

7.1 分类筛选与三列网格

@Component
struct RelicTab {
  onOpenRelic: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('📦 藏品图鉴')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('共 12 件')
          .fontSize(11)
          .fontColor('#9E9E9E')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      // 分类筛选
      Scroll() {
        Column() {
          Row() {
            ForEach(getCatFilter(), (cf: string, ci: number) => {
              Text(cf)
                .fontSize(11)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .backgroundColor(ci === 0 ? '#5D4037' : '#FFFFFF')
                .fontColor(ci === 0 ? '#FFFFFF' : '#616161')
                .borderRadius(12)
                .margin({ right: 8 })
                .onClick(() => {
                  this.onToast('筛选:' + cf);
                })
            }, (cf: string) => cf)
          }
          .width('100%')
          .padding({ left: 16, top: 10 })

          // 三列网格
          Row() {
            Column() {
              ForEach(getRelicRows(), (rl: RelicItem) => {
                this.relicCell(rl)
              }, (rl: RelicItem) => String(rl.id))
            }
            .layoutWeight(1)
            Column() {
              ForEach(getRelicRows2(), (rl: RelicItem) => {
                this.relicCell(rl)
              }, (rl: RelicItem) => String(rl.id))
            }
            .layoutWeight(1)
            Column() {
              ForEach(getRelicRows3(), (rl: RelicItem) => {
                this.relicCell(rl)
              }, (rl: RelicItem) => String(rl.id))
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
          .alignItems(VerticalAlign.Top)
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F3EE')
  }

藏品 Tab 的核心是三列网格布局。不同于使用 Grid 组件,这里通过三个等宽 Column(每个 layoutWeight(1))并排排列来模拟网格效果。每个 Column 内部使用 ForEach 渲染一列数据。这种"Row + 多个等宽 Column"的方式虽然不如 Grid 灵活,但对于固定列数且不需要单元格合并的场景来说,实现更简单、控制更精确。

分类筛选栏通过 ForEach 渲染一行可选标签。当前选中项(索引 0,即"全部")使用深色背景,其余使用白色背景。ci === 0 的判断是硬编码的——在实际应用中,应当使用一个 @State 变量来记录当前选中索引,实现真正的切换效果。本应用此处仅展示 Toast 提示,未实现真实筛选逻辑。

在 ArkTS 中,Row 容器的 alignItems 属性控制子元素在交叉轴(垂直方向)上的对齐方式。设置 alignItems(VerticalAlign.Top) 使三个 Column 从顶部对齐,确保各列的卡片从同一水平线开始排列,视觉上整齐划一。

7.2 藏品单元格 Builder

  @Builder
  relicCell(rl: RelicItem) {
    Column() {
      Text(getRelicEmoji(rl.cat))
        .fontSize(26)
        .width('100%')
        .height(64)
        .textAlign(TextAlign.Center)
        .backgroundColor('#EFEBE9')
        .borderRadius(10)
      Text(rl.name)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .margin({ top: 6 })
        .maxLines(1)
      Text(rl.era)
        .fontSize(9)
        .fontColor('#8D6E63')
        .width('100%')
        .margin({ top: 3 })
      Text('热度 ' + String(rl.hot))
        .fontSize(9)
        .fontColor('#BDBDBD')
        .width('100%')
        .margin({ top: 2 })
    }
    .width('100%')
    .padding(8)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
    .onClick(() => {
      this.onOpenRelic(rl.id);
    })
  }

藏品单元格是一个紧凑的卡片,包含图标区(64vp 高的浅棕背景区 + Emoji 图标)、名称(maxLines(1) 单行显示)、年代(浅棕色文字)、热度(灰色文字)。四层信息自上而下排列,层次分明。

字号从 26(图标)到 9(辅助信息),形成了明显的视觉层次。在 ArkTS 中,合理使用不同的 fontSize 是建立信息层级的有效手段——大字号吸引注意力,小字号提供补充信息,用户扫一眼就能获取核心内容。

八、年代 Tab 组件:垂直时间轴

@Component
struct EraTab {
  onOpenEra: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🕰 年代穿越')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('六十年记忆')
          .fontSize(11)
          .fontColor('#9E9E9E')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column() {
          Column() {
            ForEach(getEraRows(), (er: EraItem) => {
              this.eraRow(er)
            }, (er: EraItem) => String(er.id))
            ForEach(getEraRows2(), (er: EraItem) => {
              this.eraRow(er)
            }, (er: EraItem) => String(er.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F3EE')
  }

  @Builder
  eraRow(er: EraItem) {
    Row() {
      Column() {
        Text('●')
          .fontSize(12)
          .fontColor('#5D4037')
        Text('')
          .width(2)
          .layoutWeight(1)
          .backgroundColor('#D7CCC8')
          .margin({ top: 2 })
      }
      .width(30)
      .alignItems(HorizontalAlign.Center)
      .height(96)

      Column() {
        Row() {
          Text(er.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3E2723')
          Text('')
            .layoutWeight(1)
          Text(er.year)
            .fontSize(11)
            .fontColor('#8D6E63')
        }
        .width('100%')
        Text(er.desc)
          .fontSize(12)
          .fontColor('#757575')
          .width('100%')
          .lineHeight(18)
          .margin({ top: 5 })
        Text('进入年代 >')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5D4037')
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor('#EFEBE9')
          .borderRadius(12)
          .margin({ top: 8 })
          .onClick(() => {
            this.onOpenEra(er.id);
          })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding(12)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ left: 6, bottom: 12 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)
  }
}

年代 Tab 实现了一个垂直时间轴效果。每行使用 Row 分为两部分:左侧 30vp 宽的 Column 作为时间轴线(顶部一个圆点 '●',下方一条 2vp 宽的竖线通过 Text('').width(2).layoutWeight(1).backgroundColor('#D7CCC8') 实现),右侧是信息卡片。

时间轴竖线的实现非常巧妙:一个空的 Text 设置 width(2)layoutWeight(1),在 ColumnlayoutWeight(1) 使其占据圆点之后的所有剩余高度,配合 backgroundColor 形成一条可见的竖线。多个时间轴行排列在一起时,竖线连续不断,形成完整的时间轴视觉效果。

在 ArkTS 中,空 Text 或空 Column 常被用作"占位元素"或"视觉线条"。通过设置宽度/高度和背景色,可以绘制出细线、分隔线等效果。这种技巧虽然不如 SVG 灵活,但在简单场景下非常实用,且不需要额外的渲染资源。

九、旧物 Tab 组件:集市列表与操作按钮

@Component
struct OldGoodsTab {
  onOpenGood: (id: number) => void = () => {
  }
  onAdd: () => void = () => {
  }
  onEdit: (id: number) => void = () => {
  }
  onDel: (id: number) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🧺 旧物集市')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('+ 发布旧物')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor('#5D4037')
          .borderRadius(16)
          .onClick(() => {
            this.onAdd();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column() {
          Text('—— 共 ' + String(OLD_GOODS.length) + ' 件旧物正在流转 ——')
            .fontSize(10)
            .fontColor('#BDBDBD')
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 8, bottom: 4 })
          Column() {
            ForEach(getGoodRows(), (gd: OldGood) => {
              this.goodRow(gd)
            }, (gd: OldGood) => String(gd.id))
            ForEach(getGoodRows2(), (gd: OldGood) => {
              this.goodRow(gd)
            }, (gd: OldGood) => String(gd.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 8, bottom: 16 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F3EE')
  }

旧物 Tab 的标题栏右侧有一个"发布旧物"按钮,这是该 Tab 与其他 Tab 的一个显著区别——它具备"创建"操作的能力。按钮使用 onClick 调用 this.onAdd() 回调,父组件收到后设置表单初始值并打开"发布旧物"弹框。

列表顶部有一行居中的辅助文字 '—— 共 X 件旧物正在流转 ——',通过 String(OLD_GOODS.length) 动态获取旧物总数。textAlign(TextAlign.Center) 确保文字水平居中。这种"分隔线式"的辅助文字在列表设计中很常见,用于提供统计信息。

旧物列表项 Builder

  @Builder
  goodRow(gd: OldGood) {
    Row() {
      Text(getGoodEmoji(gd.cat))
        .fontSize(22)
        .width(46)
        .height(46)
        .textAlign(TextAlign.Center)
        .backgroundColor('#EFEBE9')
        .borderRadius(12)
      Column() {
        Text(gd.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .maxLines(1)
        Text(gd.owner + ' · ' + gd.cat + ' · ' + gd.desc)
          .fontSize(10)
          .fontColor('#9E9E9E')
          .margin({ top: 3 })
          .maxLines(1)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 10 })

      Column() {
        Text(gd.status)
          .fontSize(10)
          .fontColor(getGoodStatusColor(gd.status))
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor('#F5F5F5')
          .borderRadius(8)
        Text('¥' + String(gd.price))
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5D4037')
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.End)
      .margin({ left: 6 })

      Column() {
        Text('✎')
          .fontSize(12)
          .fontColor('#5D4037')
          .padding(4)
          .onClick(() => {
            this.onEdit(gd.id);
          })
        Text('🗑')
          .fontSize(11)
          .fontColor('#E53935')
          .padding(4)
          .onClick(() => {
            this.onDel(gd.id);
          })
      }
      .margin({ left: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
    .onClick(() => {
      this.onOpenGood(gd.id);
    })
  }
}

旧物列表项是所有 Tab 中布局最复杂的——它包含四段内容:左侧 Emoji 图标、中间名称和描述、右侧状态标签和价格、最右侧编辑和删除操作按钮。四段横向排列在一个 Row 中,中间段使用 layoutWeight(1) 撑满空间。

状态标签的颜色通过 getGoodStatusColor(gd.status) 动态获取——"在售"为绿色、"已售"为灰色、"已下架"为橙色。这种基于数据状态的颜色变化让用户在浏览列表时能快速识别物品的流转情况。

编辑按钮(✎)和删除按钮(🗑)各有独立的 onClick 事件,分别调用 this.onEdit(gd.id)this.onDel(gd.id)。整个列表项也有 onClick 调用 this.onOpenGood(gd.id) 打开详情。三个 onClick 互不干扰,因为 ArkTS 的事件不会自动冒泡——内层元素的 onClick 触发后不会传播到外层元素。

十、活动 Tab 组件:报名列表

@Component
struct EventTab {
  onOpenEvent: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🎪 怀旧活动')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('本月 6 场')
          .fontSize(11)
          .fontColor('#9E9E9E')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column() {
          Column() {
            ForEach(getEventRows(), (ev: RetroEvent) => {
              this.eventCard(ev)
            }, (ev: RetroEvent) => String(ev.id))
            ForEach(getEventRows2(), (ev: RetroEvent) => {
              this.eventCard(ev)
            }, (ev: RetroEvent) => String(ev.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F3EE')
  }

  @Builder
  eventCard(ev: RetroEvent) {
    Column() {
      Row() {
        Text(getEventEmoji(ev.title))
          .fontSize(30)
          .width(56)
          .height(56)
          .textAlign(TextAlign.Center)
          .backgroundColor('#EFEBE9')
          .borderRadius(12)
        Column() {
          Text(ev.title)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text(ev.date + ' · ' + ev.place)
            .fontSize(11)
            .fontColor('#9E9E9E')
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 12 })
        Text(ev.joined >= ev.quota ? '已满' : '报名')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor(ev.joined >= ev.quota ? '#BDBDBD' : '#FFFFFF')
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(ev.joined >= ev.quota ? '#EEEEEE' : '#5D4037')
          .borderRadius(12)
          .onClick(() => {
            this.onOpenEvent(ev.id);
          })
      }
      .width('100%')
      Row() {
        Text('报名 ' + String(ev.joined) + '/' + String(ev.quota) + ' 人')
          .fontSize(10)
          .fontColor('#8D6E63')
        Text('')
          .layoutWeight(1)
        Text('🎁 参与即得:' + ev.reward)
          .fontSize(10)
          .fontColor('#9E9E9E')
          .maxLines(1)
      }
      .width('100%')
      .padding({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
  }
}

活动卡片使用条件表达式 ev.joined >= ev.quota ? '已满' : '报名' 来动态切换按钮文字。同时,按钮的颜色也通过三元运算符切换:已满时使用灰色背景和灰色文字,未满时使用棕色背景和白色文字。这种"数据驱动 UI"的模式是声明式框架的核心优势——开发者只需描述状态与 UI 的映射关系,框架自动处理 UI 更新。

卡片底部行展示报名进度和奖励信息。'报名 ' + String(ev.joined) + '/' + String(ev.quota) + ' 人' 通过字符串拼接展示如"报名 48/60 人"的格式,让用户直观了解报名情况。maxLines(1) 确保奖励信息过长时不会换行挤压布局。

十一、我的 Tab 组件:多功能聚合页面

11.1 会员卡与零食横滑

@Component
struct MineTab {
  onOpenSnack: (id: number) => void = () => {
  }
  onDelCollect: (id: number) => void = () => {
  }
  onCancelBook: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 会员卡
          Row() {
            Column() {
              Text('🎞 时光收藏家')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('已收藏 18 件 · 参观 12 次')
                .fontSize(11)
                .fontColor('#EFEBE9')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            Text('')
              .layoutWeight(1)
            Text('Lv.3')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFD54F')
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 18, bottom: 18 })
          .linearGradient({ angle: 135, colors: [['#3E2723', 0], ['#6D4C41', 1]] })
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 12 })
          .shadow({ radius: 8, color: 'rgba(62,39,35,0.35)', offsetY: 4 })

“我的” Tab 是内容最丰富的页面,集合了会员卡、零食横滑、收藏列表、预约列表四个模块。整个页面使用一个外层 Scroll 包裹,所有模块在一个 Column 中纵向排列。

会员卡的设计与首页横幅类似,使用深棕色渐变背景 + 阴影。左侧是会员名称和统计信息,右侧是等级标识"Lv.3"(使用 #FFD54F 金黄色,与整体棕色形成对比)。会员卡作为用户身份的视觉锚点,在视觉层级上最为突出。

11.2 收藏列表与预约列表

          // 预约列表
          Row() {
            Text('📅 我的参观预约')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
            Text('')
              .layoutWeight(1)
            Text('全部 >')
              .fontSize(11)
              .fontColor('#9E9E9E')
              .onClick(() => {
                this.onToast('全部预约');
              })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12 })

          Column() {
            ForEach(getBookRows(), (bk: BookItem) => {
              this.bookRow(bk)
            }, (bk: BookItem) => String(bk.id))
            ForEach(getBookRows2(), (bk: BookItem) => {
              this.bookRow(bk)
            }, (bk: BookItem) => String(bk.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 20 })

预约列表通过 ForEach 渲染 getBookRows()getBookRows2() 的返回值。每个预约项通过 bookRow 这个 @Builder 方法渲染,包含展厅名称、日期时段和状态标签。

11.3 预约行与收藏行 Builder

  @Builder
  bookRow(bk: BookItem) {
    Row() {
      Text('🏛')
        .fontSize(18)
        .width(40)
        .height(40)
        .textAlign(TextAlign.Center)
        .backgroundColor('#EFEBE9')
        .borderRadius(10)
      Column() {
        Text(bk.hall)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .maxLines(1)
        Text(bk.date + ' ' + bk.slot)
          .fontSize(11)
          .fontColor('#9E9E9E')
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 10 })
      Text(bk.status === '待确认' ? '取消' : '')
        .fontSize(10)
        .fontColor('#E53935')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor('#FFEBEE')
        .borderRadius(10)
        .onClick(() => {
          if (bk.status === '待确认') {
            this.onCancelBook(bk.id);
          }
        })
      Text(bk.status)
        .fontSize(10)
        .fontColor(getBookStatusColor(bk.status))
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor('#F5F5F5')
        .borderRadius(8)
        .margin({ left: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
  }

预约行中有一个精巧的条件逻辑:只有"待确认"状态的预约才显示"取消"按钮(红色文字 + 浅红背景),其他状态显示空字符串(即不可见的空 Text)。同时,onClick 内部也有 if (bk.status === '待确认') 的保护性检查,确保即使按钮文字为空时被点击也不会触发取消操作。

这种"双重保护"(视觉隐藏 + 逻辑判断)是防御性编程的体现。在 ArkTS 中,空字符串的 Text 仍然占据布局空间,如果需要完全隐藏,应使用条件渲染 if (condition) { Text(...) } 而非 Text(condition ? '...' : '')。不过在本场景中,保留占位空间可以避免状态标签的位置在有无取消按钮时发生跳动,保持视觉稳定性。

十二、主组件:状态编排与导航中枢

12.1 状态声明

@Entry
@Component
struct Index {
  @State curTab: number = 0
  private tabs1: string[] = ['首页', '展厅', '藏品', '年代']
  private tabs2: string[] = ['旧物', '活动', '我的']

  @State showRelicDetail: boolean = false
  @State selRelic: RelicItem | null = null
  @State showHallDetail: boolean = false
  @State selHall: HallItem | null = null
  @State showBook: boolean = false
  @State showCancelBook: boolean = false
  @State selBook: BookItem | null = null
  @State showAddGood: boolean = false
  @State showEditGood: boolean = false
  @State selGood: OldGood | null = null
  @State showDelGood: boolean = false
  @State showEraDetail: boolean = false
  @State selEra: EraItem | null = null
  @State showEventDetail: boolean = false
  @State selEvent: RetroEvent | null = null
  @State showJoinEvent: boolean = false
  @State showExchange: boolean = false
  @State showSnackDetail: boolean = false
  @State selSnack: SnackItem | null = null
  @State showDelCollect: boolean = false
  @State selCollect: CollectItem | null = null
  @State showTicket: boolean = false
  @State showDonate: boolean = false

  // 表单状态
  @State fName: string = '老式闹钟'
  @State fCat: string = '生活'
  @State fPrice: number = 60
  @State fStatus: string = '在售'
  @State bookSlot: string = '14:00-15:30'
  @State bookQuota: number = 1
  @State exchangeLevel: number = 100
  @State toast: string = ''

主组件 Index 使用 @Entry@Component 双重装饰器标注,是应用的入口组件。它声明了大量 @State 变量,可分为三类:

第一类是 Tab 导航状态:curTab 记录当前选中的 Tab 索引(0-6),tabs1tabs2 是两行底栏的 Tab 标签列表(private 修饰,不可变)。

第二类是弹框控制状态:每个弹框对应两个 @State 变量——一个 boolean 控制是否显示,一个具体类型变量保存弹框所需的数据(如 selRelic 保存当前查看的藏品)。这种"布尔开关 + 数据载体"的成对设计是弹框管理的标准模式。

第三类是表单状态:fNamefCatfPricefStatus 用于发布/编辑旧物表单,bookSlotbookQuota 用于预约表单,exchangeLevel 用于积分兑换,toast 用于 Toast 消息。

@State 是 ArkTS 中最核心的状态管理装饰器。被 @State 标注的变量一旦变化,框架会自动重新渲染依赖该变量的 UI 部分。@State 变量必须是组件内部声明的,用于管理组件的私有状态。对于需要跨组件共享的状态,应使用 @Provide/@Consume@StorageLink 等装饰器。

12.2 Tab 切换与内容区渲染

  build() {
    Column() {
      // 头部
      Row() {
        Column() {
          Text('时光博物馆')
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('员工怀旧馆 · 时光不老')
            .fontSize(10)
            .fontColor('#EFEBE9')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Text('')
          .layoutWeight(1)
        Text('🎞')
          .fontSize(18)
          .padding(8)
          .onClick(() => {
            this.toast = '时光机';
          })
        Text('🔔')
          .fontSize(18)
          .padding(8)
          .onClick(() => {
            this.toast = '活动提醒(2 条未读)';
          })
      }
      .width('100%')
      .padding({ left: 16, right: 12, top: 10, bottom: 10 })
      .linearGradient({ angle: 135, colors: [['#3E2723', 0], ['#6D4C41', 1]] })

      // 内容区
      if (this.curTab === 0) {
        HomeTab({
          onOpenRelic: (id: number) => {
            this.selRelic = this.findRelic(id);
            this.showRelicDetail = true;
          },
          onOpenEvent: (id: number) => {
            this.selEvent = this.findEvent(id);
            this.showEventDetail = true;
          },
          onToast: (msg: string) => {
            this.toast = msg;
          }
        })
      } else if (this.curTab === 1) {
        HallTab({
          onOpenHall: (id: number) => {
            this.selHall = this.findHall(id);
            this.showHallDetail = true;
          },
          onBook: (id: number) => {
            this.selHall = this.findHall(id);
            this.bookSlot = '14:00-15:30';
            this.showBook = true;
          }
        })
      } else if (this.curTab === 2) {

主组件的 build 方法使用 if-else if-else 条件渲染来切换 Tab 内容。当 curTab 变化时,ArkTS 框架会自动销毁旧 Tab 的组件实例并创建新 Tab 的组件实例。每个 Tab 子组件在实例化时通过参数传入回调函数——这些回调函数在子组件内部被调用时,会修改主组件的 @State 变量,从而触发弹框的显示或 Toast 的更新。

以首页 Tab 为例,HomeTab 接收三个回调:onOpenRelic(设置 selRelicshowRelicDetail)、onOpenEvent(设置 selEventshowEventDetail)、onToast(设置 toast)。每个回调内部先调用 findRelic(id)findEvent(id) 查找完整数据,再设置弹框显示标志。这种"先查数据再开弹框"的顺序确保了弹框打开时已有完整数据可供渲染。

在 ArkTS 中,if-else 条件渲染与 ForEach 列表渲染是两种不同的 Diff 机制。if-else 会在条件变化时完全销毁/创建组件分支,适用于 Tab 切换等"互斥"场景。而 ForEach 则是对同一类型子项的增删更新,适用于列表场景。选择正确的渲染机制对性能至关重要。

12.3 底栏导航

      // 底栏两排
      Column() {
        Row() {
          ForEach(this.tabs1, (tb: string, ti: number) => {
            this.bottomTabItem(getTabIcon(tb), tb, ti)
          }, (tb: string, ti: number) => tb + String(ti))
        }
        .width('100%')
        .padding({ top: 6, bottom: 2 })

        Row() {
          ForEach(this.tabs2, (tb: string, ti: number) => {
            this.bottomTabItem(getTabIcon(tb), tb, ti + 4)
          }, (tb: string, ti: number) => tb + String(ti))
        }
        .width('100%')
        .padding({ top: 2, bottom: 6 })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: -2 })

底栏导航分为两排,第一排四个 Tab(首页、展厅、藏品、年代,索引 0-3),第二排三个 Tab(旧物、活动、我的,索引 4-6)。两排都通过 ForEach 渲染,调用 bottomTabItem 这个 @Builder 方法。注意第二排的索引传入 ti + 4,因为 ForEach 的第二参数 ti 从 0 开始,而第二排 Tab 的实际索引从 4 开始。

  @Builder
  bottomTabItem(icon: string, label: string, idx: number) {
    Column() {
      Text(icon)
        .fontSize(17)
        .fontColor(this.curTab === idx ? '#5D4037' : '#9E9E9E')
      Text(label)
        .fontSize(10)
        .fontColor(this.curTab === idx ? '#5D4037' : '#9E9E9E')
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .padding({ top: 4, bottom: 2 })
    .onClick(() => {
      this.curTab = idx;
      this.toast = '';
    })
  }

bottomTabItemfontColor 通过 this.curTab === idx ? '#5D4037' : '#9E9E9E' 实现选中态/未选中态的颜色切换。选中时使用深棕色(#5D4037),未选中时使用灰色(#9E9E9E)。点击时设置 this.curTab = idx 切换 Tab,同时清空 this.toast(避免切换 Tab 后残留上一页的 Toast 消息)。

用户点击底栏 Tab

bottomTabItem onClick

curTab = idx

toast = ''

curTab 变化触发 @State 重新渲染

if-else 条件渲染切换

旧 Tab 组件销毁

新 Tab 组件创建

新 Tab 渲染完成

12.4 弹框渲染与遮罩

    // ========== 弹框区 ==========
    if (this.showRelicDetail && this.selRelic !== null) {
      this.modalOverlay(() => {
        this.showRelicDetail = false;
      })
      this.relicDetailModal()
    }
    if (this.showHallDetail && this.selHall !== null) {
      this.modalOverlay(() => {
        this.showHallDetail = false;
      })
      this.hallDetailModal()
    }

弹框区的渲染采用条件渲染模式。每个弹框对应一个 if 语句,条件为"显示标志为 true 且选中数据不为 null"。当条件成立时,先渲染遮罩层 modalOverlay,再渲染弹框内容。遮罩层接收一个关闭回调函数,点击遮罩时将显示标志设为 false,弹框消失。

  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.55)')
    .onClick(onClose)
  }

modalOverlay 是一个全屏的空 Column,背景色为半透明黑色(rgba(0,0,0,0.55))。它覆盖在正常内容之上,起到"遮罩"的作用——阻止用户与底层的交互,并提供点击关闭的便捷操作。这种"遮罩 + 弹框"的双层结构是移动端弹框的标准实现方式。

12.5 Toast 通知

    // Toast
    if (this.toast.length > 0) {
      Column() {
        Text('🎞 ' + this.toast)
          .fontSize(12)
          .fontColor('#FFFFFF')
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
      }
      .backgroundColor('rgba(62,39,35,0.9)')
      .borderRadius(18)
      .position({ x: 0, y: '72%' })
      .onAppear(() => {
        setTimeout(() => {
          this.toast = '';
        }, 1600);
      })
    }

Toast 通知通过 if (this.toast.length > 0) 条件渲染。当 toast 变量非空时,渲染一个深棕色半透明背景的圆角条,使用 position({ x: 0, y: '72%' }) 绝对定位到屏幕 72% 高度处(接近底部)。onAppear 生命周期回调在组件出现后触发,通过 setTimeout 延迟 1600 毫秒后将 toast 清空,Toast 随之消失。

onAppear 是 ArkTS 组件的生命周期回调之一,在组件首次渲染完成后触发。与之对应的是 onDisappear,在组件被销毁时触发。在 Toast 场景中使用 onAppear + setTimeout 是实现"自动消失"通知的常见模式。

12.6 数据查找函数

  findRelic(id: number): RelicItem | null {
    for (let i = 0; i < RELIC_LIST.length; i++) {
      if (RELIC_LIST[i].id === id) {
        return RELIC_LIST[i];
      }
    }
    return null;
  }

  findHall(id: number): HallItem | null {
    for (let i = 0; i < HALL_LIST.length; i++) {
      if (HALL_LIST[i].id === id) {
        return HALL_LIST[i];
      }
    }
    return null;
  }

主组件内部定义了七个 find 方法,用于根据 ID 在全局数据中查找完整数据项。这些方法使用简单的 for 循环遍历数组,找到匹配 ID 即返回,未找到则返回 null。返回类型使用联合类型 RelicItem | null,调用方在使用返回值时需要做空值检查(或使用非空断言 !)。

在弹框渲染中,条件判断已确保 selRelic !== null,因此在弹框 @Builder 内部使用 this.selRelic!.name 这样的非空断言是安全的。这种"条件渲染守卫 + 非空断言"的组合是 ArkTS 中处理可空状态的标准实践。

十三、弹框系统:十五种交互弹框

13.1 藏品详情弹框(居中卡)

  @Builder
  relicDetailModal() {
    Column() {
      Text(getRelicEmoji(this.selRelic!.cat))
        .fontSize(46)
        .width('100%')
        .height(96)
        .textAlign(TextAlign.Center)
        .backgroundColor('#EFEBE9')
        .borderRadius({ topLeft: 18, topRight: 18 })
      Column() {
        Row() {
          Text(this.selRelic!.name)
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text('')
            .layoutWeight(1)
          Text('热度 ' + String(this.selRelic!.hot))
            .fontSize(11)
            .fontColor('#F57C00')
        }
        .width('100%')
        Text(this.selRelic!.era + ' · ' + this.selRelic!.cat)
          .fontSize(12)
          .fontColor('#9E9E9E')
          .width('100%')
          .margin({ top: 4 })
        Text(this.selRelic!.desc)
          .fontSize(13)
          .fontColor('#616161')
          .width('100%')
          .lineHeight(22)
          .padding(12)
          .backgroundColor('#F5F1EC')
          .borderRadius(10)
          .margin({ top: 10 })
        Row() {
          Text('收藏')
            .fontSize(12)
            .fontColor('#5D4037')
            .padding({ left: 18, right: 18, top: 9, bottom: 9 })
            .backgroundColor('#EFEBE9')
            .borderRadius(18)
            .onClick(() => {
              this.showRelicDetail = false;
              this.toast = '已收藏「' + this.selRelic!.name + '」';
            })
          Text('')
            .layoutWeight(1)
          Text('听讲解')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 18, right: 18, top: 9, bottom: 9 })
            .backgroundColor('#5D4037')
            .borderRadius(18)
            .onClick(() => {
              this.showRelicDetail = false;
              this.toast = '正在播放藏品讲解音频';
            })
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .padding(16)
    }
    .width('82%')
    .backgroundColor('#FFFFFF')
    .borderRadius(18)
    .clip(true)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

藏品详情弹框是一个居中卡片,宽度为屏幕的 82%。顶部是一个 96vp 高的 Emoji 图标区,使用 borderRadius({ topLeft: 18, topRight: 18 }) 只圆角化上方两角(因为下方紧接白色内容区,不需要圆角)。clip(true) 确保子元素不会超出圆角边界。

transition(TransitionEffect.OPACITY.animation({ duration: 200 })) 是该弹框唯一的过渡效果——出现时从透明渐变为不透明,消失时反向。200 毫秒的持续时间足够柔和,不会让用户感到突兀。这是 ArkTS 中实现弹框过渡动画的轻量级方式。

弹框底部有两个操作按钮:“收藏”(浅色背景,次要操作)和"听讲解"(深色背景,主要操作)。两个按钮都先设置 showRelicDetail = false 关闭弹框,再设置 toast 显示操作反馈。这种"关闭弹框 + Toast 反馈"的操作链路在本应用的所有弹框中反复出现。

13.2 展厅详情弹框(上下拼接卡)

  @Builder
  hallDetailModal() {
    Column() {
      Column() {
        Text(getHallEmoji(this.selHall!.theme))
          .fontSize(44)
        Text(this.selHall!.name)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ top: 8 })
        Text(this.selHall!.theme + '主题展厅')
          .fontSize(12)
          .fontColor('#EFEBE9')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 22, bottom: 22 })
      .linearGradient({ angle: 135, colors: [['#4E342E', 0], ['#795548', 1]] })
      .borderRadius({ topLeft: 18, topRight: 18 })

      Column() {
        Text(this.selHall!.intro)
          .fontSize(13)
          .fontColor('#616161')
          .width('100%')
          .lineHeight(22)
        Row() {
          Text('开放时间')
            .fontSize(12)
            .fontColor('#9E9E9E')
          Text('')
            .layoutWeight(1)
          Text(this.selHall!.open)
            .fontSize(12)
            .fontColor('#333333')
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        Row() {
          Text('门票')
            .fontSize(12)
            .fontColor('#9E9E9E')
          Text('')
            .layoutWeight(1)
          Text(this.selHall!.fee === 0 ? '免费' : '¥' + String(this.selHall!.fee))
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#5D4037')
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        Row() {
          Text('预约参观')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor('#5D4037')
            .borderRadius(20)
            .onClick(() => {
              this.bookSlot = '14:00-15:30';
              this.showHallDetail = false;
              this.showBook = true;
            })
          Text('')
            .layoutWeight(1)
          Text('✕ 关闭')
            .fontSize(13)
            .fontColor('#9E9E9E')
            .padding({ left: 18, right: 18, top: 10, bottom: 10 })
            .onClick(() => {
              this.showHallDetail = false;
            })
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius({ bottomLeft: 18, bottomRight: 18 })
    }
    .width('84%')
    .clip(true)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

展厅详情弹框采用"上下拼接"设计:上半部分是深棕色渐变背景的标题区(Emoji + 名称 + 主题),下半部分是白色背景的信息区(简介 + 开放时间 + 门票 + 操作按钮)。两个区域的圆角分别设置在上方两角和下方两角,拼接后形成完整的圆角矩形。

门票价格的展示使用了条件表达式 this.selHall!.fee === 0 ? '免费' : '¥' + String(this.selHall!.fee)——免费展厅显示"免费"文字,收费展厅显示"¥" + 价格。这种基于数据条件的文本切换在声明式 UI 中非常自然。

点击"预约参观"按钮会执行三步操作:重置 bookSlot 为默认值、关闭展厅详情弹框、打开预约弹框。这种"关闭当前弹框 + 打开下一个弹框"的链式操作在多步表单流程中很常见。

13.3 展厅预约弹框(底部抽屉)

  @Builder
  bookModal() {
    Column() {
      Row() {
        Text('🎫 预约参观')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showBook = false;
          })
      }
      .width('100%')

      Text(getHallEmoji(this.selHall!.theme) + ' ' + this.selHall!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .padding({ top: 10, bottom: 10, left: 12, right: 12 })
        .backgroundColor('#F5F1EC')
        .borderRadius(10)
        .margin({ top: 12 })

      Text('选择时段')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getSlotList(), (sl: string) => {
          Text(sl)
            .fontSize(11)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.bookSlot === sl ? '#5D4037' : '#F5F5F5')
            .fontColor(this.bookSlot === sl ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.bookSlot = sl;
            })
        }, (sl: string) => sl)
      }
      .width('100%')

      Text('参观人数')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getQuotaList(), (qo: number) => {
          Text(String(qo) + ' 人')
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.bookQuota === qo ? '#5D4037' : '#F5F5F5')
            .fontColor(this.bookQuota === qo ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.bookQuota = qo;
            })
        }, (qo: number) => String(qo))
      }
      .width('100%')

      Text('预约后请按时到馆,迟到 30 分钟将自动释放名额。')
        .fontSize(11)
        .fontColor('#9E9E9E')
        .width('100%')
        .margin({ top: 12 })

      Text('确认预约')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#5D4037')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showBook = false;
          this.toast = '已预约 ' + this.selHall!.name + ' ' + this.bookSlot;
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 24 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .constraintSize({ maxHeight: '80%' })
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

预约弹框是一个底部抽屉式弹框。与居中弹框不同,它宽度为 100%(铺满屏幕宽度),只在上方两角设置圆角(borderRadius({ topLeft: 18, topRight: 18 })),模拟从底部滑出的效果。constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%,防止内容过多时覆盖整个屏幕。

弹框包含两个选择器:时段选择和人数选择。每个选择器通过 ForEach 渲染一行可选按钮,选中项使用 this.bookSlot === sl ? '#5D4037' : '#F5F5F5' 切换背景色。点击按钮时更新对应的 @State 变量(this.bookSlot = slthis.bookQuota = qo),框架自动重渲染按钮颜色。

在 ArkTS 中,"选中态"的实现通常依赖 @State 变量与条件表达式的组合。当 @State 变量变化时,ForEach 内部的条件表达式重新求值,对应项的样式自动更新。这种"状态驱动样式"的模式比手动操作 DOM 样式要简洁得多。

13.4 警示弹框(取消预约、删除旧物、移出收藏)

  @Builder
  cancelBookModal() {
    Column() {
      Text('⚠️')
        .fontSize(36)
      Text('取消预约?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 8 })
      Text(this.selBook!.hall + ' ' + this.selBook!.date + ' ' + this.selBook!.slot)
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 6 })
      Row() {
        Text('保留')
          .fontSize(12)
          .fontColor('#616161')
          .padding({ left: 18, right: 18, top: 8, bottom: 8 })
          .backgroundColor('#F5F5F5')
          .borderRadius(16)
          .onClick(() => {
            this.showCancelBook = false;
          })
        Text('')
          .layoutWeight(1)
        Text('确认取消')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 18, right: 18, top: 8, bottom: 8 })
          .backgroundColor('#E53935')
          .borderRadius(16)
          .onClick(() => {
            this.showCancelBook = false;
            this.toast = '预约已取消';
          })
      }
      .width('100%')
      .margin({ top: 14 })
    }
    .width('76%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

取消预约弹框是一个居中的小型警示卡,宽度 76%。顶部是一个 ⚠️ 警示图标,然后是标题、详情信息和两个按钮。"保留"按钮使用浅灰背景(次要操作),"确认取消"按钮使用红色背景(#E53935,危险操作)。红色作为危险操作的视觉编码是通用的 UI 设计规范,在本应用的删除、取消等警示弹框中一致使用。

删除旧物弹框(delGoodModal)使用了深色渐变背景(linearGradient#3E2723#1B0000),营造出更强烈的"警告"氛围。这与取消预约的白色背景形成对比——删除操作的后果更严重,因此视觉警示更强。这种"操作严重程度决定弹框视觉风格"的设计思路值得借鉴。

13.5 表单弹框(发布旧物、编辑旧物)

  @Builder
  addGoodModal() {
    Column() {
      Row() {
        Text('🧺 发布旧物')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showAddGood = false;
          })
      }
      .width('100%')

      Text('物品名称')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14, bottom: 6 })
      Row() {
        ForEach(getNameOptions(), (nm: string) => {
          Text(nm)
            .fontSize(11)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.fName === nm ? '#5D4037' : '#F5F5F5')
            .fontColor(this.fName === nm ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.fName = nm;
            })
        }, (nm: string) => nm)
      }
      .width('100%')

      Text('物品分类')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getCatOptions(), (ct: string) => {
          Text(ct)
            .fontSize(11)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.fCat === ct ? '#5D4037' : '#F5F5F5')
            .fontColor(this.fCat === ct ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.fCat = ct;
            })
        }, (ct: string) => ct)
      }
      .width('100%')

      Text('转让价格')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getPriceOptions(), (pr: number) => {
          Text('¥' + String(pr))
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.fPrice === pr ? '#5D4037' : '#F5F5F5')
            .fontColor(this.fPrice === pr ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.fPrice = pr;
            })
        }, (pr: number) => String(pr))
      }
      .width('100%')

      Text('发布状态')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getStatusOptions(), (st: string) => {
          Text(st)
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.fStatus === st ? '#5D4037' : '#F5F5F5')
            .fontColor(this.fStatus === st ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.fStatus = st;
            })
        }, (st: string) => st)
      }
      .width('100%')

      Text('确认发布')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#5D4037')
        .borderRadius(24)
        .margin({ top: 16 })
        .onClick(() => {
          this.showAddGood = false;
          this.toast = '「' + this.fName + '」已发布到集市';
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 24 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .constraintSize({ maxHeight: '80%' })
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

发布旧物弹框是一个底部抽屉式表单,包含四个选择字段:物品名称、物品分类、转让价格、发布状态。每个字段使用相同的"标签 + 可选按钮行"模式——标签文字(fontWeight(FontWeight.Bold) 加粗)+ Row 包裹的 ForEach 可选按钮。选中项使用深色背景,未选中项使用浅灰背景。

编辑旧物弹框(editGoodModal)的结构与发布弹框几乎一致,区别在于:编辑弹框的选中态使用 #6D4C41 而非 #5D4037(略浅的棕色),以视觉区分"编辑"与"新增"两种模式。此外,编辑弹框顶部展示当前编辑物品的信息(Emoji + 名称),帮助用户确认正在编辑的对象。

在 ArkTS 的表单设计中,"标签 + ForEach 可选按钮"比传统的下拉选择器(Picker/Select)更适合移动端——用户无需展开下拉列表,所有选项一目了然,点击即可切换。这种设计在选项数量较少(3-6 个)时尤其有效。

13.6 结果弹框(活动报名成功、零食详情票根、捐赠奖励)

  @Builder
  joinEventModal() {
    Column() {
      Text('🎉')
        .fontSize(42)
        .margin({ top: 16 })
      Text('报名成功!')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 8 })
      Text('「' + this.selEvent!.title + '」期待你的到来')
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 6 })
      Row() {
        Text('活动时间')
          .fontSize(12)
          .fontColor('#9E9E9E')
        Text('')
          .layoutWeight(1)
        Text(this.selEvent!.date)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5D4037')
      }
      .width('100%')
      .padding({ top: 12, bottom: 12, left: 12, right: 12 })
      .backgroundColor('#F5F1EC')
      .borderRadius(10)
      .margin({ top: 12 })
      Row() {
        Text('参与奖励')
          .fontSize(12)
          .fontColor('#9E9E9E')
        Text('')
          .layoutWeight(1)
        Text(this.selEvent!.reward)
          .fontSize(12)
          .fontColor('#333333')
      }
      .width('100%')
      .padding({ top: 10, bottom: 10 })
      Text('完成')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 11, bottom: 11 })
        .backgroundColor('#5D4037')
        .borderRadius(20)
        .margin({ top: 14 })
        .onClick(() => {
          this.showJoinEvent = false;
          this.toast = '已报名,奖励待活动后发放';
        })
    }
    .width('80%')
    .padding({ left: 20, right: 20, top: 8, bottom: 20 })
    .backgroundColor('#FFFFFF')
    .borderRadius(18)
    .shadow({ radius: 12, color: 'rgba(62,39,35,0.3)', offsetY: 5 })
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

报名成功弹框是一个居中结果卡,顶部使用 🎉 庆祝图标营造正面氛围。信息展示采用"左标签 + 右值"的行布局,背景使用 #F5F1EC 浅棕色区分。shadow 属性添加了比普通弹框更强的阴影(radius: 12),增强卡片的悬浮感。

零食详情弹框(snackDetailModal)采用了"票根式"设计——顶部有"MEMORY"和"怀旧票根"的文字标识(使用 letterSpacing 增加字间距),模拟纸质票根的视觉效果。这种"模拟实物"的设计思路在怀旧主题应用中非常贴切。

  @Builder
  ticketModal() {
    Column() {
      Column() {
        Text('🎞')
          .fontSize(36)
        Text('时光博物馆 · 纪念门票')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ top: 8 })
        Text('2026 · 员工怀旧专线')
          .fontSize(11)
          .fontColor('#EFEBE9')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 20, bottom: 20 })
      .linearGradient({ angle: 135, colors: [['#3E2723', 0], ['#6D4C41', 1]] })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Column() {
        Row() {
          Text('票号')
            .fontSize(11)
            .fontColor('#9E9E9E')
          Text('')
            .layoutWeight(1)
          Text('NO.2026-0891')
            .fontSize(11)
            .fontColor('#424242')
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        Row() {
          Text('有效期')
            .fontSize(11)
            .fontColor('#9E9E9E')
          Text('')
            .layoutWeight(1)
          Text('长期有效')
            .fontSize(11)
            .fontColor('#424242')
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        Row() {
          Text('权益')
            .fontSize(11)
            .fontColor('#9E9E9E')
          Text('')
            .layoutWeight(1)
          Text('6 展厅通用 · 免预约一次')
            .fontSize(11)
            .fontColor('#5D4037')
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })

        Text('完成')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor('#5D4037')
          .borderRadius(20)
          .margin({ top: 12 })
          .onClick(() => {
            this.showTicket = false;
          })
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius({ bottomLeft: 16, bottomRight: 16 })
    }
    .width('82%')
    .clip(true)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

纪念门票弹框(ticketModal)同样采用"上下拼接"设计——上半部分深棕色渐变背景(标题区),下半部分白色背景(信息区)。信息区包含票号、有效期、权益三行"左标签 + 右值"的布局。clip(true) 确保上下两区的圆角形成完整的圆角矩形。

在 ArkTS 中,clip(true) 是一个重要的视觉属性。它会对组件进行裁剪,使子元素不会超出父组件的边界(包括圆角)。当父组件设置了 borderRadius 而子组件有不同背景色时,clip(true) 是确保圆角效果不被破坏的关键。

13.7 积分兑换弹框

  @Builder
  exchangeModal() {
    Column() {
      Row() {
        Text('🎁 积分兑换')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showExchange = false;
          })
      }
      .width('100%')

      Text('当前积分:1280 分')
        .fontSize(12)
        .fontColor('#5D4037')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#F5F1EC')
        .borderRadius(10)
        .margin({ top: 10 })

      Text('选择兑换档位')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getPayLevels(), (pl: number, pi: number) => {
          Column() {
            Text(String(pl) + ' 分')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.exchangeLevel === pl ? '#FFFFFF' : '#5D4037')
            Text(getPayGifts()[pi])
              .fontSize(9)
              .fontColor(this.exchangeLevel === pl ? '#EFEBE9' : '#9E9E9E')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(this.exchangeLevel === pl ? '#5D4037' : '#F5F5F5')
          .borderRadius(12)
          .margin({ right: 8 })
          .alignItems(HorizontalAlign.Center)
          .onClick(() => {
            this.exchangeLevel = pl;
          })
        }, (pl: number, pi: number) => String(pl) + String(pi))
      }
      .width('100%')

      Text('立即兑换')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#5D4037')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showExchange = false;
          this.toast = '兑换成功,奖励已放入背包';
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 24 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .constraintSize({ maxHeight: '80%' })
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

积分兑换弹框的档位选择器与预约弹框的时段选择器有所不同——每个档位项是一个 Column(而非单个 Text),包含两行信息:积分数和对应奖励名称。选中时整个 Column 的背景变为深棕色,两行文字颜色同时变化。这种"复合项的选择态"展示了 @State 驱动多属性联动的能力。

ForEachkey 函数使用 String(pl) + String(pi),将积分数和索引拼接作为唯一标识。这是因为在某些场景下,不同的数据源可能产生相同的 pl 值(虽然本例不会),加上索引可以确保 key 的唯一性。

十四、全局工具函数:Tab 图标与年代物件

function getTabIcon(label: string): string {
  if (label === '首页') {
    return '🏠';
  }
  if (label === '展厅') {
    return '🏛';
  }
  if (label === '藏品') {
    return '📦';
  }
  if (label === '年代') {
    return '🕰';
  }
  if (label === '旧物') {
    return '🧺';
  }
  if (label === '活动') {
    return '🎪';
  }
  return '👤';
}

function getEraObjects(era: string): string {
  if (era === '1960s') {
    return '粮票 · 搪瓷缸 · 钢笔';
  }
  if (era === '1970s') {
    return '缝纫机 · 收音机 · 手表';
  }
  if (era === '1980s') {
    return '自行车 · 黑白电视 · 相机';
  }
  if (era === '1990s') {
    return 'BP 机 · 磁带 · 大哥大';
  }
  if (era === '2000s') {
    return '复读机 · MP3 · 网吧';
  }
  return '智能手机 · 平板 · 网购';
}

getTabIcon 根据 Tab 标签文本返回对应 Emoji 图标,在底栏渲染时被调用。getEraObjects 根据年代名称返回该年代的代表物件列表,在年代详情弹框中展示。这两个函数都是"文本到文本"的映射,使用连续 if 语句实现。

getCatFilter 函数返回藏品分类筛选的标签列表:['全部', '家用电器', '影音设备', '交通', '影像', '文具', '票证']。注意"交通"和"影像"与 RELIC_LIST 中的"交通工具"和"影像设备"并不完全一致,这是因为筛选标签是简化版本,实际筛选逻辑需要做前缀匹配或更复杂的关联。不过本应用未实现真实筛选,仅展示标签列表。

十五、技术要点总结与对比

以下表格对本应用涉及的核心 ArkTS 技术点进行了系统性总结和对比:

技术点作用本应用中的用法适用场景
@Entry标注入口组件标注 Index 主组件每个页面有且仅有一个 @Entry
@Component声明自定义组件标注七个 Tab 子组件和主组件可复用的 UI 单元
@State管理组件内部可变状态管理 Tab 索引、弹框开关、表单值、Toast组件私有状态
@Builder定义可复用 UI 构建方法卡片、列表项、弹框的构建方法组件内部 UI 复用
Column垂直线性布局标题文字纵向排列、信息卡片内容元素需纵向排列
Row水平线性布局三段式列表项、底栏 Tab 行元素需横向排列
Scroll可滚动容器各 Tab 的内容区、横滑列表内容超出屏幕时
ForEach列表渲染指令渲染所有列表、网格、选项行数组数据渲染
layoutWeight弹性占位权重Text('').layoutWeight(1) 撑开空间均分宽度或占位
FlexAlign主轴对齐方式FlexAlign.End 底部对齐柱状图Flex 容器对齐控制
linearGradient线性渐变背景横幅、卡片、弹框标题区需要色彩层次感
borderRadius圆角设置所有卡片的圆角美化视觉柔和化
shadow阴影投射横幅悬浮感、弹框深度感增强层次感
clip内容裁剪弹框圆角裁剪防止子元素溢出
transition过渡动画弹框出现/消失的透明度渐变弹框动画
position绝对定位Toast 定位到 72% 高度浮层定位
onClick点击事件所有按钮和可点击元素用户交互
onAppear组件出现回调Toast 自动消失计时生命周期管理
constraintSize约束尺寸弹框最大高度 80%防止内容溢出
设计模式说明优势
回调通信子组件通过函数属性向父组件上报事件解耦父子组件,子组件不依赖父类型
数据分片通过辅助函数将数组分为多组/多列灵活控制渲染结构
布尔开关 + 数据载体每个弹框用 boolean + 具体类型变量控制状态管理清晰,条件渲染守卫安全
Emoji 图标系统用 Emoji 字符替代图片资源零资源开销,跨平台一致
色阶体系六个棕色色阶贯穿全局视觉统一性强
条件渲染弹框if (show && sel !== null) 控制弹框避免空值访问,自动管理生命周期

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============ 类型定义 ============
interface HallItem {
  id: number
  name: string
  theme: string
  intro: string
  open: string
  fee: number
}

interface RelicItem {
  id: number
  name: string
  era: string
  cat: string
  desc: string
  hot: number
}

interface EraItem {
  id: number
  name: string
  year: string
  desc: string
  color: string
}

interface OldGood {
  id: number
  name: string
  cat: string
  price: number
  owner: string
  status: string
  desc: string
}

interface RetroEvent {
  id: number
  title: string
  date: string
  place: string
  quota: number
  joined: number
  reward: string
}

interface SnackItem {
  id: number
  name: string
  price: number
  taste: string
  stock: string
}

interface CollectItem {
  id: number
  name: string
  cat: string
  date: string
}

interface BookItem {
  id: number
  hall: string
  date: string
  slot: string
  status: string
}

interface EraVisit {
  era: string
  count: number
}

// ============ 全局写死数据 ============
const HALL_LIST: HallItem[] = [
  { id: 1, name: '时光放映厅', theme: '老电影', intro: '老式放映机与胶片的摩登回响', open: '每日 10:00-20:00', fee: 30 },
  { id: 2, name: '街角杂货铺', theme: '童年零食', intro: '复刻 80 年代小卖部的玻璃柜台', open: '每日 10:00-20:00', fee: 20 },
  { id: 3, name: '收音机博物馆', theme: '广播记忆', intro: '从矿石机到录音机的岁月留声', open: '每周三-周日', fee: 25 },
  { id: 4, name: '老照片展廊', theme: '城市影像', intro: '泛黄底片里的城市变迁', open: '每日 9:00-21:00', fee: 0 },
  { id: 5, name: '连环画长廊', theme: '小人书', intro: '两万册小人书与儿时英雄梦', open: '周末 10:00-18:00', fee: 15 },
  { id: 6, name: '缝纫机工作坊', theme: '手作记忆', intro: '脚踩缝纫机与外婆的针线盒', open: '周末 14:00-17:00', fee: 0 }
]

const RELIC_LIST: RelicItem[] = [
  { id: 1, name: '牡丹牌缝纫机', era: '70年代', cat: '家用电器', desc: '外婆的嫁妆,机身漆面依旧油亮。', hot: 96 },
  { id: 2, name: '永久牌自行车', era: '80年代', cat: '交通工具', desc: '二八大杠,后座载过整个童年。', hot: 98 },
  { id: 3, name: '海鸥牌照相机', era: '80年代', cat: '影像设备', desc: '手动过片,按下快门有清脆回响。', hot: 88 },
  { id: 4, name: '红灯牌收音机', era: '70年代', cat: '影音设备', desc: '拧动旋钮的滋滋声是最早的广播记忆。', hot: 90 },
  { id: 5, name: '英雄牌钢笔', era: '60年代', cat: '文具', desc: '爸爸的办公桌抽屉里总有一支。', hot: 82 },
  { id: 6, name: '老式搪瓷缸', era: '60年代', cat: '生活用品', desc: '磕掉瓷的地方露出黑色铁胎。', hot: 78 },
  { id: 7, name: '黑白电视机', era: '80年代', cat: '影音设备', desc: '全村围坐看《西游记》的夏夜。', hot: 99 },
  { id: 8, name: '磁带电唱机', era: '90年代', cat: '影音设备', desc: 'A 面听完翻 B 面的仪式感。', hot: 86 },
  { id: 9, name: '粮票与布票', era: '60年代', cat: '票证', desc: '计划经济时代的硬通货。', hot: 84 },
  { id: 10, name: 'BP 机', era: '90年代', cat: '通讯设备', desc: '腰间震动一下,回个电话亭电话。', hot: 80 },
  { id: 11, name: '复读机', era: '00年代', cat: '影音设备', desc: '英语磁带倒带机的咔哒声。', hot: 75 },
  { id: 12, name: '大哥大', era: '90年代', cat: '通讯设备', desc: '一块砖头,一份排面。', hot: 83 }
]

const ERA_LIST: EraItem[] = [
  { id: 1, name: '1960s', year: '1960-1969', desc: '粮票布票,集体生活的朴素年代', color: '#6D4C41' },
  { id: 2, name: '1970s', year: '1970-1979', desc: '三转一响,自行车缝纫机手表收音机', color: '#8D6E63' },
  { id: 3, name: '1980s', year: '1980-1989', desc: '改革开放,黑白电视与迪斯科', color: '#A1887F' },
  { id: 4, name: '1990s', year: '1990-1999', desc: '港片黄金时代,磁带走四方', color: '#BCAAA4' },
  { id: 5, name: '2000s', year: '2000-2009', desc: 'MP3 与网吧,互联网呼啸而至', color: '#D7CCC8' },
  { id: 6, name: '2010s', year: '2010-2019', desc: '智能手机普及,生活方式剧变', color: '#EFEBE9' }
]

const OLD_GOODS: OldGood[] = [
  { id: 1, name: '老式木质收音机', cat: '影音', price: 120, owner: '老周', status: '在售', desc: '外观完好,通电有杂音可修' },
  { id: 2, name: '凤凰牌二八大杠', cat: '出行', price: 680, owner: '阿明', status: '在售', desc: '原装车架,可正常骑行' },
  { id: 3, name: '海鸥双反相机', cat: '摄影', price: 980, owner: '小林', status: '已售', desc: '快门正常,缺皮套' },
  { id: 4, name: '全套小人书 60 本', cat: '书籍', price: 300, owner: '大刘', status: '在售', desc: '品相七成新,含三国系列' },
  { id: 5, name: '磁带 30 盘', cat: '影音', price: 99, owner: '小陈', status: '在售', desc: '邓丽君/小虎队/四大天王' },
  { id: 6, name: '搪瓷缸套装', cat: '生活', price: 45, owner: '王姐', status: '在售', desc: '带盖搪瓷缸,字迹清晰' },
  { id: 7, name: '老式台灯', cat: '生活', price: 88, owner: '老张', status: '已下架', desc: '绿色灯罩,需换灯线' },
  { id: 8, name: '飞人牌缝纫机', cat: '家用', price: 520, owner: '李阿姨', status: '在售', desc: '可正常缝纫,带原装木架' },
  { id: 9, name: '军用水壶', cat: '户外', price: 60, owner: '退伍老兵', status: '在售', desc: '1960 年代制式,包浆自然' },
  { id: 10, name: '老式座钟', cat: '生活', price: 350, owner: '赵叔', status: '在售', desc: '整点报时清脆,走时准确' }
]

const EVENT_LIST: RetroEvent[] = [
  { id: 1, title: '怀旧电影放映夜', date: '每周五 19:30', place: '时光放映厅', quota: 60, joined: 48, reward: '老电影海报' },
  { id: 2, title: '童年零食品鉴会', date: '9 月第一个周六', place: '街角杂货铺', quota: 40, joined: 36, reward: '零食盲盒' },
  { id: 3, title: '老照片修复工作坊', date: '每周日 14:00', place: '老照片展廊', quota: 20, joined: 12, reward: '修复套装' },
  { id: 4, title: '连环画交换市集', date: '每月第三个周末', place: '连环画长廊', quota: 80, joined: 55, reward: '签名画册' },
  { id: 5, title: '磁带交换日', date: '每月第二个周五', place: '收音机博物馆', quota: 50, joined: 30, reward: '定制磁带' },
  { id: 6, title: '缝纫机手作课', date: '每周六 15:00', place: '缝纫机工作坊', quota: 15, joined: 15, reward: '布艺作品' },
  { id: 7, title: '年代歌会', date: '10 月 1 日 19:00', place: '园区草坪', quota: 200, joined: 168, reward: '复古徽章' },
  { id: 8, title: '老物件鉴定会', date: '9 月 15 日 10:00', place: '大礼堂', quota: 100, joined: 76, reward: '鉴定证书' }
]

const SNACK_LIST: SnackItem[] = [
  { id: 1, name: '大大泡泡糖', price: 0.5, taste: '果味', stock: '热销' },
  { id: 2, name: '无花果丝', price: 1, taste: '酸甜', stock: '热销' },
  { id: 3, name: '跳跳糖', price: 0.8, taste: '爆炸', stock: '补货中' },
  { id: 4, name: '麦丽素', price: 2, taste: '巧克力', stock: '热销' },
  { id: 5, name: '辣条·卫龙', price: 1.5, taste: '香辣', stock: '热销' },
  { id: 6, name: '小浣熊干脆面', price: 1, taste: '烤肉味', stock: '限量' },
  { id: 7, name: '北冰洋汽水', price: 4, taste: '桔子', stock: '热销' },
  { id: 8, name: '大白兔奶糖', price: 3, taste: '奶香', stock: '热销' }
]

const COLLECT_LIST: CollectItem[] = [
  { id: 1, name: '黑白电视机', cat: '藏品', date: '2026-08-18' },
  { id: 2, name: '永久牌自行车', cat: '藏品', date: '2026-08-10' },
  { id: 3, name: '海鸥双反相机', cat: '旧物', date: '2026-08-02' },
  { id: 4, name: '磁带 30 盘', cat: '旧物', date: '2026-07-28' },
  { id: 5, name: '粮票与布票', cat: '藏品', date: '2026-07-20' },
  { id: 6, name: '搪瓷缸套装', cat: '旧物', date: '2026-07-15' },
  { id: 7, name: '英雄牌钢笔', cat: '藏品', date: '2026-07-08' },
  { id: 8, name: '老式台灯', cat: '旧物', date: '2026-06-30' }
]

const BOOK_LIST: BookItem[] = [
  { id: 1, hall: '时光放映厅', date: '2026-09-12', slot: '19:30-21:30', status: '已确认' },
  { id: 2, hall: '街角杂货铺', date: '2026-09-13', slot: '15:00-16:30', status: '已确认' },
  { id: 3, hall: '收音机博物馆', date: '2026-08-30', slot: '14:00-15:30', status: '已完成' },
  { id: 4, hall: '老照片展廊', date: '2026-08-22', slot: '10:00-11:30', status: '已完成' },
  { id: 5, hall: '连环画长廊', date: '2026-09-21', slot: '10:00-12:00', status: '待确认' },
  { id: 6, hall: '缝纫机工作坊', date: '2026-08-15', slot: '14:00-16:00', status: '已取消' },
  { id: 7, hall: '老照片展廊', date: '2026-07-25', slot: '16:00-17:30', status: '已完成' },
  { id: 8, hall: '时光放映厅', date: '2026-07-18', slot: '19:30-21:30', status: '已完成' }
]

const ERA_VISITS: EraVisit[] = [
  { era: '60', count: 180 },
  { era: '70', count: 240 },
  { era: '80', count: 420 },
  { era: '90', count: 380 },
  { era: '00', count: 260 },
  { era: '10', count: 150 }
]

// ============ 全局辅助函数 ============
function getHallRows(): HallItem[] {
  return [HALL_LIST[0], HALL_LIST[1], HALL_LIST[2]];
}

function getHallRows2(): HallItem[] {
  return [HALL_LIST[3], HALL_LIST[4], HALL_LIST[5]];
}

function getRelicRows(): RelicItem[] {
  return [RELIC_LIST[0], RELIC_LIST[3], RELIC_LIST[6], RELIC_LIST[9]];
}

function getRelicRows2(): RelicItem[] {
  return [RELIC_LIST[1], RELIC_LIST[4], RELIC_LIST[7], RELIC_LIST[10]];
}

function getRelicRows3(): RelicItem[] {
  return [RELIC_LIST[2], RELIC_LIST[5], RELIC_LIST[8], RELIC_LIST[11]];
}

function getEraRows(): EraItem[] {
  return [ERA_L
  @State showDelCollect: boolean = false
  @State selCollect: CollectItem | null = null
  @State showTicket: boolean = false
  @State showDonate: boolean = false

  // 表单状态
  @State fName: string = '老式闹钟'
  @State fCat: string = '生活'

  }
  return '智能手机 · 平板 · 网购';
}


在这里插入图片描述

十六、总结

本文以一个"时光博物馆"应用为案例,完整剖析了 HarmonyOS ArkTS 声明式 UI 开发的全链路实践。从类型定义到数据管理,从子组件封装到主组件编排,从 Tab 导航到十五种弹框交互,涵盖了移动端应用开发的核心场景。

在类型层面,九个 interface 定义了完整的业务数据模型,每个接口字段精简但语义明确。在数据层面,八个 const 数组作为全局数据源,通过辅助函数进行分片和映射,支撑所有 Tab 和弹框的渲染需求。在组件层面,七个 @Component 子组件各自封装独立的页面视图,通过回调函数与主组件通信。在状态层面,主组件 Index 通过二十余个 @State 变量管理 Tab 切换、弹框开关、表单数据和 Toast 消息,构成了应用的状态中枢。

在布局技术方面,ColumnRow 的嵌套组合构成了所有页面的骨架。layoutWeight 实现弹性占位,FlexAlign 控制对齐方式,Scroll 提供滚动能力,ForEach 驱动列表渲染。这些基础容器的灵活运用,使得无需 GridList 等高级组件也能实现网格、时间轴、柱状图等复杂布局。

在交互设计方面,十五个弹框覆盖了详情查看(居中卡、上下拼接卡、票根式)、表单填写(底部抽屉)、警示确认(居中小卡、深色警示)、结果展示(庆祝卡、奖励卡)等全部常见交互类型。每个弹框通过"布尔开关 + 数据载体"的成对状态管理,配合 modalOverlay 遮罩层和 TransitionEffect.OPACITY 过渡动画,实现了完整的弹框交互流程。

在视觉设计方面,一套从 #3E2723#EFEBE9 的棕色色阶贯穿全局,配合 linearGradient 渐变、shadow 阴影、borderRadius 圆角,营造出统一而有质感的怀旧氛围。Emoji 字符作为图标系统,既省去了图片资源的管理开销,又保持了跨平台的一致性。

ArkTS 的声明式范式使得"状态驱动 UI"的理念贯穿整个应用——@State 变量变化时框架自动重新渲染依赖该变量的 UI 部分,开发者无需手动操作 DOM。if-else 条件渲染处理 Tab 切换和弹框显隐,ForEach 列表渲染处理数据集合的展示和更新。这两种渲染机制各司其职,共同构成了高效 UI 更新的基础。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐