在 HarmonyOS(鸿蒙)应用开发生态中,ArkTS 声明式 UI 框架以其独特的状态驱动渲染机制和组件化开发模式,为复杂业务场景提供了高效、可维护的构建方案。本文将以一个极地科考站人事管理系统为切入点,深入剖析一个包含 7 个标签页、16 个弹框、10 组数据列表的大型 ArkTS 应用的完整架构设计与实现细节。

鸿蒙 ArkTS 并非传统的前端框架,它是一门基于 TypeScript 扩展的、面向全场景跨设备的应用开发语言。其声明式 UI 范式通过 @Component@State@Builder 等装饰器,将 UI 描述与状态管理深度融合,使得开发者只需关注"状态是什么",框架自动处理"UI 如何变化"。

该系统的技术栈围绕 ArkTS 声明式 UI 的核心能力展开。布局层面大量使用了 Column(垂直线性布局)、Row(水平线性布局)、Scroll(滚动容器)和 FlexAlign(弹性对齐)等基础组件构建复杂界面层级。状态管理层面,通过 @State 装饰器管理当前标签索引、16 个弹框的开关状态、9 个选中数据对象以及表单字段等数十个响应式变量。组件复用层面,借助 @Builder 装饰器将 16 个弹框和若干列表项模板抽离为可复用的构建函数。数据驱动层面,通过 ForEach 渲染列表实现 10 组数据集合的动态渲染。

整体架构采用"主组件统一调度 + 子组件分区渲染"的设计模式。主组件 Index 作为唯一入口,负责头部横幅、标签切换路由、底部两排导航栏和全部 16 个弹框的挂载管理。7 个标签页各自封装为独立的 @Component 子组件,通过回调函数与主组件通信,实现了关注点分离。所有业务数据以 const 常量数组的形式集中定义在文件顶部,配合一组纯函数完成数据的切片、格式化和样式映射,形成清晰的"数据层 — 逻辑层 — 视图层"三层架构。

数据层: 9个接口 + 10组常量数组

逻辑层: 20+纯函数 切片/格式化/映射

视图层: 主组件 Index

Tab1: HomeTab 站点首页

Tab2: StaffTab 员工名册

Tab3: DutyTab 值勤排班

Tab4: ExpedTab 冰原远征

Tab5: GearTab 装备仓库

Tab6: RankTab 抗寒榜单

Tab7: MineTab 个人中心

16个弹框 @Builder 统一挂载

Toast 全局提示

在这里插入图片描述

一、数据模型层:接口定义与类型约束

ArkTS 作为 TypeScript 的超集,继承了完整的类型系统。在本系统中,数据模型的定义是整个应用的基石。开发者首先通过 interface 关键字定义了 9 个数据接口和 1 个图标接口,为每一类业务实体建立了严格的类型契约。

interface CrewItem {
  id: number;
  name: string;
  role: string;
  emoji: string;
  level: number;
  cold: number;
  days: number;
  skill: string;
  phone: string;
  home: string;
  desc: string;
}

interface DutyItem {
  id: number;
  name: string;
  time: string;
  type: string;
  temp: string;
  wind: number;
  people: string;
  color: string;
}

interface ExpedItem {
  id: number;
  name: string;
  target: string;
  dist: number;
  days: number;
  level: number;
  leader: string;
  team: string;
  reward: string;
  desc: string;
}

在这里插入图片描述

CrewItem 接口定义了站员档案的完整数据结构,包含 11 个字段:id 作为唯一标识、namerole 表示姓名与角色、emoji 存储头像表情符号、level 表示站龄等级、cold 表示抗寒指数(百分比数值)、days 表示驻站天数、skill 描述专精技能、phonehome 分别记录联系方式与所在舱室、desc 是一段人物简介。这种设计将一个业务实体的所有展示所需的字段一次性聚合,使得在渲染列表项和详情弹框时可以方便地通过点语法访问任意属性。

DutyItem 接口面向值勤排班场景,其中 time 以字符串形式存储时间段(如 '06:00 - 09:00')、type 标识值勤类型、tempwind 描述环境参数、color 存储主题色值。值得注意的是,color 字段被设计为接口的一部分,这意味着配色策略在数据层就已经确定,视图层只需直接读取即可。这种"数据携带样式"的设计在原型快速迭代阶段非常高效,但也需要注意在大型项目中保持数据与样式的适当解耦。

ExpedItem 接口对应探险任务,字段覆盖了任务名称、目标地点、距离、周期、难度等级、队长、队伍成员、奖励和描述。其中 level 字段用数字 1-5 表示难度,后续会被纯函数 getStarEmoji 转换为星星表情符号进行可视化展示。这种"用数字存储语义、在渲染时转换"的模式,既保证了数据的可比较性,又兼顾了展示的灵活性。

interface GearItem {
  id: number;
  name: string;
  emoji: string;
  cat: string;
  stock: number;
  status: string;
  keeper: string;
  spec: string;
}

interface SupplyItem {
  id: number;
  name: string;
  emoji: string;
  qty: number;
  unit: string;
  store: string;
  level: number;
}

interface RankItem {
  id: number;
  name: string;
  emoji: string;
  cold: number;
  trips: number;
  bonus: string;
  color: string;
}

interface StationItem {
  id: number;
  name: string;
  emoji: string;
  zone: string;
  people: number;
  state: string;
  color: string;
}

interface AchItem {
  id: number;
  name: string;
  emoji: string;
  year: string;
  note: string;
  color: string;
}

interface SkillItem {
  id: number;
  name: string;
  emoji: string;
  lv: number;
  max: number;
  exp: number;
  color: string;
}

interface QuickIcon {
  name: string;
  emoji: string;
  color: string;
}

在这里插入图片描述

剩余的接口定义遵循同样的设计哲学。GearItem 面向装备管理,SupplyItem 面向物资申领,RankItem 面向抗寒榜单,StationItem 面向站点设施,AchItem 面向荣誉成就,SkillItem 面向技能树。每个接口的字段都精确定位到该业务实体的展示需求。SkillItem 中的 lvmaxexp 三个字段共同构成了技能等级系统:当前等级、等级上限和经验百分比,这使得技能进度条的渲染逻辑非常直观。QuickIcon 是唯一一个不含 id 字段的接口,因为它用于首页功能宫格的快捷入口图标,不需要唯一标识和详情跳转。

在 ArkTS 中,interface 定义的类型在编译期被擦除,不会产生运行时开销。但 ArkTS 的类型检查比标准 TypeScript 更加严格,例如禁止使用 any 类型、要求显式标注所有变量类型等。这种严格性虽然增加了编写时的约束,却大幅提升了代码的可靠性和可维护性,特别适合大型团队协作场景。

二、数据集合层:常量数组的集中定义

在接口定义之后,系统通过 const 关键字声明了 10 组数据集合。这些集合作为整个应用的"数据源",被各个 Tab 子组件和弹框 Builder 通过纯函数按需切片后消费。将所有数据集中定义在文件顶部,使得数据维护变得极为便捷——修改一条记录只需在一处更新,所有引用该数据的视图都会自动刷新。

const CREW_LIST: CrewItem[] = [
  { id: 1, name: '北辰', role: '站长', emoji: '🧊', level: 12, cold: 98, days: 1580, skill: '极地指挥·大师', phone: '010-5588-901', home: '北纬78°基地', desc: '驻站四年,带队完成 23 次冰盖穿越,站内公认的"冰原定海神针"。' },
  { id: 2, name: '霜月', role: '气象官', emoji: '❄️', level: 10, cold: 92, days: 1120, skill: '极夜预报·精通', phone: '010-5588-902', home: '极光观测塔', desc: '能提前三天预判暴风雪,值班日志被兄弟站点争相抄录。' },
  { id: 3, name: '雪鸮', role: '救援队长', emoji: '🦉', level: 11, cold: 95, days: 1350, skill: '冰缝救援·大师', phone: '010-5588-903', home: '救援车库', desc: '三次带队深入冰缝救援,创下零伤亡记录,外号"雪原猎鹰"。' },
  // ... 更多站员数据
];

在这里插入图片描述

CREW_LIST 包含 14 名站员的完整档案数据。每条记录都严格符合 CrewItem 接口的类型约束。这种"一条记录即一个完整业务对象"的设计,使得无论是列表卡片还是详情弹框,都可以从同一个数据源获取全部展示信息,避免了数据冗余和不一致风险。值得注意的是 level 字段的设计:站长的等级为 12,明显高于其他站员,这在视觉上通过等级标签的颜色和文字差异得到了体现。

const HOME_DUTY: DutyItem[] = [
  { id: 1, name: '气象观测', time: '06:00 - 09:00', type: '观测', temp: '-42°C', wind: 8, people: '霜月 · 风铃', color: '#4DD0E1' },
  { id: 2, name: '冰盖巡线', time: '09:30 - 13:00', type: '巡线', temp: '-38°C', wind: 12, people: '北辰 · 白熊', color: '#64B5F6' },
  { id: 3, name: '通讯守听', time: '13:00 - 16:00', type: '通讯', temp: '-40°C', wind: 6, people: '极光 · 蓝鲸', color: '#7C4DFF' },
  { id: 4, name: '温室养护', time: '16:30 - 18:00', type: '养护', temp: '6°C', wind: 0, people: '银杉 · 星屑', color: '#66BB6A' },
  { id: 5, name: '夜间救援待命', time: '19:00 - 次日 08:00', type: '待命', temp: '-45°C', wind: 15, people: '雪鸮 · 雾凇', color: '#EF5350' }
];

const DUTY_LIST: DutyItem[] = [
  { id: 6, name: '暴雪监测', time: '08:00 - 12:00', type: '监测', temp: '-46°C', wind: 22, people: '霜月 · 极光', color: '#26C6DA' },
  // ... 更多值勤班次
];

值勤数据被拆分为两个数组:HOME_DUTY 包含 5 条首页横滑展示的今日值勤班次,DUTY_LIST 包含 10 条值勤页完整排班数据。两者的 id 是连续递增的(1-15),这种设计允许通过统一的 findDuty(id) 方法在两个集合的并集中查找。temp 字段同时承载了正负温度(如 '-42°C''6°C'),后续的 getTempColor 函数通过检测字符串中是否包含 '-' 来判断是低温还是常温,并返回对应的颜色——这是一个巧妙但需要谨慎使用的字符串解析技巧。

const GEAR_LIST: GearItem[] = [
  { id: 1, name: '极地羽绒服', emoji: '🧥', cat: '防护', stock: 6, status: '可借', keeper: '仓库 A1', spec: '抗 -50°C · 加厚款' },
  { id: 2, name: '冰爪防滑鞋', emoji: '🥾', cat: '鞋具', stock: 4, status: '可借', keeper: '仓库 A2', spec: '钛合金爪 · 42-44 码' },
  // ... 更多装备
];

const SUPPLY_LIST: SupplyItem[] = [
  { id: 1, name: '压缩饼干', emoji: '🍪', qty: 1200, unit: '块', store: '冷库 A', level: 2 },
  { id: 2, name: '冻干蔬菜', emoji: '🥦', qty: 480, unit: '袋', store: '冷库 B', level: 1 },
  // ... 更多物资
];

const RANK_LIST: RankItem[] = [
  { id: 1, name: '北辰', emoji: '🧊', cold: 98, trips: 23, bonus: '🏆 站长金杯', color: '#FFD54F' },
  { id: 2, name: '雪鸮', emoji: '🦉', cold: 95, trips: 21, bonus: '🥈 救援银盾', color: '#B0BEC5' },
  // ... 更多排名
];

在这里插入图片描述

装备列表 GEAR_LIST 包含 12 件极地装备,物资列表 SUPPLY_LIST 包含 10 类补给物资,榜单 RANK_LIST 包含 10 名站员的抗寒排名。每个数据集合都为对应的 Tab 页和弹框提供了充足的展示数据。SUPPLY_LIST 中的 level 字段用 1-3 表示库存紧急程度,这种分级设计使得物资申领弹框可以通过颜色或图标直观地提示优先级。

鸿蒙 ArkTS 中的 const 声明与标准 TypeScript 行为一致:保证变量引用不可变,但不保证对象内容不可变。然而在本系统中,这些数组被设计为只读数据源,所有切片函数都返回新数组而非修改原数组,遵循了函数式编程中的不可变数据原则。这种做法避免了状态污染,确保每次渲染都基于原始数据的纯净切片。

三、纯函数层:数据切片与样式映射

在数据集合与视图之间,系统定义了一组纯函数作为"数据管道"。这些函数接收原始数据集合和参数,返回经过切片或转换的结果,不产生副作用。它们是整个应用中最具复用价值的代码,体现了"数据转换逻辑与视图渲染逻辑分离"的良好实践。

function getCrewRows(n: number): CrewItem[] {
  let arr: CrewItem[] = [];
  let cnt = n < CREW_LIST.length ? n : CREW_LIST.length;
  for (let i = 0; i < cnt; i++) {
    arr.push(CREW_LIST[i]);
  }
  return arr;
}

function getCrewRows2(): CrewItem[] {
  let arr: CrewItem[] = [];
  for (let i = 0; i < CREW_LIST.length; i += 2) {
    arr.push(CREW_LIST[i]);
  }
  return arr;
}

function getCrewRows3(): CrewItem[] {
  let arr: CrewItem[] = [];
  for (let i = 1; i < CREW_LIST.length; i += 2) {
    arr.push(CREW_LIST[i]);
  }
  return arr;
}

getCrewRows(n) 函数实现了"取前 N 条"的切片逻辑。内部使用三元运算符 n < CREW_LIST.length ? n : CREW_LIST.length 来防止越界访问,确保当请求的数量超过数组实际长度时安全地返回全部数据。这种防御性编程在处理动态数据时尤为重要。函数体使用 for 循环和 push 方法构建新数组,虽然不如 slice 简洁,但在 ArkTS 的严格类型环境下更为明确。

getCrewRows2()getCrewRows3() 这对函数实现了"交错分列"的逻辑:前者取偶数索引(0, 2, 4…),后者取奇数索引(1, 3, 5…)。这对函数被 StaffTab 组件用于实现双列瀑布流布局——左列渲染偶数索引的站员卡片,右列渲染奇数索引的站员卡片。这种手动分列的方式是 ArkTS 中实现多列布局的一种常见手段,因为 ForEach 本身不支持直接配置列数。类似的模式还出现在装备页的三列网格(getGearRows2/3/4,步长为 3)和荣誉墙的三列网格(getAchRows2/3/4,步长为 3)中。

function getBarHeight(v: number): number {
  return Math.round(v * 1.4);
}

function getStarEmoji(n: number): string {
  let s = '';
  for (let i = 0; i < n; i++) {
    s += '⭐';
  }
  return s;
}

function getLevelName(n: number): string {
  if (n <= 3) {
    return '见习极地生';
  }
  if (n <= 6) {
    return '极地行者';
  }
  if (n <= 9) {
    return '冰原精英';
  }
  return '极光大师';
}

getBarHeight(v) 函数将抗寒指数(0-100 的百分比值)转换为柱状图高度像素值,乘数为 1.4 并取整。这意味着抗寒指数 98 对应的高度为 137 像素。这个简单的线性映射函数是榜单页柱状图可视化的核心。getStarEmoji(n) 通过循环拼接字符串生成难度星级显示,例如难度 5 会生成 '⭐⭐⭐⭐⭐'getLevelName(n) 使用阶梯式 if 判断将数字等级映射为语义化的称号文本,分为见习极地生、极地行者、冰原精英和极光大师四档。

function getRoleColor(role: string): string {
  if (role === '站长') {
    return '#FFD54F';
  }
  if (role === '救援队长') {
    return '#EF5350';
  }
  if (role === '气象官') {
    return '#4DD0E1';
  }
  if (role === '后勤主管') {
    return '#FFA726';
  }
  if (role === '生物学家' || role === '地质员') {
    return '#AB47BC';
  }
  if (role === '医生') {
    return '#EC407A';
  }
  return '#64B5F6';
}

function getDutyColor(type: string): string {
  if (type === '观测' || type === '监测') {
    return '#4DD0E1';
  }
  if (type === '巡线' || type === '巡逻') {
    return '#64B5F6';
  }
  if (type === '通讯' || type === '值守') {
    return '#7C4DFF';
  }
  if (type === '科考' || type === '采样') {
    return '#AB47BC';
  }
  if (type === '待命' || type === '侦察') {
    return '#EF5350';
  }
  if (type === '检修') {
    return '#FFA726';
  }
  return '#26C6DA';
}

function getDutyIcon(type: string): string {
  if (type === '观测' || type === '监测') {
    return '🌡️';
  }
  if (type === '巡线' || type === '巡逻') {
    return '🧭';
  }
  if (type === '通讯') {
    return '📡';
  }
  if (type === '科考' || type === '采样') {
    return '🔬';
  }
  if (type === '待命' || type === '侦察') {
    return '🚨';
  }
  if (type === '检修') {
    return '🔧';
  }
  if (type === '餐饮') {
    return '🍲';
  }
  return '🛎️';
}

getRoleColorgetDutyColor 是两个典型的"语义到颜色"映射函数。它们接收业务语义字符串(如角色名或值勤类型),返回对应的十六进制颜色值。getRoleColor 为站长返回金色、为救援队长返回红色、为气象官返回冰晶青,通过颜色直观传达角色的职能特征。getDutyColor 则将值勤类型映射为不同色系:观测类用冰晶青、巡线类用蓝色、通讯类用极光紫、科考类用紫色、待命类用警示红、检修类用橙色。

getDutyIcon 函数与 getDutyColor 配套使用,将值勤类型映射为表情符号图标。观测类用温度计、巡线类用指南针、通讯类用卫星天线、科考类用显微镜。这种"一词映射一图一色"的设计使得每种值勤类型在视觉上形成统一的识别系统。三个函数都使用连续的 if 语句而非 switch 或查找表,这在条目数量较少时清晰直观,但如果类型持续增长,重构为 Record<string, string> 查找表会更易维护。

纯函数的核心价值在于"可预测性"——相同的输入永远产生相同的输出,不依赖也不修改外部状态。在本系统中,所有切片函数和映射函数都严格遵循这一原则。它们不引用任何 @State 变量,不修改任何 const 数组,只做"输入到输出"的转换。这种设计使得这些函数可以被任意组件在任何时机安全调用,无需担心副作用引发的状态混乱。

四、主组件状态管理:@State 装饰器的响应式体系

主组件 Index 是整个应用的神经中枢,通过 @Entry@Component 装饰器标记为入口组件。它内部管理着数十个 @State 变量,构成了一个完整的响应式状态树。每当任何 @State 变量发生变化,框架会自动触发关联的 UI 片段重新渲染,无需手动调用刷新方法。

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  // 16 弹框开关
  @State showSign: boolean = false;
  @State showStaff: boolean = false;
  @State showDuty: boolean = false;
  @State showExped: boolean = false;
  @State showGear: boolean = false;
  @State showReturn: boolean = false;
  @State showRank: boolean = false;
  @State showBadge: boolean = false;
  @State showAurora: boolean = false;
  @State showRescue: boolean = false;
  @State showSupply: boolean = false;
  @State showNight: boolean = false;
  @State showSkill: boolean = false;
  @State showDel: boolean = false;
  @State showCaptain: boolean = false;
  @State showIce: boolean = false;
  // 选中数据(初始 null,build 中防空)
  @State selCrew: CrewItem | null = null;
  @State selDuty: DutyItem | null = null;
  @State selExped: ExpedItem | null = null;
  @State selGear: GearItem | null = null;
  @State selRank: RankItem | null = null;
  @State selSupply: SupplyItem | null = null;
  @State selStation: StationItem | null = null;
  @State selAch: AchItem | null = null;
  @State selSkill: SkillItem | null = null;
  // 表单字段
  @State fDate: string = '08-28';
  @State fShift: string = '白班';
  @State fQty: number = 1;
  @State fDays: number = 2;
  // Toast
  @State toast: string = '';

@State currentTab: number = 0 是标签切换的核心状态变量,初始值为 0(站点首页)。当用户点击底部导航栏时,currentTab 被更新为对应索引值,触发 build() 方法中的 if/else if 分支重新求值,从而切换显示的 Tab 子组件。鸿蒙 ArkTS 的 @State 装饰器内部使用了观察者模式:变量被框架代理(Proxy),任何赋值操作都会通知依赖该变量的 UI 片段进行更新。

16 个布尔型 @State 变量(showSignshowIce)控制着 16 个弹框的显示与隐藏。这种"一个布尔值对应一个弹框"的设计虽然变量数量多,但语义极其清晰——哪个弹框需要显示就将对应的变量设为 true,关闭时设为 false。由于 ArkTS 的 @State 只能触发值比较级别的更新,布尔值的 true/false 切换是最轻量的状态变化,不会带来性能负担。

9 个选中数据变量使用了联合类型 CrewItem | null(以及其他接口的对应类型),初始值均为 null。这种设计允许弹框在未选中任何数据时安全地不渲染。在 build() 方法中,弹框的挂载条件是 this.showXxx && this.selXxx !== null,双重保证避免了空指针访问。当用户点击列表项时,对应的 findXxx(id) 方法先填充选中数据,再打开弹框开关,两步操作在同一个回调中原子性地完成。

表单字段 fDatefShiftfQtyfDays 用于值勤申请弹框和装备借用弹框中的用户输入。fDate 存储选择的日期,fShift 存储班次类型,fQty 存储借用数量,fDays 存储借用天数。这些变量在弹框打开时被初始化为默认值,在用户交互过程中通过 onClick 回调更新,最终在提交时被读取生成 Toast 提示消息。

@State 是 ArkTS 中最基础的状态装饰器,它的作用域限定于当前组件内部。当需要跨组件传递状态时,需要使用 @Prop(单向同步)、@Link(双向同步)或 @Provide/@Consume(跨层级传递)。本系统选择了"回调函数"模式进行子组件到父组件的通信,这是一种更灵活、更松耦合的方案,避免了状态装饰器的层级依赖。

五、查找方法与底部导航:数据检索与标签路由

主组件内部定义了一组 findXxx(id) 实例方法,用于根据 ID 从对应的数据集合中查找单条记录。这些方法在用户点击列表项时被调用,将查找到的数据赋值给对应的 selXxx 状态变量,随后打开相应的弹框。

findCrew(id: number): CrewItem {
  for (let i = 0; i < CREW_LIST.length; i++) {
    if (CREW_LIST[i].id === id) {
      return CREW_LIST[i];
    }
  }
  return CREW_LIST[0];
}

findDuty(id: number): DutyItem {
  for (let i = 0; i < DUTY_LIST.length; i++) {
    if (DUTY_LIST[i].id === id) {
      return DUTY_LIST[i];
    }
  }
  return DUTY_LIST[0];
}

findCrew(id) 方法遍历 CREW_LIST 数组,逐个比较元素的 id 字段,找到匹配项后立即返回。如果遍历结束仍未找到,则返回数组的第一个元素作为兜底默认值。这种"安全降级"策略确保了即使用户传入不存在的 ID,方法也不会返回 undefined,避免了后续渲染时的空指针异常。所有 9 个 findXxx 方法都遵循这一模式。

这些方法被定义为实例方法(在 struct 内部声明)而非顶层纯函数,是因为它们需要在 build() 方法的回调闭包中通过 this 访问。在 ArkTS 中,struct 内部的方法可以通过 this.methodName() 调用,而 @State 变量的赋值也必须通过 this.xxx = value 完成。将查找逻辑封装为方法而非内联在回调中,使得代码结构更加清晰,也便于在多个回调中复用同一段查找逻辑。

private tabs1: string[] = ['站点', '员工', '值勤', '探险'];
private tabs2: string[] = ['装备', '榜单', '我的'];

底部导航栏的数据由两个 private 数组定义:tabs1 包含前 4 个标签(站点、员工、值勤、探险),tabs2 包含后 3 个标签(装备、榜单、我的)。这种将标签分为两排的设计在移动端 UI 中并不常见,但在本系统中通过两行 Row + ForEach 的布局实现了"7 Tab 两排底栏"的独特导航结构。private 修饰符确保这些数组不会被外部组件访问,遵循了封装原则。

底部导航

点击 idx 0-3

点击 idx 4-6

触发 if/else 分支

Row 排1: 站点 员工 值勤 探险

Row 排2: 装备 榜单 我的

currentTab = idx

currentTab=0: HomeTab

currentTab=1: StaffTab

currentTab=2: DutyTab

currentTab=3: ExpedTab

currentTab=4: GearTab

currentTab=5: RankTab

currentTab=6: MineTab

六、build 方法:头部横幅与标签路由

build() 方法是每个 @Component 的核心,它描述了组件的 UI 结构。在主组件 Index 中,build() 方法由头部横幅、内容区(标签路由)、底部导航栏和弹框挂载区四大部分组成,构成了完整的页面骨架。

build() {
  Column() {
    // ===== 头部:极光渐变横幅 =====
    Column() {
      Row() {
        Text('🏔️')
          .fontSize(24)
        Text('POLAR PEAK')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ left: 6 })
        Text('')
          .layoutWeight(1)
        Text('🔔')
          .fontSize(18)
          .onClick(() => {
            this.toast = '🔔 极夜警报:今晚 20:00 起风力增强';
          })
        Text('')
          .width(14)
        Text('🌡️ -42°C')
          .fontSize(11)
          .fontColor('#B3E5FC')
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.14)')
          .borderRadius(10)
      }
      .width('100%')

      Text('极地站人事乐园')
        .fontSize(23)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .margin({ top: 10 })
      Text('POLAR STATION HR · 冰原之上,温暖之家')
        .fontSize(11)
        .fontColor('#B3E5FC')
        .width('100%')
        .margin({ top: 5 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 16 })
    .linearGradient({
      angle: 135,
      colors: [['#0B2F5C', 0.0], ['#1565C0', 0.55], ['#1A237E', 1.0]]
    })
    .shadow({ radius: 14, color: 'rgba(21,101,192,0.35)', offsetY: 4 })

头部横幅由嵌套的 ColumnRow 组合而成。最外层 Column 通过 linearGradient 属性设置了 135 度角的三段渐变背景:从冰川深蓝 #0B2F5C 经海蓝 #1565C0 过渡到深靛蓝 #1A237E,营造出极地冰川的深邃氛围。shadow 属性添加了带有 Y 轴偏移的投影,使横幅在视觉上浮于内容之上。

第一行 Row 是顶部工具栏,包含品牌图标、标题文字、弹性间距、通知铃铛和温度标签。这里有一个重要的布局技巧:Text('').layoutWeight(1) 创建了一个空的 Text 组件并赋予 layoutWeight(1),它的作用等同于 CSS 中的 flex: 1,将剩余空间全部占据,从而把后续的铃铛和温度标签推到右侧。这种"空组件占位"的模式在 ArkTS 的 Row 布局中非常常见,因为 ArkTS 不支持 CSS 的 justify-content: space-betweenRow 上的直接等价写法(虽然 FlexAlign 可以实现,但空组件占位更灵活)。

linearGradient 的参数结构是一个对象,包含 angle(渐变角度)和 colors(颜色停靠点数组)。每个颜色停靠点是一个 [颜色值, 位置] 的元组,位置值范围 0.0 到 1.0。0.55 的位置意味着第二种颜色在渐变路径的 55% 处达到完全强度。通过精心调整这些位置值,可以控制渐变的过渡节奏——本例中从中蓝到深蓝的过渡被推迟到后半段,使得上半部分更明亮、下半部分更深沉。

      Row() {
        Text('🔍')
          .fontSize(14)
        Text('搜索员工 / 任务 / 装备…')
          .fontSize(12)
          .fontColor('#B3E5FC')
          .margin({ left: 8 })
        Text('')
          .layoutWeight(1)
        Text('❄️')
          .fontSize(14)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 8, bottom: 8 })
      .backgroundColor('rgba(255,255,255,0.12)')
      .borderRadius(12)
      .margin({ top: 12 })

      Row() {
        Text('🧊 站员 14')
          .fontSize(10)
          .fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('rgba(255,255,255,0.16)')
          .borderRadius(8)
        Text('')
          .width(8)
        Text('⛺ 设施 8')
          .fontSize(10)
          .fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('rgba(255,255,255,0.16)')
          .borderRadius(8)
        Text('')
          .layoutWeight(1)
        Text('✦ ✦ ✦')
          .fontSize(10)
          .fontColor('#E1F5FE')
      }
      .width('100%')
      .margin({ top: 12 })

搜索栏和统计标签行是头部横幅的补充区域。搜索栏使用半透明白色背景 rgba(255,255,255,0.12) 叠加在渐变之上,形成毛玻璃质感。统计标签行展示"站员 14""设施 8""极夜 D-12"三个胶囊状标签,每个标签通过 paddingborderRadius 形成圆角胶囊外观。标签之间的间距通过 Text('').width(8) 实现——一个宽度为 8 的空 Text 充当间隔符,比 margin 方式更直观可控。

rgba() 颜色格式在 ArkTS 中被完整支持,可以精确控制透明度通道。本系统大量使用 rgba(255,255,255,0.12)rgba(255,255,255,0.20) 范围的半透明白色来创建层次感——同一色系的不同透明度叠加,既保持了视觉统一性,又区分了信息层级。这是极地冰雪主题设计的核心配色策略。

    // ===== 内容区 =====
    if (this.currentTab === 0) {
      HomeTab({
        onOpenDuty: (id: number) => {
          this.selDuty = this.findDuty(id);
          this.showDuty = true;
        },
        onOpenStation: (id: number) => {
          this.selStation = this.findStation(id);
          this.showNight = true;
        },
        onSign: () => {
          this.showSign = true;
        },
        onAurora: () => {
          this.showAurora = true;
        },
        onToast: (msg: string) => {
          this.toast = msg;
        }
      })
    } else if (this.currentTab === 1) {
      StaffTab({
        onOpenStaff: (id: number) => {
          this.selCrew = this.findCrew(id);
          this.showStaff = true;
        },
        onEditStaff: (id: number) => {
          this.selCrew = this.findCrew(id);
          this.showSkill = true;
        },
        onDelStaff: (id: number) => {
          this.selCrew = this.findCrew(id);
          this.showDel = true;
        },
        onAddStaff: () => {
          this.fDate = '08-28';
          this.fShift = '白班';
          this.showCaptain = true;
        }
      })
    } else if (this.currentTab === 2) {
      DutyTab({
        onOpenDuty: (id: number) => {
          this.selDuty = this.findDuty(id);
          this.showDuty = true;
        },
        onApply: () => {
          this.fDate = '08-29';
          this.fShift = '夜班';
          this.showBadge = true;
        }
      })
    }

内容区是标签路由的核心。通过 if / else if 链式判断 this.currentTab 的值,条件性地渲染对应的 Tab 子组件。这是 ArkTS 声明式 UI 的条件渲染语法——与 React 中的 {condition && <Component />} 或 Vue 中的 v-if 类似,但直接使用语言原生的 if 语句。

每个 Tab 子组件在实例化时接收一组回调函数作为参数。以 HomeTab 为例,它接收 5 个回调:onOpenDuty(打开值勤详情)、onOpenStation(打开站点详情)、onSign(打开签到弹框)、onAurora(打开极光弹框)和 onToast(显示 Toast 提示)。每个回调的函数体都在主组件的上下文中执行,可以直接访问和修改 this@State 变量。这种"子组件触发事件、父组件处理状态"的模式,实现了组件间的松耦合通信。

回调函数的设计模式在这里体现了"依赖倒置"原则:子组件不直接知道弹框如何打开、状态如何变化,它只负责在用户交互时"喊一声"(调用回调),具体"做什么"由父组件决定。这使得 Tab 子组件可以在不同的上下文中复用——只要传入不同的回调集合,同一个 HomeTab 可以连接到不同的弹框系统。

主组件 Index 状态处理

子组件 HomeTab

onOpenDuty id

onSign

onAurora

用户点击值勤卡片

findDuty id -> selDuty

用户点击签到按钮

showSign = true

用户点击极光卡片

showAurora = true

showDuty = true

挂载 dutyModal

挂载 signModal

挂载 auroraModal

七、底部导航栏与弹框挂载

底部导航栏采用两排布局,每排通过 ForEach 渲染对应的标签数组。弹框挂载区则在 build() 方法的末尾,通过 16 个条件判断逐一挂载所有弹框。

      // ===== 底部两排 tab =====
      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: 10, color: 'rgba(11,47,92,0.12)', offsetY: -2 })

底部导航容器是一个 Column,内含两个 Row。第一行 Row 通过 ForEach 遍历 tabs1 数组(站点、员工、值勤、探险),第二行 Row 遍历 tabs2 数组(装备、榜单、我的)。关键细节在于第二行的索引偏移:this.bottomTabItem(getTabIcon(tb), tb, ti + 4)tabs2 的本地索引 ti(0-2)加 4 后传入,使其与 currentTab 的全局索引(4-6)对齐。

ForEach 的第三个参数是键值生成器(key generator),接收元素和索引,返回一个唯一字符串。这里使用 tb + String(ti) 将标签名和索引拼接为键值。虽然在本例中标签名本身就是唯一的,但加入索引可以确保即使出现重名标签也不会产生键冲突。ForEach 通过键值来追踪列表项的增删改,高效的 diff 算法依赖于键值的唯一性和稳定性。

  @Builder
  bottomTabItem(icon: string, label: string, idx: number) {
    Column() {
      Text(icon)
        .fontSize(17)
        .fontColor(this.currentTab === idx ? '#1565C0' : '#9E9E9E')
      Text(label)
        .fontSize(10)
        .fontColor(this.currentTab === idx ? '#1565C0' : '#9E9E9E')
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .onClick(() => {
      this.currentTab = idx;
    })
  }

bottomTabItem 是一个 @Builder 方法,它接收图标、标签文本和索引三个参数,构建一个底部导航项。导航项由 Column 包裹图标和文字组成,通过 layoutWeight(1)Row 中等分宽度。颜色逻辑使用了三元运算符:当 this.currentTab === idx 时(当前选中的标签),图标和文字使用海蓝 #1565C0,否则使用灰色 #9E9E9E。点击时将 this.currentTab 设置为对应索引,触发标签切换。

@Builder 装饰器是 ArkTS 中实现 UI 片段复用的核心机制。被 @Builder 修饰的方法返回一个 UI 描述,可以在 build() 方法或其他 @Builder 方法中通过 this.methodName(args) 调用。与 @Component 不同,@Builder 不创建独立的组件实例,而是在调用处内联展开,因此没有独立的状态和生命周期,但可以访问所属组件的 this。这使得 @Builder 非常适合抽取重复的 UI 结构片段。

      // ===== 弹框挂载(16 个) =====
      if (this.showSign) {
        this.modalOverlay(() => {
          this.showSign = false;
        }) {
          this.signModal()
        }
      }
      if (this.showStaff && this.selCrew !== null) {
        this.modalOverlay(() => {
          this.showStaff = false;
        }) {
          this.staffModal()
        }
      }
      if (this.showDuty && this.selDuty !== null) {
        this.modalOverlay(() => {
          this.showDuty = false;
        }) {
          this.dutyModal()
        }
      }

弹框挂载区是 build() 方法中最具设计感的部分。16 个弹框通过 16 个 if 条件块逐一挂载,每个条件块遵循统一的三段式结构:检查开关变量和选中数据是否就绪、调用 modalOverlay 提供遮罩层和关闭回调、在遮罩层的内容槽中渲染对应的弹框 @Builder

modalOverlay 是一个特殊的 @Builder,它接收一个 onClose 回调参数,同时通过尾随闭包语法接收一个内容构建器。这种"参数 + 闭包"的调用方式是 ArkTS @Builder 的高级用法——onClose 是普通参数,花括号 { this.signModal() } 是内容槽,会被渲染到遮罩层内部。当用户点击遮罩层(半透明黑色背景)时,onClose 回调被触发,将对应的开关变量设为 false,弹框消失。

条件 this.showStaff && this.selCrew !== null 是双重保护:开关变量确保只在需要时渲染,null 检查确保弹框内部访问 this.selCrew!.name 等属性时不会因空指针崩溃。ArkTS 中的 ! 非空断言操作符用于告诉编译器"我确定这里不是 null",但前提是开发者通过逻辑保证了这个断言成立——这里的 !== null 检查就是那道逻辑保障。

  @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),点击时调用 onClose 回调。空 Column 作为遮罩层,其内容槽(尾随闭包)渲染的弹框内容会叠加在遮罩层之上。这种设计巧妙地利用了 @Builder 的内容槽机制,将"遮罩层 + 内容"的复合结构封装为一个可复用的模板。

鸿蒙 ArkTS 的 @Builder 尾随闭包语法是构建复合 UI 结构的利器。它允许将 UI 片段作为"内容"传递给另一个 @Builder,类似于 React 中的 children prop 或 SwiftUI 中的 @ViewBuilder。这种模式在弹框、卡片、列表项等需要"外壳 + 内容"分离的场景中极为实用。

八、弹框系统:16 种差异化弹框设计

本系统的 16 个弹框各具特色,在布局方式、视觉风格和交互模式上都做了差异化设计。以下选取几个代表性弹框进行深入剖析。

  // ===== 弹框 1:极地签到(底部抽屉,冰晶渐变头) =====
  @Builder
  signModal() {
    Column() {
      Column() {
        Text('📋 极地签到')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('今日已连续签到 12 天 · 极夜能量 +120')
          .fontSize(11)
          .fontColor('#B3E5FC')
          .margin({ top: 6 })
        Row() {
          Text('D-1')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
          Text('D-2')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
          // ... D-3 到 D-7
          Text('D-6')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFD54F')
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .padding(16)
      .linearGradient({
        angle: 135,
        colors: [['#0D47A1', 0.0], ['#1E88E5', 1.0]]
      })

      Column() {
        Row() {
          Text('🎁 连续签到奖励')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0B2F5C')
          Text('')
            .layoutWeight(1)
          Text('已领取 5/7')
            .fontSize(10)
            .fontColor('#9E9E9E')
        }
        .width('100%')

        Row() {
          Column() {
            Text('🥫')
              .fontSize(20)
            Text('罐头×2')
              .fontSize(10)
              .fontColor('#616161')
              .margin({ top: 4 })
            Text('D-1 领取')
              .fontSize(8)
              .fontColor('#4DD0E1')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          // ... 更多奖励列
        }
        .width('100%')
        .padding({ top: 12 })

        Row() {
          Text('✅ 立即签到')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .textAlign(TextAlign.Center)
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .backgroundColor('#1565C0')
            .borderRadius(12)
            .onClick(() => {
              this.showSign = false;
              this.toast = '✅ 签到成功:极夜能量 +120';
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
    }
    .width('100%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 22, topRight: 22 })
    .shadow({ radius: 18, color: 'rgba(11,47,92,0.35)', offsetY: -4 })
  }

签到弹框采用"底部抽屉"布局模式。外层 Column 宽度 100%、背景白色、仅顶部圆角(borderRadius({ topLeft: 22, topRight: 22 })),模拟从底部滑出的抽屉效果。constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%,防止内容过多时撑满全屏。阴影的 offsetY: -4 使阴影向上投射,增强抽屉"从下方升起"的视觉暗示。

弹框内部分为头部和内容体两部分。头部使用蓝色渐变背景,包含标题、连续签到天数和 7 天签到日历(D-1 到 D-7 的 Row)。日历中第 6 天(D-6)使用金色 #FFD54F 字体,表示今天是可签到的当日。内容体为白色背景,包含奖励列表和签到按钮。奖励列表使用 layoutWeight(1) 的四列 Column 等分排列,每列展示一个奖励的图标、名称和领取状态。

签到按钮的交互逻辑值得注意:点击后先将 this.showSign 设为 false(关闭弹框),再设置 this.toast 为成功消息。两个状态赋值在同一回调中顺序执行,框架会批量处理这些状态变更,在下一帧统一渲染——弹框消失的同时 Toast 提示浮现。这种"关闭弹框 + 显示反馈"的组合模式在所有 16 个弹框的提交按钮中反复出现。

  // ===== 弹框 2:员工档案(居中卡,冰晶蓝) =====
  @Builder
  staffModal() {
    Column() {
      Column() {
        Text(this.selCrew!.emoji)
          .fontSize(46)
        Text(this.selCrew!.name)
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0B2F5C')
          .margin({ top: 6 })
        Text(this.selCrew!.role + ' · ' + getLevelName(this.selCrew!.level))
          .fontSize(11)
          .fontColor('#1565C0')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 22, bottom: 16 })
      .linearGradient({
        angle: 160,
        colors: [['#E1F5FE', 0.0], ['#FFFFFF', 1.0]]
      })
      .borderRadius({ topLeft: 18, topRight: 18 })

      Column() {
        Row() {
          Column() {
            Text('Lv.' + this.selCrew!.level)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0D47A1')
            Text('站龄等级')
              .fontSize(9)
              .fontColor('#9E9E9E')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          Column() {
            Text(String(this.selCrew!.cold) + '%')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4DD0E1')
            Text('抗寒指数')
              .fontSize(9)
              .fontColor('#9E9E9E')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          Column() {
            Text(String(this.selCrew!.days) + ' 天')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#7C4DFF')
            Text('驻站时长')
              .fontSize(9)
              .fontColor('#9E9E9E')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#F0F8FF')
        .borderRadius(12)
        .margin({ top: 12 })

员工档案弹框采用"居中卡片"布局模式。外层 Column 宽度为 82%(width('82%')),四周圆角 18,通过 shadow 添加深色投影,使卡片悬浮于遮罩层之上。与签到弹框的底部抽屉不同,居中卡片不设 constraintSize,而是通过内容自然撑开高度。

头部区域使用从浅蓝 #E1F5FE 到白色 #FFFFFF 的渐变,模拟冰晶光泽效果。this.selCrew!.emoji 以 46 的字号展示站员头像表情,紧随其后的是姓名和"角色 + 等级称号"组合文本。getLevelName(this.selCrew!.level) 在此处被调用,将数字等级转换为语义化称号(如等级 12 对应"极光大师")。

数据统计区使用三列等分布局,每列通过 layoutWeight(1) 等分宽度。每列展示一个数值和其标签:站龄等级、抗寒指数、驻站时长。数值使用加粗大字号和不同颜色(深蓝、冰晶青、极光紫),标签使用灰色小字号,形成清晰的主次对比。String(this.selCrew!.cold) + '%' 中的 String() 调用是必要的类型转换——ArkTS 的 + 运算符在数字和字符串之间需要显式转换,不会像 JavaScript 那样自动隐式转换。

        Row() {
          Text('✉️ 发消息')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1565C0')
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor('#E3F2FD')
            .borderRadius(12)
            .onClick(() => {
              this.showStaff = false;
              this.toast = '📨 已向 ' + this.selCrew!.name + ' 发送极地短讯';
            })
          Text('')
            .width(10)
          Text('🎖️ 授予勋章')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor('#1565C0')
            .borderRadius(12)
            .onClick(() => {
              this.showStaff = false;
              this.toast = '🎖️ 勋章已授予 ' + this.selCrew!.name;
            })
        }
        .width('100%')
        .margin({ top: 12 })

员工档案弹框底部的双按钮区域展示了 ArkTS 中"等分按钮组"的标准实现模式。两个按钮 Text 各自 layoutWeight(1) 等分宽度,中间通过 Text('').width(10) 创建 10 像素的间距。左按钮是次要操作(发消息),使用浅蓝背景和深蓝文字;右按钮是主要操作(授予勋章),使用深蓝背景和白色文字。这种"次要 + 主要"的视觉层级通过背景色反转来区分,是移动端弹框按钮设计的经典模式。

  // ===== 弹框 6:装备归还确认(深色警示卡) =====
  @Builder
  returnModal() {
    Column() {
      Text('⚠️')
        .fontSize(42)
      Text('确认归还装备?')
        .fontSize(19)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 10 })
      Text(this.selGear!.emoji + ' ' + this.selGear!.name + ' ×' + String(this.fQty))
        .fontSize(13)
        .fontColor('#B3E5FC')
        .margin({ top: 6 })
      Text('归还后将自动检测磨损,如有损坏将从站积中扣除维修费')
        .fontSize(10)
        .fontColor('#90CAF9')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ top: 10 })
      Row() {
        Text('再想想')
          .fontSize(13)
          .fontColor('#B3E5FC')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('rgba(255,255,255,0.12)')
          .borderRadius(12)
          .onClick(() => {
            this.showReturn = false;
          })
        Text('')
          .width(10)
        Text('确认归还')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0D47A1')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#4DD0E1')
          .borderRadius(12)
          .onClick(() => {
            this.showReturn = false;
            this.toast = '✅ ' + this.selGear!.name + ' 已归还至 ' + this.selGear!.keeper;
          })
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('80%')
    .padding(20)
    .linearGradient({
      angle: 160,
      colors: [['#0D2137', 0.0], ['#0B2F5C', 1.0]]
    })
    .borderRadius(18)
    .shadow({ radius: 20, color: 'rgba(11,47,92,0.5)', offsetY: 6 })
  }

装备归还弹框采用"深色警示卡"风格,与前两个弹框的白色背景形成鲜明对比。整个卡片使用从近黑 #0D2137 到深蓝 #0B2F5C 的渐变背景,文字主体为白色和浅蓝色,营造出严肃的确认氛围。这种深色弹框在用户执行不可逆或需要谨慎的操作时使用——归还装备可能涉及磨损检测和费用扣除,深色背景起到了视觉警示作用。

按钮组的设计与员工档案弹框相反:左按钮"再想想"使用半透明白色背景,是取消操作;右按钮"确认归还"使用冰晶青 #4DD0E1 实心背景,文字为深蓝色。在深色背景上,冰晶青的高亮效果格外醒目,引导用户注意确认按钮的同时,也通过颜色区分了"安全"(取消)和"需要注意"(确认)两种操作语义。

  // ===== 弹框 11:物资申领(底部抽屉,物资列表) =====
  @Builder
  supplyModal() {
    Column() {
      Column() {
        Text('📦 冬季物资申领')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('后勤主管 · 海雀 已开启 30 分钟快速通道')
          .fontSize(11)
          .fontColor('#B3E5FC')
          .margin({ top: 6 })
      }
      .width('100%')
      .padding(18)
      .linearGradient({
        angle: 135,
        colors: [['#E65100', 0.0], ['#FB8C00', 1.0]]
      })

      Scroll() {
        Column() {
          ForEach(getSupplyRows(5), (item: SupplyItem) => {
            Row() {
              Text(item.emoji)
                .fontSize(26)
              Column() {
                Text(item.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#212121')
                Text(item.store + ' · 库存 ' + String(item.qty) + ' ' + item.unit)
                  .fontSize(9)
                  .fontColor('#9E9E9E')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text('')
                .layoutWeight(1)
              Text('申领')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .backgroundColor('#FB8C00')
                .borderRadius(10)
                .onClick(() => {
                  this.selSupply = this.findSupply(item.id);
                  this.toast = '📦 已申领 ' + item.name + ',请到 ' + item.store + ' 领取';
                })
            }
            .width('100%')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .backgroundColor('#FFF8F0')
            .borderRadius(12)
            .margin({ top: 8 })
          }, (item: SupplyItem) => String(item.id))
        }
        .width('100%')
      }
      .width('100%')
      .constraintSize({ maxHeight: 260 })
      .margin({ top: 10 })
      .scrollBar(BarState.Off)

物资申领弹框是唯一在弹框内部使用 Scroll 滚动容器的弹框。头部使用橙色渐变(#E65100#FB8C00),呼应后勤物资的温暖色调。内容区是一个 Scroll 容器,constraintSize({ maxHeight: 260 }) 限制了滚动区域的最大高度为 260 像素,超出部分通过滚动查看。scrollBar(BarState.Off) 隐藏了滚动条,使界面更简洁。

Scroll 内部通过 ForEach(getSupplyRows(5), ...) 渲染 5 条物资记录。每条记录是一个 Row,左侧是物资表情图标,中间是名称和库存信息(通过嵌套 Column 实现两行文本),右侧是"申领"按钮。HorizontalAlign.Start 使嵌套 Column 的内容左对齐,Text('').layoutWeight(1) 将按钮推到最右侧。

ForEach 的键值生成器使用 String(item.id),将物资 ID 转换为字符串作为唯一键。这种基于业务 ID 的键值策略是最佳实践——它确保即使列表顺序变化,框架也能通过 ID 正确追踪每条记录,避免不必要的重新渲染。申领按钮的 onClick 回调中,先通过 this.findSupply(item.id) 查找完整数据并赋值给 this.selSupply,再设置 Toast 消息。虽然 selSupply 状态变量在此弹框中未被直接使用(弹框内容不依赖它),但赋值操作保留了"选中数据可追溯"的设计一致性。

Scroll 组件是 ArkTS 中处理内容溢出的标准方案。与 List 组件不同,Scroll 不提供列表项回收机制,适用于条目数量有限的场景。本系统中弹框内的物资列表最多 5 条,使用 Scroll 完全合适。如果列表项可能达到数百条,则应改用 List + LazyForEach 以获得性能优势。

九、Toast 全局提示组件

Toast 是一种轻量级的操作反馈机制,在用户执行操作后短暂显示提示消息,自动消失。本系统的 Toast 实现巧妙地利用了 @State 响应式特性和 transition 过渡动画。

  // ===== Toast =====
  @Builder
  toastBox() {
    Text(this.toast)
      .fontSize(12)
      .fontColor('#FFFFFF')
      .padding({ left: 16, right: 16, top: 9, bottom: 9 })
      .backgroundColor('rgba(11,47,92,0.88)')
      .borderRadius(20)
      .shadow({ radius: 10, color: 'rgba(11,47,92,0.4)', offsetY: 3 })
      .position({ y: '72%' })
      .transition(TransitionEffect.OPACITY.animation({ duration: 250 }))
      .onAppear(() => {
        setTimeout(() => {
          this.toast = '';
        }, 2200);
      })
  }

Toast 的挂载条件是 this.toast !== ''——当 toast 字符串非空时渲染,为空时移除。Text 组件直接绑定 this.toast 作为内容,背景为半透明深蓝 rgba(11,47,92,0.88),圆角 20 形成胶囊形状。position({ y: '72%' }) 使用绝对定位将 Toast 固定在屏幕 72% 高度处,避免遮挡底部导航栏和弹框内容。

transition(TransitionEffect.OPACITY.animation({ duration: 250 })) 是 Toast 出现和消失时的过渡动画。TransitionEffect.OPACITY 定义了透明度变化效果,animation({ duration: 250 }) 设置动画持续 250 毫秒。当 Toast 从无到有渲染时,会从透明渐变为不透明(淡入);当 this.toast 被设为空字符串、Toast 被移除时,会从不透明渐变为透明(淡出)。

onAppear 是组件的生命周期回调,在组件首次渲染完成后触发。在 onAppear 中通过 setTimeout 设置 2200 毫秒的延迟,到期后将 this.toast 清空,触发 Toast 淡出消失。这种"自动清除"机制使得调用方只需设置 Toast 内容,无需关心清除时机——框架会在显示后自动回收。整个系统中有数十处 this.toast = '...' 的赋值,它们都共享这一套自动消失机制。

用户操作: 点击按钮

设置 toast = 消息文本

toast !== '' 条件成立

toastBox 渲染, 淡入动画 250ms

onAppear 触发

setTimeout 2200ms

设置 toast = ''

toast !== '' 条件不成立

toastBox 移除, 淡出动画 250ms

十、Tab 子组件:站点首页 HomeTab

7 个 Tab 子组件各自封装了一个完整的页面视图,通过 @Component 装饰器定义为独立组件。它们通过回调函数参数与主组件通信,实现了"渲染归自己、状态归父组件"的职责分离。

@Component
struct HomeTab {
  onOpenDuty: (id: number) => void = () => {
  }
  onOpenStation: (id: number) => void = () => {
  }
  onSign: () => void = () => {
  }
  onAurora: () => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  @Builder
  quickCell(item: QuickIcon) {
    Column() {
      Text(item.emoji)
        .fontSize(24)
      Text(item.name)
        .fontSize(10)
        .fontColor('#424242')
        .margin({ top: 6 })
    }
    .layoutWeight(1)
    .padding({ top: 14, bottom: 14 })
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(11,47,92,0.06)', offsetY: 2 })
    .onClick(() => {
      this.onToast(item.name + '(功能入口)');
    })
  }

HomeTab 组件的定义展示了 ArkTS 子组件接收回调的标准模式。5 个回调属性在声明时都赋予了默认的空函数 () => {},这是一种防御性编程——即使父组件没有传入某个回调,子组件内部调用时也不会报错。在主组件的 build() 方法中实例化 HomeTab 时,这些回调被实际的函数覆盖。

quickCell 是首页功能宫格的单元格 @Builder,接收一个 QuickIcon 参数,构建一个图标 + 文字的垂直布局卡片。layoutWeight(1) 使其在 Row 中等分宽度,配合外层 Row 中的 4 个 quickCell 调用形成四列宫格。点击时调用 this.onToast(item.name + '(功能入口)'),将功能名称通过回调传递给父组件显示 Toast。

  @Builder
  dutyCard(item: DutyItem) {
    Column() {
      Row() {
        Text(getDutyIcon(item.type))
          .fontSize(20)
        Text(item.type)
          .fontSize(10)
          .fontColor(getDutyColor(item.type))
          .margin({ left: 6 })
        Text('')
          .layoutWeight(1)
        Text(item.temp)
          .fontSize(10)
          .fontColor(getTempColor(item.temp))
      }
      .width('100%')
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0B2F5C')
        .width('100%')
        .margin({ top: 8 })
      Text(item.time)
        .fontSize(10)
        .fontColor('#757575')
        .width('100%')
        .margin({ top: 4 })
      Text('👥 ' + item.people)
        .fontSize(9)
        .fontColor('#9E9E9E')
        .width('100%')
        .margin({ top: 6 })
    }
    .width(158)
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .margin({ right: 10 })
    .shadow({ radius: 5, color: 'rgba(11,47,92,0.08)', offsetY: 2 })
    .onClick(() => {
      this.onOpenDuty(item.id);
    })
  }

dutyCard 是首页今日值勤横滑区域的卡片 @Builder。卡片宽度固定为 158 像素,这使得在横滑 Scroll 中每屏可以显示约 2.5 张卡片,暗示用户可以横向滑动查看更多。卡片顶部 Row 展示值勤类型图标和温度,图标通过 getDutyIcon(item.type) 动态获取,温度颜色通过 getTempColor(item.temp) 判断。中部依次显示值勤名称(加粗深蓝)、时间段(灰色)和参与人员(更小灰色)。点击卡片触发 this.onOpenDuty(item.id) 回调,通知父组件打开值勤详情弹框。

这里体现了 @Builder 的一个重要特性:它可以调用全局纯函数。getDutyIcongetDutyColorgetTempColor 都是文件顶层的纯函数,在 @Builder 内部直接调用,将数据转换逻辑与视图渲染逻辑无缝衔接。这种"纯函数做数据转换、@Builder 做 UI 描述"的分工,使得代码的可测试性和可维护性都得到了提升。

  build() {
    Column() {
      Scroll() {
        Column() {
          // 签到横幅卡
          Row() {
            Column() {
              Text('🧊 极地签到')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('连续签到 12 天 · 极夜能量 +120')
                .fontSize(10)
                .fontColor('#B3E5FC')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('立即签到 ➜')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0D47A1')
              .padding({ left: 12, right: 12, top: 8, bottom: 8 })
              .backgroundColor('#FFFFFF')
              .borderRadius(10)
              .onClick(() => {
                this.onSign();
              })
          }
          .width('100%')
          .padding(14)
          .linearGradient({
            angle: 135,
            colors: [['#1565C0', 0.0], ['#1E88E5', 1.0]]
          })
          .borderRadius(14)
          .shadow({ radius: 8, color: 'rgba(21,101,192,0.3)', offsetY: 3 })

          // 功能宫格
          Row() {
            ForEach(getQuickRows(4), (item: QuickIcon) => {
              this.quickCell(item)
            }, (item: QuickIcon) => item.name)
          }
          .width('100%')
          .margin({ top: 12 })

          Row() {
            ForEach(getQuickRows2(), (item: QuickIcon) => {
              this.quickCell(item)
            }, (item: QuickIcon) => item.name)
          }
          .width('100%')
          .margin({ top: 10 })

HomeTabbuild() 方法外层是一个 Column 包裹 Scroll 的结构。Scroll 使整个页面内容可以垂直滚动,内部的 Column 作为滚动内容容器。所有页面元素按从上到下的顺序排列:签到横幅卡、两行功能宫格、今日值勤标题、值勤横滑区域、极光推荐大卡、站点设施标题和设施列表。

签到横幅卡使用蓝色渐变背景和白色文字,右侧是一个白色实心按钮"立即签到 ➜"。Column 通过 alignItems(HorizontalAlign.Start) 使内部两行文字左对齐,layoutWeight(1) 占据左侧空间,将按钮推到右侧。功能宫格通过两个 Row 各渲染 4 个 quickCell,第一个 Row 使用 getQuickRows(4) 取前 4 个图标,第二个 Row 使用 getQuickRows2() 取后 4 个图标,形成两行四列的宫格布局。

          // 今日值勤横滑
          Row() {
            Text('❄️ 今日值勤')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0B2F5C')
            Text('')
              .layoutWeight(1)
            Text('查看全部 ➜')
              .fontSize(10)
              .fontColor('#1565C0')
              .onClick(() => {
                this.onToast('值勤排班 · 共 15 个班次');
              })
          }
          .width('100%')
          .margin({ top: 16 })

          Scroll() {
            Row() {
              ForEach(HOME_DUTY, (item: DutyItem) => {
                this.dutyCard(item)
              }, (item: DutyItem) => String(item.id))
            }
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 8 })

值勤横滑区域是一个嵌套的 Scroll,与外层的垂直 Scroll 不同,这个内层 Scroll 通过 scrollable(ScrollDirection.Horizontal) 设置为水平滚动方向。内部是一个 Row 包裹的 ForEach,遍历 HOME_DUTY 数组渲染值勤卡片。由于 dutyCardmargin({ right: 10 }) 提供了卡片间距,横滑时卡片之间保持 10 像素的间隔。scrollBar(BarState.Off) 隐藏了水平滚动条。

这种"外层垂直滚动 + 内层水平滚动"的嵌套结构在移动端信息流页面中极为常见。鸿蒙 ArkTS 的 Scroll 组件支持手势冲突处理——当用户在水平滚动区域内横向滑动时,事件被内层 Scroll 消费;当用户纵向滑动时,事件冒泡到外层 Scroll,实现页面整体滚动。这种手势协商机制由框架自动处理,开发者无需手动干预。

ScrollDirection.HorizontalScrollDirection.Vertical 分别指定水平和垂直滚动。默认情况下 Scroll 是垂直滚动的。在嵌套滚动场景中,框架通过手势方向判定来自动分发触摸事件,这是 ArkTS 布局引擎内置的能力。但如果嵌套层级过深或滚动区域过小,可能出现手势冲突,此时需要通过 nestedScroll 属性进行精细控制。

十一、Tab 子组件:员工页 StaffTab 双列布局

StaffTab 使用双列瀑布流布局展示 14 名站员的档案卡片。这种布局通过手动分列函数将数据分为两组,分别渲染到左右两列的 Column 中。

@Component
struct StaffTab {
  onOpenStaff: (id: number) => void = () => {
  }
  onEditStaff: (id: number) => void = () => {
  }
  onDelStaff: (id: number) => void = () => {
  }
  onAddStaff: () => void = () => {
  }

  @Builder
  staffCard(item: CrewItem) {
    Column() {
      Row() {
        Text(item.emoji)
          .fontSize(30)
          .width(46)
          .height(46)
          .textAlign(TextAlign.Center)
          .backgroundColor('#E3F2FD')
          .borderRadius(23)
        Text('')
          .layoutWeight(1)
        Text('Lv.' + String(item.level))
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor('#1565C0')
          .borderRadius(8)
      }
      .width('100%')

      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0B2F5C')
        .width('100%')
        .margin({ top: 8 })
      Text(item.role)
        .fontSize(9)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(getRoleColor(item.role))
        .borderRadius(8)
        .margin({ top: 5 })

staffCard 是站员卡片的 @Builder。卡片顶部 Row 左侧是站员头像——一个 46x46 像素的圆形背景(borderRadius(23) 即为半径,形成正圆),中间放置 emoji 表情符号。右侧是等级标签"Lv.XX",使用蓝色背景的圆角胶囊。卡片中部依次显示姓名(加粗深蓝)和角色标签——角色标签的背景色通过 getRoleColor(item.role) 动态获取,站长为金色、救援队长为红色、气象官为冰晶青,通过颜色直观区分职能。

      Row() {
        Text('抗寒')
          .fontSize(9)
          .fontColor('#9E9E9E')
        Text('')
          .width(6)
        Row() {
          Row() {
          }
          .width(String(item.cold) + '%')
          .height(5)
          .backgroundColor('#4DD0E1')
          .borderRadius(3)
        }
        .layoutWeight(1)
        .height(5)
        .backgroundColor('#E3F2FD')
        .borderRadius(3)
      }
      .width('100%')
      .margin({ top: 10 })

抗寒进度条是 staffCard 中的视觉亮点。进度条由两层 Row 嵌套实现:外层 Row 是灰色轨道(backgroundColor('#E3F2FD'),高度 5 像素),内层 Row 是冰晶青色填充条(backgroundColor('#4DD0E1')),宽度通过 String(item.cold) + '%' 动态设置为百分比字符串。例如抗寒指数 98 的站员,内层填充宽度为 '98%'。这种"容器 + 填充"的双层进度条模式在 ArkTS 中极为通用,可以用于任何百分比可视化场景。

      Row() {
        Text('✏️')
          .fontSize(14)
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 7, bottom: 7 })
          .backgroundColor('#E3F2FD')
          .borderRadius(8)
          .onClick(() => {
            this.onEditStaff(item.id);
          })
        Text('')
          .width(8)
        Text('🗑️')
          .fontSize(14)
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 7, bottom: 7 })
          .backgroundColor('#FFEBEE')
          .borderRadius(8)
          .onClick(() => {
            this.onDelStaff(item.id);
          })
        Text('')
          .width(8)
        Text('👀')
          .fontSize(14)
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 7, bottom: 7 })
          .backgroundColor('#E0F7FA')
          .borderRadius(8)
          .onClick(() => {
            this.onOpenStaff(item.id);
          })
      }
      .width('100%')
      .margin({ top: 10 })

卡片底部是三按钮操作区:编辑(✏️ 浅蓝背景)、删除(🗑️ 浅红背景)、查看(👀 浅青背景)。三个按钮通过 layoutWeight(1) 等分宽度,中间用 Text('').width(8) 创建间距。每个按钮通过不同的背景色暗示操作性质——编辑是温和的蓝色、删除是警示的红色、查看是中性的青色。点击分别触发 onEditStaffonDelStaffonOpenStaff 三个回调,将站员 ID 传递给父组件处理。

  build() {
    Column() {
      Scroll() {
        Column() {
          Row() {
            Column() {
              Text('🧊 站员名册')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#0B2F5C')
              Text('共 ' + String(CREW_LIST.length) + ' 人 · 全员在岗')
                .fontSize(10)
                .fontColor('#9E9E9E')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('+ 新增站员')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 14, right: 14, top: 9, bottom: 9 })
              .backgroundColor('#1565C0')
              .borderRadius(10)
              .onClick(() => {
                this.onAddStaff();
              })
          }
          .width('100%')

          Row() {
            Column() {
              ForEach(getCrewRows2(), (item: CrewItem) => {
                this.staffCard(item)
              }, (item: CrewItem) => String(item.id))
            }
            .layoutWeight(1)

            Column() {
              ForEach(getCrewRows3(), (item: CrewItem) => {
                this.staffCard(item)
              }, (item: CrewItem) => String(item.id))
            }
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
          .margin({ top: 12 })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 12, bottom: 20 })
      }
      .width('100%')
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
  }

StaffTabbuild() 方法展示了双列布局的实现。主体是一个 Row 包含两个 Column:左列 Column 通过 ForEach(getCrewRows2(), ...) 渲染偶数索引的站员卡片(id 1, 3, 5, 7, 9, 11, 13),右列 Column 通过 ForEach(getCrewRows3(), ...) 渲染奇数索引的站员卡片(id 2, 4, 6, 8, 10, 12, 14)。两个 Column 各自 layoutWeight(1) 等分宽度,右列通过 margin({ left: 10 }) 与左列保持 10 像素的间距。

由于左右两列各自独立的 Column 高度由内容撑开,且每张卡片的高度可能因描述文字长度不同而略有差异,两列的卡片不会水平对齐。这正是"瀑布流"效果——左列的第二张卡片紧跟第一张卡片底部排列,与右列的第二张卡片不在同一水平线上。这种布局在社交媒体和信息流应用中非常常见,能够在有限宽度内高效展示大量卡片。

十二、Tab 子组件:值勤页 DutyTab 时间轴

DutyTab 使用时间轴样式展示值勤排班,每条排班记录左侧是时间点和连接线,右侧是详细信息卡片,形成清晰的纵向时间线。

  @Builder
  dutyRow(item: DutyItem) {
    Row() {
      Text(item.time.split(' - ')[0])
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1565C0')
        .width(64)
      Column() {
        Text('')
          .width(3)
          .layoutWeight(1)
          .backgroundColor('#BBDEFB')
        Text('●')
          .fontSize(9)
          .fontColor(getDutyColor(item.type))
        Text('')
          .width(3)
          .layoutWeight(1)
          .backgroundColor('#BBDEFB')
      }
      .width(24)
      .alignItems(HorizontalAlign.Center)
      .height(88)

      Column() {
        Row() {
          Text(getDutyIcon(item.type))
            .fontSize(16)
          Text(item.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#212121')
            .margin({ left: 6 })
          Text('')
            .layoutWeight(1)
          Text(item.type)
            .fontSize(9)
            .fontColor('#FFFFFF')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(getDutyColor(item.type))
            .borderRadius(8)
        }
        .width('100%')
        Text(item.time)
          .fontSize(10)
          .fontColor('#757575')
          .width('100%')
          .margin({ top: 5 })
        Text('👥 ' + item.people + ' · 🌡️ ' + item.temp + ' · 🌬️ ' + String(item.wind) + 'm/s')
          .fontSize(9)
          .fontColor('#9E9E9E')
          .width('100%')
          .margin({ top: 5 })
      }
      .layoutWeight(1)
      .padding(10)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .shadow({ radius: 4, color: 'rgba(11,47,92,0.06)', offsetY: 2 })
      .onClick(() => {
        this.onOpenDuty(item.id);
      })
    }
    .width('100%')
  }

dutyRow 是时间轴单行的 @Builder,由三部分横向排列组成。第一部分是时间标签,宽度 64 像素,通过 item.time.split(' - ')[0] 从时间段字符串 '06:00 - 09:00' 中提取起始时间 '06:00'split(' - ')' - ' 为分隔符将字符串切分为数组,取第一个元素即为起始时间。这种从已有字段中提取展示信息的方式避免了数据冗余。

第二部分是时间轴线,一个宽度 24 像素的 Column,内部由三段组成:上方的连接线(layoutWeight(1) 的空 Text,背景色 #BBDEFB)、中间的时间点圆点('●' 字符,颜色为值勤类型对应的主题色)、下方的连接线。height(88) 固定了整个时间轴列的高度,使得多条排班记录垂直排列时,时间轴线能够无缝衔接——上一条的下连接线与下一条的上连接线视觉上形成连续的竖线。

第三部分是详细信息卡片,通过 layoutWeight(1) 占据剩余宽度。卡片内包含三行信息:顶部 Row 展示类型图标、值勤名称和类型标签胶囊;中部显示完整时间段;底部将人员、温度、风速三个信息合并为一行文本,用 emoji 图标分隔。点击卡片触发 this.onOpenDuty(item.id) 回调。

时间轴布局是 ArkTS 中"固定列 + 弹性列"组合的典型案例。第一列固定宽度 64 像素,第二列固定宽度 24 像素,第三列通过 layoutWeight(1) 弹性占据剩余空间。这种布局模式在 Row 中通过 width(px)layoutWeight 的混用实现,是掌握 ArkTS 布局的核心技能之一。

十三、Tab 子组件:装备页 GearTab 三列网格

GearTab 使用三列网格展示装备仓库,同时提供借还清单列表。三列网格通过三个步长为 3 的分列函数实现数据分配。

  @Builder
  gearCell(item: GearItem) {
    Column() {
      Text(item.emoji)
        .fontSize(26)
      Text(item.name)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
        .margin({ top: 6 })
      Text(item.cat)
        .fontSize(9)
        .fontColor('#9E9E9E')
        .margin({ top: 3 })
      Text('余 ' + String(item.stock))
        .fontSize(9)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(getGearStatusColor(item.status))
        .borderRadius(8)
        .margin({ top: 6 })
    }
    .width('100%')
    .padding({ top: 12, bottom: 12 })
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(11,47,92,0.06)', offsetY: 2 })
    .onClick(() => {
      this.onOpenGear(item.id);
    })
  }

gearCell 是装备网格单元格的 @Builder。与 staffCard 的水平卡片不同,gearCell 是纯垂直布局:图标、名称、分类、库存标签从上到下排列。库存标签的背景色通过 getGearStatusColor(item.status) 获取——"可借"为绿色、"借出"为橙色、其他状态为红色。width('100%') 使单元格宽度填满所在列,padding 提供内边距,shadow 添加轻微投影。

  build() {
    Column() {
      Scroll() {
        Column() {
          // 物资补给横幅
          Row() {
            Text('📦')
              .fontSize(26)
            Column() {
              Text('冬季物资补给 · 快速通道')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('后勤主管海雀已开启 30 分钟申领窗口')
                .fontSize(9)
                .fontColor('#FFE0B2')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
            Text('')
              .layoutWeight(1)
            Text('去申领 ➜')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 10, right: 10, top: 7, bottom: 7 })
              .backgroundColor('rgba(255,255,255,0.2)')
              .borderRadius(9)
              .onClick(() => {
                this.onSupply();
              })
          }
          .width('100%')
          .padding(12)
          .linearGradient({
            angle: 135,
            colors: [['#E65100', 0.0], ['#F57C00', 1.0]]
          })
          .borderRadius(13)
          .shadow({ radius: 7, color: 'rgba(230,81,0,0.3)', offsetY: 3 })

          Row() {
            Column() {
              ForEach(getGearRows2(), (item: GearItem) => {
                this.gearCell(item)
              }, (item: GearItem) => String(item.id))
            }
            .layoutWeight(1)

            Column() {
              ForEach(getGearRows3(), (item: GearItem) => {
                this.gearCell(item)
              }, (item: GearItem) => String(item.id))
            }
            .layoutWeight(1)
            .margin({ left: 8 })

            Column() {
              ForEach(getGearRows4(), (item: GearItem) => {
                this.gearCell(item)
              }, (item: GearItem) => String(item.id))
            }
            .layoutWeight(1)
            .margin({ left: 8 })
          }
          .width('100%')
          .margin({ top: 10 })

三列网格的实现与双列布局原理相同,但增加了一个 Column。三个 Column 分别使用 getGearRows2()(索引 0, 3, 6, 9)、getGearRows3()(索引 1, 4, 7, 10)、getGearRows4()(索引 2, 5, 8, 11)渲染装备卡片。三个 Column 各自 layoutWeight(1) 等分宽度,第二列和第三列通过 margin({ left: 8 }) 创建列间距。这种手动三列分列的方式虽然代码略显重复,但在 ArkTS 缺乏原生网格布局组件的情况下是最直接的解决方案。

          ForEach(getGearRows(12), (item: GearItem) => {
            this.gearRow(item)
          }, (item: GearItem) => String(item.id))

在网格区域下方,GearTab 还通过 ForEach(getGearRows(12), ...) 渲染了完整的 12 条装备借还清单。gearRow 是一个横向列表项 @Builder,与 gearCell 的垂直网格单元格不同,gearRow 使用 Row 布局将图标、名称规格、状态信息和归还按钮横向排列。这种"网格 + 列表"的双视图模式在同一页面中展示了装备的全景概览和详细清单,提供了不同粒度的信息浏览方式。

十四、Tab 子组件:榜单页 RankTab 柱状图

RankTab 使用纯 ArkTS 组件绘制柱状图,展示站员的抗寒指数排名。柱状图通过 Column 的嵌套和动态高度实现,无需引入图表库。

  @Builder
  barColumn(item: RankItem) {
    Column() {
      Text(String(item.cold))
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor(item.color)
      Column() {
      }
      .width(24)
      .height(getBarHeight(item.cold))
      .linearGradient({
        angle: 180,
        colors: [[item.color, 0.0], ['#FFFFFF', 1.0]]
      })
      .borderRadius({ topLeft: 6, topRight: 6 })
      .margin({ top: 4 })
      Text(item.emoji)
        .fontSize(12)
        .margin({ top: 5 })
    }
    .layoutWeight(1)
    .height(170)
    .justifyContent(FlexAlign.End)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.onOpenRank(item.id);
    })
  }

barColumn 是柱状图单列的 @Builder,由三部分垂直排列组成:顶部的数值标签、中间的柱体、底部的 emoji 图标。柱体是一个空的 Column,宽度 24 像素,高度通过 getBarHeight(item.cold) 动态计算(抗寒指数乘以 1.4 取整)。柱体使用 180 度(从上到下)的渐变填充:顶部是站员对应的主题色,底部过渡到白色,形成从深到浅的渐变柱效果。borderRadius({ topLeft: 6, topRight: 6 }) 仅设置顶部圆角,使柱体顶部呈圆弧形。

最关键的设计是外层 ColumnjustifyContent(FlexAlign.End)height(170)FlexAlign.End 使所有子元素向底部对齐——柱体高度较短的列,其数值标签会紧贴柱体顶部,而不是固定在外层 Column 的顶部。这使得所有柱体都从底部对齐向上生长,形成标准的柱状图效果。height(170) 固定了整个柱列的高度,为最高的柱体(98 * 1.4 = 137 像素)留出了足够空间,剩余空间由 FlexAlign.End 自动分配在顶部。

FlexAlign 枚举提供了主轴方向的对齐选项:Start(起始端对齐)、Center(居中对齐)、End(结束端对齐)、SpaceBetween(两端对齐无间距)、SpaceAround(等间距含两端)、SpaceEvenly(均匀间距)。在 Column 中主轴是垂直方向,FlexAlign.End 使子元素向底部对齐,这是实现柱状图"底部对齐生长"效果的关键属性。

十五、Tab 子组件:个人中心 MineTab

MineTab 是"我的"页面,集成了站员证卡片、个人数据统计、技能树和荣誉徽章墙四大模块,是信息密度最高的 Tab 页。

  @Builder
  skillRow(item: SkillItem) {
    Row() {
      Text(item.emoji)
        .fontSize(20)
      Column() {
        Row() {
          Text(item.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#212121')
          Text('')
            .layoutWeight(1)
          Text('Lv.' + String(item.lv) + '/' + String(item.max))
            .fontSize(10)
            .fontColor(item.color)
        }
        .width('100%')
        Row() {
          Row() {
          }
          .width(String(item.exp) + '%')
          .height(5)
          .backgroundColor(item.color)
          .borderRadius(3)
        }
        .width('100%')
        .height(5)
        .backgroundColor('#ECEFF1')
        .borderRadius(3)
        .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Text('升级')
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor(item.color)
        .borderRadius(9)
        .onClick(() => {
          this.onOpenSkill(item.id);
        })
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 11, bottom: 11 })
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ top: 8 })
    .shadow({ radius: 4, color: 'rgba(11,47,92,0.05)', offsetY: 2 })
  }

skillRow 是技能树单行的 @Builder。每行左侧是技能图标,中间是技能信息(包含名称、等级和经验进度条),右侧是升级按钮。进度条的实现与 staffCard 中的抗寒进度条完全一致——双层 Row 嵌套,外层灰色轨道、内层彩色填充,填充宽度通过 String(item.exp) + '%' 动态设置。不同之处在于进度条颜色使用 item.color 字段,每种技能有自己的主题色。

升级按钮的背景色也使用 item.color,与进度条颜色保持一致,形成视觉统一性。点击触发 this.onOpenSkill(item.id) 回调,打开技能升级弹框。这种"行级操作按钮"的设计使得每条技能记录都可以独立升级,无需进入详情页面。

  @Builder
  achCell(item: AchItem) {
    Column() {
      Text(item.emoji)
        .fontSize(26)
      Text(item.name)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
        .margin({ top: 6 })
      Text(item.year)
        .fontSize(8)
        .fontColor('#9E9E9E')
        .margin({ top: 3 })
    }
    .width('100%')
    .padding({ top: 12, bottom: 12 })
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(11,47,92,0.06)', offsetY: 2 })
    .onClick(() => {
      this.onOpenAch(item.id);
    })
  }

achCell 是荣誉徽章墙的单元格 @Builder。与装备页的 gearCell 结构类似,都是纯垂直布局:图标、名称、年份从上到下排列。点击触发 this.onOpenAch(item.id) 回调,打开冰雕工坊弹框展示成就详情。荣誉徽章墙通过三列布局(getAchRows2/3/4)展示 8 枚徽章,与装备页的三列网格实现方式一致。

十六、架构设计总结

视图层-子组件

视图层-主组件

逻辑层

数据层

回调

回调

回调

回调

回调

回调

回调

9个 interface 类型定义

10组 const 数据数组

切片函数 getCrewRows 等

映射函数 getRoleColor 等

格式函数 getStarEmoji 等

查找方法 findCrew 等

状态管理 @State x30+

头部横幅

标签路由 if/else if

底部导航 ForEach

弹框挂载 x16

Toast 提示

HomeTab 宫格+横滑

StaffTab 双列瀑布流

DutyTab 时间轴

ExpedTab 卡片+双列

GearTab 三列网格

RankTab 柱状图

MineTab 技能树+徽章墙

本系统的架构设计遵循了清晰的"数据层 — 逻辑层 — 视图层"三层分离原则。数据层由 9 个 interface 类型定义和 10 组 const 数据数组组成,是整个应用的静态数据源。逻辑层由 20 余个纯函数和 9 个查找方法组成,负责数据的切片、格式化和检索,不持有任何状态。视图层由 1 个主组件和 7 个 Tab 子组件组成,通过 @State 管理响应式状态,通过 @Builder 复用 UI 片段,通过回调函数实现组件间通信。

以下表格对系统中使用的核心 ArkTS 技术点进行了横向对比总结:

技术点 类别 作用 本系统使用场景 使用频率
Column 布局组件 垂直线性布局 页面骨架、卡片内部纵向排列、弹框容器 极高
Row 布局组件 水平线性布局 按钮组、列表项、标签栏、宫格行 极高
Scroll 滚动容器 内容溢出滚动 每个 Tab 页的垂直滚动、值勤横滑、物资弹框滚动
ForEach 渲染控制 列表数据渲染 站员列表、值勤列表、装备网格、徽章墙、底部导航 极高
@State 状态装饰器 组件内响应式状态 标签索引、16个弹框开关、9个选中数据、表单字段、Toast 极高
@Builder UI 复用 抽取可复用 UI 片段 16个弹框、列表项模板、导航项、Toast 极高
@Component 组件装饰器 声明自定义组件 主组件 Index + 7个 Tab 子组件
@Entry 入口装饰器 标记页面入口组件 主组件 Index
FlexAlign 对齐枚举 主轴对齐方式 柱状图底部对齐(FlexAlign.End)、救援弹框居中(Center)
layoutWeight 弹性布局 占据剩余空间 按钮等分、左右分栏、空组件占位推右 极高
linearGradient 渐变背景 渐变色填充 头部横幅、弹框头部、卡片背景、柱状图柱体
shadow 阴影效果 投影深度感 几乎所有卡片和弹框 极高
borderRadius 圆角属性 圆角裁剪 卡片圆角、胶囊按钮、圆形头像、抽屉顶部圆角 极高
transition 过渡动画 出入场动画 Toast 淡入淡出
constraintSize 约束尺寸 最大/最小尺寸限制 底部抽屉弹框最大高度 80%、物资滚动区 260px
position 绝对定位 固定位置定位 Toast 固定在 72% 高度处

以下表格对 16 个弹框的设计风格进行了对比总结:

弹框名称 布局模式 宽度 背景风格 交互特征
极地签到 底部抽屉 100% 蓝色渐变头 + 白色体 7天日历 + 奖励列表
员工档案 居中卡片 82% 冰晶渐变头 + 白色体 三列数据 + 双按钮
值勤申请 底部抽屉 100% 类型色渐变头 + 白色体 日期chips + 班次chips
探险详情 居中卡片 82% 纯白 + 星级 三列数据 + 报名按钮
装备借用 底部抽屉 100% 纯白 + 状态标签 数量步进 + 天数chips
装备归还 居中卡片 80% 深色渐变警示 双按钮确认
榜单奖励 居中卡片 80% 金色渐变数据区 领取按钮
站员证 居中卡片 84% 蓝色渐变头 + 票根体 票根虚线 + 数据三列
极光报告 居中卡片 84% 极光紫渐变头 + 白色体 数据三列 + 约伴按钮
救援响应 居中卡片 82% 纯白 + 状态流标签 三状态标签 + 加入按钮
物资申领 底部抽屉 100% 橙色渐变头 + 滚动列表 Scroll列表 + 申领按钮
极夜祝福 居中卡片 80% 深蓝星空渐变 点亮按钮
技能升级 居中卡片 80% 纯白 + 费用数据 升级按钮
删除确认 居中卡片 80% 深红渐变警示 双按钮确认
队长竞选 居中卡片 82% 纯白 + 三人候选卡 投票按钮
冰雕工坊 居中卡片 82% 冰晶青渐变 参观 + 点赞双按钮

十七、总结

本系统是一个功能完整、架构清晰的鸿蒙 ArkTS 声明式 UI 应用范例。它以极地科考站人事管理为业务主题,通过 7 个差异化标签页和 16 个风格各异的弹框,全面展示了 ArkTS 在复杂移动端应用场景下的开发能力。

从架构层面看,系统严格遵循了三层分离原则。数据层通过 interface 定义了 9 个类型化的业务实体接口和 1 个图标接口,通过 const 声明了 10 组静态数据集合,为整个应用提供了类型安全、内容丰富的数据源。逻辑层通过 20 余个纯函数实现了数据切片、格式化和样式映射,这些函数无副作用、可独立测试,是整个系统中最具复用价值的代码资产。视图层通过 1 个主组件和 7 个 Tab 子组件构建了完整的页面体系,利用 @State 管理数十个响应式变量,利用 @Builder 抽取了 16 个弹框和大量列表项模板,实现了高效的代码复用。

从布局技术看,系统充分运用了 ColumnRow 的嵌套组合构建了从简单到复杂的各种布局结构。layoutWeight 弹性权重在按钮等分、左右分栏、空组件占位等场景中被高频使用,是掌握 ArkTS 布局的核心机制。FlexAlign 枚举在柱状图底部对齐、状态标签居中等场景中发挥了关键作用。Scroll 组件在垂直滚动页面、水平滚动卡片列表和弹框内滚动列表三种场景中都有应用。ForEach 作为列表渲染的核心控制组件,通过键值生成器保证了列表的高效更新。

从状态管理看,系统采用了"主组件集中管理状态 + 子组件通过回调通信"的模式。30 余个 @State 变量覆盖了标签索引、弹框开关、选中数据、表单字段和 Toast 提示等全部状态需求。子组件不持有任何状态,所有状态变更通过回调函数委托给主组件执行。这种模式使得状态流转路径清晰可追踪——所有状态变更都汇聚在主组件的回调函数体中,便于调试和维护。

从弹框设计看,16 个弹框展示了"底部抽屉"和"居中卡片"两大布局范式,以及"白色卡片"“深色警示卡”"渐变品牌卡"等多种视觉风格。modalOverlay 通过 @Builder 的尾随闭包语法实现了"遮罩层 + 内容"的复合结构,所有弹框共享统一的关闭机制(点击遮罩层触发 onClose 回调)。constraintSize 限制了底部抽屉的最大高度,防止内容溢出全屏。transition 为 Toast 提供了淡入淡出过渡效果。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 主题:冰川深蓝 #0B2F5C→#1565C0 / 冰晶青 #4DD0E1 / 极光紫 #7C4DFF / 雪白 #F0F8FF
// 7 Tab 两排底栏 · 16 弹框 · 10 组数据列表 · 差异化布局

interface CrewItem {
  id: number;
  name: string;
  role: string;
  emoji: string;
  level: number;
  cold: number;
  days: number;
  skill: string;
  phone: string;
  home: string;
  desc: string;
}

interface DutyItem {
  id: number;
  name: string;
  time: string;
  type: string;
  temp: string;
  wind: number;
  people: string;
  color: string;
}

interface ExpedItem {
  id: number;
  name: string;
  target: string;
  dist: number;
  days: number;
  level: number;
  leader: string;
  team: string;
  reward: string;
  desc: string;
}

interface GearItem {
  id: number;
  name: string;
  emoji: string;
  cat: string;
  stock: number;
  status: string;
  keeper: string;
  spec: string;
}

interface SupplyItem {
  id: number;

        .fontWeight(FontWeight.Bold)
        .fontColor('#0B2F5C')
        .width('100%')
        .margin({ top: 8 })
      Text(this.selExped!.target + ' · 难度 ' + getStarEmoji(this.selExped!.level))
        .fontSize(11)
        .fontColor('#1565C0')
        .width(
          .fontColor('#1565C0')
          .width(36)

  }
}


在这里插入图片描述

从交互设计看,系统大量使用了"关闭弹框 + 设置 Toast"的组合模式作为操作反馈。用户在弹框中点击提交按钮后,先关闭弹框再显示 Toast 提示,两个状态变更在同一回调中完成,框架在下一帧统一渲染。Toast 通过 onAppear 生命周期回调自动设置 2200 毫秒的延时清除,调用方只需设置内容无需关心清除时机。

整体而言,本系统的代码结构体现了 ArkTS 声明式 UI 范式的核心优势:通过类型系统保证数据安全,通过装饰器简化状态管理,通过 @Builder 实现 UI 复用,通过回调函数解耦组件通信。这些设计模式和最佳实践对于鸿蒙应用开发具有直接的参考价值,可以作为构建中大型 ArkTS 应用的架构模板。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐