引言:当医疗健康遇上ArkUI声明式UI

在这里插入图片描述

在移动互联网深度渗透各行各业的今天,互联网医疗已经成为民生服务的重要基础设施。从预约挂号到在线问诊,从检验报告查看到用药提醒,一款优秀的医疗健康应用需要在有限的屏幕空间内承载极其丰富的功能模块,同时还要兼顾操作的直观性、信息的可读性以及交互的流畅性。这对UI布局架构提出了极高的要求。

在这里插入图片描述
HarmonyOS的ArkUI声明式开发范式为这类复杂场景提供了强大的技术支撑。通过@Component装饰器定义组件,@State管理响应式状态,@Builder抽取可复用的UI构建逻辑,@Observed实现数据模型的深度观测,ArkUI构建了一套完整的声明式UI开发体系。在这个体系中,开发者可以用极其简洁的语法描述出复杂的界面结构,并通过状态驱动的机制实现UI的自动更新,极大降低了复杂交互场景下的开发心智负担。

在这里插入图片描述
本文将深入剖析一个名为"康诺医疗"的互联网医疗应用界面,它采用了深湖蓝暗色主题搭配薄荷绿#4DE1C1的视觉风格,底部设计了7个功能Tab且每个Tab的布局风格完全不同,更集成了Vision Kit的卡证识别能力实现拍卡建档。这种"七态异构"的布局策略在同类型应用中极具代表性——每个功能页面都有其独特的信息展示诉求,强行统一布局模板反而会牺牲用户体验。通过逐段拆解代码,我们将看到ArkUI如何在一个组件内优雅地组织这些差异巨大的界面形态。

在这里插入图片描述

整体架构总览

在深入代码细节之前,我们先通过一张架构图来建立全局认知。整个应用界面从上到下分为三个核心区域:头部品牌区(含健康数据条和可收展宫格)、中部滚动内容区(7个Tab各异的布局)、底部导航栏(两行4+3排列)。此外还有弹窗层和Vision Kit全屏识别层叠加在主体之上。

根容器 Stack

Vision Kit 识别层

scanning=true

addModal/editModal/delModal

弹窗层

panelAdd 新增

panelEdit 管理

panelDel 删除

CardRecognition 全屏独占

主界面层

滚动内容区

tabHome 首页

tabReg 挂号

tabCard 就诊卡

tabReport 报告

tabMed 用药

tabHosp 医院

tabPatient 就诊人

chartCard 月度图表

headerMed 头部品牌区

分割线

tabBar 底部导航

从架构图可以清晰地看到,整个界面通过一个Stack容器实现了多层次的叠加渲染。当scanning状态为true时,Vision Kit的卡证识别控件会全屏独占显示,完全覆盖主界面层;而三个弹窗则根据各自的状态变量条件渲染,叠加在主界面之上。这种通过状态变量控制层级切换的设计模式,是ArkUI中实现复杂交互的典型手法。

在这里插入图片描述

一、颜色系统:深湖蓝暗色主题的工程化设计

在这里插入图片描述
在任何优秀的UI实现中,颜色系统都是最基础也是最重要的工程化设计之一。这个应用采用了一套完整的暗色系配色方案,以深湖蓝#0A1220为背景基调,薄荷绿#4DE1C1作为主强调色,晴空蓝#6FA8FF作为次强调色,构建出既专业又温和的医疗视觉氛围。

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  dark: string;
  title: string;
  sub: string;
  text3: string;
  main: string;
  mainD: string;
  sec: string;
  secD: string;
  red: string;
  green: string;
  purple: string;
  line: string;
  tabOn: string;
  mask: string;
}

首先定义了一个ColorPalette接口,它将所有颜色按用途进行了语义化分类。这种做法的好处是显而易见的:bg代表页面背景色,card代表卡片背景色,title代表主标题色,sub代表副文本色——每个字段名直接传达了颜色的使用场景,而非用blue1blue2这样无意义的序号命名。当团队协作时,语义化命名可以大幅降低沟通成本。

在这里插入图片描述

颜色常量的具体定义

const COLORS: ColorPalette = {
  bg: '#0A1220',
  card: '#121C2E',
  chip: '#1A2740',
  dark: '#0D1726',
  title: '#E7EFFA',
  sub: '#A9BBD6',
  text3: '#67789A',
  main: '#4DE1C1',
  mainD: '#2CA98D',
  sec: '#6FA8FF',
  secD: '#3F73C2',
  red: '#FF6B6B',
  green: '#3FD98C',
  purple: '#B388FF',
  line: '#1E2C48',
  tabOn: '#4DE1C1',
  mask: 'rgba(2,5,10,0.66)',
};

这里值得深入分析的是颜色的层次设计。背景色bg使用#0A1220这种极深的湖蓝色,比纯黑更柔和,长时间注视不易产生视觉疲劳,这在医疗类应用中尤为重要——用户可能需要频繁查看检验报告和用药信息。卡片色card比背景色略亮,chip(标签色)又比卡片色略亮,形成了bg < dark < card < chip的三级深浅层次,这种细微的明度差异在暗色主题中构建出了清晰的视觉层级。

主强调色main采用#4DE1C1薄荷绿,这是一个在深色背景上具有极高辨识度的颜色,同时传达出"健康""活力"的语义感受。mainD是其深色变体,用于渐变和柱状图等需要色彩层次的场景。次强调色sec使用#6FA8FF晴空蓝,与薄荷绿形成冷暖对比,用于区分不同类型的信息——比如就诊高峰月和常规月在图表中用不同颜色区分。

mask使用rgba(2,5,10,0.66)半透明遮罩色,用于弹窗背景的蒙层效果。66%的不透明度既能突出弹窗内容,又不会完全遮盖底层界面,保持了一定的上下文可见性。

二、常量定义:Tab元数据与功能入口

定义完颜色系统后,接下来是一组常量定义,它们描述了底部导航栏的结构和首页快捷功能入口。

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_ROW1: TabMeta[] = [
  { icon: '🏠', label: '首页' },
  { icon: '🏥', label: '挂号' },
  { icon: '🪪', label: '就诊卡' },
  { icon: '🧪', label: '报告' },
];

TabMeta接口定义了Tab项的两个核心属性:icon图标和label文字标签。这里使用Emoji作为图标,在原型设计和演示场景中是一种快速有效的方案。Tab被分为两行:第一行4个(首页、挂号、就诊卡、报告),第二行3个(用药、医院、就诊人),这种4+3的布局在底部导航中并不常见,但在功能模块较多的医疗应用中是一种合理的空间分配策略。

const TAB_ROW2: TabMeta[] = [
  { icon: '💊', label: '用药' },
  { icon: '🏬', label: '医院' },
  { icon: '👪', label: '就诊人' },
];

interface EntryMeta {
  icon: string;
  label: string;
}

const HOME_ENTRY: EntryMeta[] = [
  { icon: '🏥', label: '预约挂号' },
  { icon: '🩺', label: '在线问诊' },
  { icon: '🧪', label: '检验报告' },
  { icon: '💊', label: '用药提醒' },
  { icon: '💉', label: '疫苗预约' },
  { icon: '🦷', label: '口腔护理' },
  { icon: '👁️', label: '视力检查' },
  { icon: '🎧', label: '更多服务' },
];

EntryMetaTabMeta结构相同但语义不同——它描述的是首页头部宫格的快捷入口。8个入口覆盖了医疗场景的核心功能:预约挂号、在线问诊、检验报告、用药提醒、疫苗预约、口腔护理、视力检查和更多服务。这8个入口会通过gridExpand状态变量实现"收起留首行4项/展开8项"的动画效果,这是后续分析的重点之一。

三、Vision Kit卡证类型映射

这个应用的一大技术亮点是集成了HarmonyOS的Vision Kit卡证识别能力。下面这段代码定义了证件类型到CardType的映射。

import { CardRecognition, CardRecognitionResult, CardType, ShootingMode } from '@kit.VisionKit';

// Vision Kit:建档实名的证件 → CardType 映射(索引与 DOC_LIST 一一对应)
const SCAN_TYPES: CardType[] = [
  CardType.CARD_ID,
  CardType.CARD_PASSPORT,
  CardType.CARD_MAINLAND_TRAVEL_PERMIT_HK_MO,
  CardType.CARD_MAINLAND_TRAVEL_PERMIT_TW,
];

@kit.VisionKit导入了卡证识别所需的核心类型:CardRecognition是识别控件组件,CardRecognitionResult是识别结果回调类型,CardType是证件类型枚举,ShootingMode是拍摄模式枚举。

SCAN_TYPES数组将四种证件类型按顺序排列:居民身份证、护照、港澳居民来往内地通行证(回乡证)、台湾居民来往大陆通行证(台胞证)。注释中明确说明了"索引与DOC_LIST一一对应",这意味着SCAN_TYPES[0]对应身份证、SCAN_TYPES[1]对应护照,以此类推。这种通过数组索引建立映射的方式简洁且高效,当用户点击第i个证件类型时,直接使用SCAN_TYPES[i]即可获取对应的CardType

特别值得注意的是,后两种通行证类型是HarmonyOS 6.1.1版本新增的支持,这体现了应用紧跟系统版本更新,为港澳台居民提供了更便捷的建档通道。

四、数据模型基础与统计常量

在分析具体的数据模型之前,先看几组辅助性的统计常量。

// 月度就诊(次,6 个月)
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const VISIT_VAL: number[] = [1, 2, 1, 3, 2, 2];

const DEPT_KIND: string[] = ['儿科', '口腔', '眼科'];

这三组常量分别服务于不同的功能模块:MONTH_NAMEVISIT_VAL用于首页底部的月度就诊柱状图,6个月的数据展示了就诊频率的变化趋势,其中6月的3次为就诊高峰(在图表中会用次强调色高亮显示);DEPT_KIND用于挂号页底部的号源紧张提示,标注了三个号源紧张的科室。

五、辅助函数:状态驱动的颜色映射

function reportColor(s: string): string {
  if (s === '已出报告') return COLORS.green;
  if (s === '检测中') return COLORS.main;
  return COLORS.text3;
}

function medColor(s: string): string {
  if (s === '已服用') return COLORS.green;
  if (s === '待服用') return COLORS.main;
  return COLORS.text3;
}

这两个辅助函数实现了"状态文本到颜色"的映射逻辑。reportColor根据检验报告的状态返回不同颜色:已出报告用绿色表示完成,检测中用薄荷主色表示进行中,其他状态用灰色表示。medColor的逻辑完全对称:已服用用绿色,待服用用薄荷主色,其他用灰色。

这种设计模式的精髓在于将颜色决策逻辑集中到函数中,而非散落在各处UI代码里。如果未来需要调整配色方案——比如将"检测中"的颜色改为黄色——只需修改一处函数即可,所有调用点的颜色都会自动更新。这是DRY(Don’t Repeat Yourself)原则在UI开发中的典型应用。

六、数据模型:@Observed可观测类

ArkUI的@Observed装饰器可以将一个普通类标记为可观测的,当该类的实例属性发生变化时,引用了该实例的UI组件会自动重新渲染。这个应用定义了8个@Observed数据模型类。

BannerItem:轮播横幅数据

@Observed export class BannerItem {
  tag: string;
  title: string;
  sub: string;

  constructor(tag: string, title: string, sub: string) {
    this.tag = tag;
    this.title = title;
    this.sub = sub;
  }
}

const BANNER_LIST: BannerItem[] = [
  new BannerItem('义诊周', '三甲名医在线义诊', '本周免挂号费'),
  new BannerItem('体检季', '入职体检 5 折', '报告 24h 出'),
  new BannerItem('港澳台', '通行证建档通道', '回乡证/台胞证拍卡即建'),
];

BannerItem包含三个字段:tag是标签(如"义诊周"),title是主标题,sub是副标题。三条Banner数据分别推广义诊活动、体检优惠和通行证建档通道,其中第三条直接引导用户使用Vision Kit的拍卡建档功能,形成了功能闭环。

DocItem:证件类型数据

@Observed export class DocItem {
  icon: string;
  name: string;
  desc: string;
  isNew: boolean;

  constructor(icon: string, name: string, desc: string, isNew: boolean) {
    this.icon = icon;
    this.name = name;
    this.desc = desc;
    this.isNew = isNew;
  }
}

const DOC_LIST: DocItem[] = [
  new DocItem('👤', '居民身份证', '大陆二代证 · 双面识别', false),
  new DocItem('📖', '护照', '中国护照 · 单面识别', false),
  new DocItem('🪪', '港澳居民来往内地通行证', '回乡证建档 · 拍卡即录', true),
  new DocItem('🎫', '台湾居民来往大陆通行证', '台胞证建档 · 拍卡即录', true),
];

DocItemBannerItem多了一个isNew布尔字段,用于标记港澳通行证和台湾通行证为"NEW"——因为这两种证件类型是新增支持的。DOC_LIST的数组索引与前面定义的SCAN_TYPES严格一一对应,这是整个卡证识别流程能够正常工作的核心约束。

DeptItem:科室挂号数据

@Observed export class DeptItem {
  icon: string;
  name: string;
  wait: string;

  constructor(icon: string, name: string, wait: string) {
    this.icon = icon;
    this.name = name;
    this.wait = wait;
  }
}

const DEPT_LIST: DeptItem[] = [
  new DeptItem('🫀', '心血管内科', '候诊 18 人'),
  new DocItem('🧠', '神经内科', '候诊 9 人'),
  new DeptItem('🦴', '骨科', '候诊 12 人'),
  new DeptItem('👶', '儿科', '候诊 26 人'),
  new DeptItem('🦷', '口腔科', '候诊 6 人'),
  new DeptItem('👁️', '眼科', '候诊 15 人'),
  new DeptItem('🩺', '全科', '候诊 4 人'),
  new DeptItem('👂', '耳鼻喉科', '候诊 8 人'),
];

DeptItemwait字段直接展示了候诊人数,这是挂号决策中最重要的信息之一。8个科室涵盖了内科、外科、专科等主要就诊方向,候诊人数从4人到26人不等,为用户提供了直观的排队参考。

ReportItem:检验报告数据

@Observed export class ReportItem {
  name: string;
  hosp: string;
  time: string;
  status: string;

  constructor(name: string, hosp: string, time: string, status: string) {
    this.name = name;
    this.hosp = hosp;
    this.time = time;
    this.status = status;
  }
}

const REPORT_LIST: ReportItem[] = [
  new ReportItem('血常规五分类', '康诺附属医院', '08-24', '已出报告'),
  new ReportItem('肝功能十二项', '康诺附属医院', '08-24', '已出报告'),
  new ReportItem('胸部 CT 平扫', '市第一人民医院', '08-18', '已出报告'),
  new ReportItem('过敏原筛查', '康诺附属医院', '08-26', '检测中'),
  new ReportItem('维生素 D 检测', '康诺附属医院', '08-26', '检测中'),
];

ReportItem包含检验项目名称、医院、日期和状态四个字段。5条数据中有3条"已出报告"和2条"检测中",status字段的值会通过前面分析的reportColor函数映射为对应的颜色,实现视觉上的状态区分。

MedItem:用药提醒数据

@Observed export class MedItem {
  time: string;
  title: string;
  status: string;
  note: string;

  constructor(time: string, title: string, status: string, note: string) {
    this.time = time;
    this.title = title;
    this.status = status;
    this.note = note;
  }
}

const MED_LIST: MedItem[] = [
  new MedItem('08:00', '维生素 D3 滴剂', '已服用', '早餐后 · 1 粒'),
  new MedItem('12:30', '益生菌冲剂', '已服用', '午餐后 · 1 袋'),
  new MedItem('18:00', '钙片', '待服用', '晚餐后 · 1 片'),
  new MedItem('21:00', '褪黑素软糖', '待服用', '睡前 30 分钟'),
  new MedItem('明日 08:00', '维生素 D3 滴剂', '明日', '循环提醒中'),
];

MedItem的数据设计很精巧:note字段包含了服用方式的详细信息(如"早餐后·1粒"),time字段支持"明日"这样的相对时间表达。5条用药提醒覆盖了一天中4个时间点加1条次日循环提醒,体现了用药管理的周期性特征。

HospItem与PatientItem:医院与就诊人数据

@Observed export class HospItem {
  name: string;
  level: string;
  dist: string;
  wait: string;

  constructor(name: string, level: string, dist: string, wait: string) {
    this.name = name;
    this.level = level;
    this.dist = dist;
    this.wait = wait;
  }
}

const HOSP_LIST: HospItem[] = [
  new HospItem('康诺附属医院', '三级甲等 · 互联网医院', '2.4 km', '在线号源充足'),
  new HospItem('市第一人民医院', '三级甲等', '4.8 km', '明日可约'),
  new HospItem('湾 区口腔医院', '三级专科', '6.1 km', '周末可约'),
  new HospItem('儿童医学中心', '三级甲等专科', '8.9 km', '本周可约'),
];

HospItemlevel字段包含了医院等级信息(如"三级甲等"),dist字段是距离信息,wait字段描述了号源状态。4家医院按距离排序,从2.4km到8.9km,方便用户就近选择。

@Observed export class PatientItem {
  emoji: string;
  name: string;
  rel: string;
  cardNo: string;

  constructor(emoji: string, name: string, rel: string, cardNo: string) {
    this.emoji = emoji;
    this.name = name;
    this.rel = rel;
    this.cardNo = cardNo;
  }
}

const PATIENT_LIST: PatientItem[] = [
  new PatientItem('🙋', '陈嘉辉', '本人', 'KNO-8841'),
  new PatientItem('👩', '林淑芬', '配偶', 'KNO-7724'),
  new PatientItem('👧', '小杏怡', '女儿', 'KNO-6618'),
  new PatientItem('👴', '陈守义', '父亲', 'KNO-5502'),
  new PatientItem('👵', '周秀兰', '母亲', 'KNO-4436'),
  new PatientItem('🧓', '林大有', '岳父', 'KNO-3311'),
  new PatientItem('👵', '吴月娥', '岳母', 'KNO-2245'),
  new PatientItem('➕', '新增就诊人', '', ''),
];

PatientItem列表包含了7位家庭成员加1个"新增就诊人"入口,这是典型的家庭健康档案管理模式。最后一条数据的relcardNo为空字符串,在UI中会特殊处理为"添加家人"和"建档后可用"的提示文案。

ScanRecord:识别记录数据

@Observed export class ScanRecord {
  time: string;
  cardName: string;
  raw: string;

  constructor(time: string, cardName: string, raw: string) {
    this.time = time;
    this.cardName = cardName;
    this.raw = raw;
  }
}

ScanRecord是唯一不在初始化时就填充数据的模型——它记录的是用户使用Vision Kit拍卡建档后产生的识别结果,raw字段存储了证件信息的JSON字符串。这些记录会在运行时动态添加到scanRecords状态数组中。

七、组件主体:@State状态管理

现在进入组件主体的分析。Page1009是整个应用的根组件,通过@Entry@Component装饰器标记为入口组件。

@Entry
@Component
struct Page1009 {
  // --- Tab 状态 ---
  @State currentTab: number = 0;

  // --- 头部宫格收起/展开 ---
  @State gridExpand: boolean = true;

  // --- 弹窗状态 ---
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delIdx: number = -1;

  // --- 弹窗表单 ---
  @State addTitle: string = '';
  @State addNote: string = '';

@State装饰器是ArkUI状态管理的核心。每个被@State修饰的变量都是响应式的——当其值发生变化时,引用了该变量的UI部分会自动重新渲染。这里定义了多个状态变量,按功能可以分为四组:

Tab状态组currentTab记录当前激活的Tab索引(0-6),控制中部内容区的布局切换。

宫格状态组gridExpand控制头部快捷入口宫格是展开(8项)还是收起(4项),初始为展开状态。

弹窗状态组addModaleditModaldelModal三个布尔变量分别控制三个弹窗的显示隐藏;editIdxdelIdx记录当前操作的是哪个就诊人索引。

表单状态组addTitleaddNote是新增就诊人弹窗中输入框的值,通过onChange回调实时更新。

动画与卡证识别状态

  // --- 动画状态 ---
  @State breath: boolean = false;
  timer: number = -1;

  // --- 卡证识别状态 ---
  @State scanning: boolean = false;
  @State scanIdx: number = -1;
  @State scanRecords: ScanRecord[] = [];

  // --- 可变数据 ---
  @State patientList: PatientItem[] = PATIENT_LIST;

breath是一个特殊的动画状态变量,它通过定时器每秒翻转一次布尔值,驱动多个UI元素产生"呼吸"动画效果——如头部数字的透明度变化、就诊卡图标的明暗交替、柱状图高度的细微波动等。这种用单一布尔变量驱动全局动画的手法非常巧妙。

scanning控制是否显示Vision Kit全屏识别界面,scanIdx记录当前识别的证件类型索引,scanRecords数组存储所有识别结果。patientList直接引用了前面定义的PATIENT_LIST常量,但由于它被@State修饰,后续的增删操作(新增就诊人、删除就诊人)会触发UI自动更新。

八、生命周期:呼吸动画的启停管理

  aboutToAppear() {
    this.timer = setInterval(() => {
      this.breath = !this.breath;
    }, 1000);
  }

  aboutToDisappear() {
    clearInterval(this.timer);
  }

ArkUI组件提供了aboutToAppearaboutToDisappear两个生命周期回调。aboutToAppear在组件创建后、build执行前调用,aboutToDisappear在组件销毁前调用。

这里在aboutToAppear中启动了一个每秒执行一次的定时器,不断翻转breath的值。由于breath@State变量,每次翻转都会触发依赖它的UI元素重新渲染,从而产生周期性的"呼吸"动画效果。在aboutToDisappear中清除定时器,防止组件销毁后定时器继续执行导致的内存泄漏。这种"成对管理"的生命周期模式是ArkUI开发的标准实践。

九、build方法:Stack多层叠加架构

build方法是ArkUI组件的核心,它描述了组件的UI结构。这个应用的build方法通过Stack容器实现了多层叠加的渲染架构。

  build() {
    Stack() {
      if (this.scanning && this.scanIdx >= 0) {
        this.scanView()
      } else {
        Column() {
          this.headerMed()
          Divider().strokeWidth(1).color(COLORS.line)
          Scroll() {
            Column() {
              if (this.currentTab === 0) {
                this.tabHome()
              } else if (this.currentTab === 1) {
                this.tabReg()
              } else if (this.currentTab === 2) {
                this.tabCard()

Stack容器的第一个子元素根据scanning状态进行条件渲染:如果正在进行卡证识别,则显示scanView()(Vision Kit全屏识别控件);否则显示主界面。这是一个典型的"互斥渲染"模式——识别期间整个界面被识别控件独占,不允许任何其他元素遮挡。

主界面通过Column从上到下排列三个部分:headerMed()头部品牌区、分割线Divider、滚动内容区Scroll。在Scroll内部,通过if-else链根据currentTab的值渲染对应的Tab内容。这种用条件分支控制内容区布局的方式,使得7个完全不同风格的布局可以在同一个位置上切换。

build方法的弹窗层

        if (this.addModal) {
          this.panelAdd(() => {
            this.addModal = false;
          })
        }
        if (this.editModal) {
          this.panelEdit(() => {
            this.editModal = false;
          })
        }
        if (this.delModal) {
          this.panelDel(() => {
            this.delModal = false;
          })
        }
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
    .alignContent(Alignment.Center)
  }

三个弹窗在主界面Column之后通过条件渲染叠加在Stack上。每个弹窗都接收一个onClose回调函数,用于在关闭弹窗时将对应的状态变量置为false。这种"回调注入"的模式使得弹窗组件可以独立于外部状态进行封装,同时又能通过回调与外部状态通信。

最外层的Stack设置了全屏宽高和背景色,alignContent(Alignment.Center)使得弹窗在叠加时可以居中显示(如删除确认弹窗),而底部弹窗则通过自身的alignContent(Alignment.Bottom)控制位置。

0

1

2

3

4

5

6

addModal

editModal

delModal

build 入口

scanning为true?

scanView 全屏识别

渲染主界面层

headerMed 头部

Divider 分割线

Scroll 内容区

currentTab值

tabHome

tabReg

tabCard

tabReport

tabMed

tabHosp

tabPatient

chartCard 图表

tabBar 底部导航

弹窗状态

panelAdd

panelEdit

panelDel

十、headerMed头部:品牌区与健康数据条

头部是用户进入应用后看到的第一块区域,它需要同时承载品牌标识、健康数据概览和快捷功能入口三重职责。

  @Builder
  headerMed() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 2 }) {
          Text('康诺医疗').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text('互联网医院 · care online').fontSize(10).fontColor(COLORS.text3)
        }
        .alignItems(HorizontalAlign.Start)

        Column().layoutWeight(1)

        Row({ space: 6 }) {
          Circle().width(8).height(8).fill(COLORS.main)
          Text('候诊中').fontSize(12).fontColor(COLORS.main)
        }
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .backgroundColor(COLORS.chip)
        .borderRadius(12)
      }
      .width('100%')

头部第一行是品牌区:左侧是"康诺医疗"品牌名称和"互联网医院·care online"副标题,右侧是一个"候诊中"状态胶囊。这个状态胶囊内有一个薄荷绿的Circle小圆点和"候诊中"文字,背景为chip色,圆角12。Column().layoutWeight(1)作为弹性占位符,将品牌名称推到左侧、状态胶囊推到右侧,实现了两端对齐的布局效果。

健康数据条

      Row({ space: 14 }) {
        Column({ space: 2 }) {
          Text('7 人').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
            .opacity(this.breath ? 1 : 0.72)
          Text('家庭建档成员').fontSize(10).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)

        Column().width(1).height(38).backgroundColor(COLORS.line)

        Column({ space: 2 }) {
          Text('12 份').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text('健康档案报告').fontSize(10).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)

        Column().width(1).height(38).backgroundColor(COLORS.line)

        Column({ space: 2 }) {
          Text('良好').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.sec)
          Text('健康评分').fontSize(10).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)

        Column().layoutWeight(1)

        Text('🪪').fontSize(16)
          .width(34).height(34).textAlign(TextAlign.Center)
          .backgroundColor(COLORS.chip).borderRadius(17)
          .onClick(() => {
            this.currentTab = 2;
          })
      }
      .width('100%')

第二行是健康数据条,展示了三个关键指标:家庭建档成员数(7人,薄荷主色,30号字体并带呼吸动画)、健康档案报告数(12份,标题白色)、健康评分(良好,晴空蓝)。三个指标之间用1px宽、38px高的分割线隔开,形成了清晰的视觉分隔。

最右侧是一个身份证Emoji图标按钮,点击后跳转到"就诊卡"Tab(currentTab = 2),这就是快捷入口的跨Tab导航功能。注意"7人"这个数字绑定了this.breath的透明度动画——当breathtrue时完全不透明,为false时72%不透明度,形成了每秒一次的脉动效果。

可收展快捷宫格

      Grid() {
        ForEach(HOME_ENTRY, (e: EntryMeta, i: number) => {
          if (i < 4 || this.gridExpand) {
            GridItem() {
              Column({ space: 6 }) {
                Text(e.icon).fontSize(20)
                Text(e.label).fontSize(10).fontColor(COLORS.sub)
              }
              .width('100%')
              .padding({ top: 10, bottom: 10 })
            }
            .onClick(() => {
              if (e.label === '预约挂号') {
                this.currentTab = 1;
              }
            })
          }
        }, (e: EntryMeta) => e.label)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')
      .rowsTemplate(this.gridExpand ? '1fr 1fr' : '1fr')
      .columnsGap(10)
      .rowsGap(10)
      .width('100%')
      .height(this.gridExpand ? 150 : 75)
      .animation({ duration: 220, curve: Curve.EaseInOut })

这是头部最复杂的部分——一个可收展的Grid快捷宫格。ForEach遍历8个入口,但通过if (i < 4 || this.gridExpand)条件控制:当gridExpandtrue时渲染全部8项(两行四列),为false时只渲染前4项(一行四列)。

rowsTemplate根据gridExpand动态切换为'1fr 1fr'(两行)或'1fr'(一行),高度也同步在150和75之间切换。关键是.animation({ duration: 220, curve: Curve.EaseInOut })——当gridExpand的值变化导致Grid高度从75变为150(或反向)时,ArkUI会自动以220毫秒的EaseInOut缓动曲线执行高度过渡动画,实现了平滑的收展效果。

      Row() {
        Text(this.gridExpand ? '收起 ∧' : '展开 ∨')
          .fontSize(10)
          .fontColor(COLORS.main)
          .padding({ left: 14, right: 14, top: 4, bottom: 2 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.gridExpand = !this.gridExpand;
      })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 14, bottom: 14 })
    .backgroundColor(COLORS.card)
  }

收展控制通过底部的"收起/展开"按钮实现。点击后翻转gridExpand状态,Grid的行数、高度随之变化并触发动画。按钮文字也会根据状态在"收起 ∧"和"展开 ∨"之间切换,箭头方向直观地指示了操作方向。整个头部Column使用COLORS.card作为背景色,与页面背景形成层次。

十一、tabHome首页:横滑Banner与候诊提醒

首页是用户最常访问的页面,它需要在有限的屏幕空间内展示最有价值的信息。这里采用了横滑Banner加候诊提醒卡片的双模块设计。

  @Builder
  tabHome() {
    Column({ space: 12 }) {
      Scroll() {
        Row({ space: 12 }) {
          ForEach(BANNER_LIST, (b: BannerItem) => {
            Column({ space: 6 }) {
              Text(b.tag).fontSize(9).fontColor(COLORS.sec)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(COLORS.dark).borderRadius(8)
              Text(b.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(b.sub).fontSize(10).fontColor(COLORS.sub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width(220)
            .alignItems(HorizontalAlign.Start)
            .padding(14)
            .backgroundColor(COLORS.card)
            .borderRadius(14)
          }, (b: BannerItem) => b.title)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

横滑Banner通过Scroll容器配合ScrollDirection.Horizontal实现水平滚动。每个Banner卡片固定宽度220px,内部从上到下排列标签(晴空蓝小胶囊)、主标题(加粗白色)和副标题(灰色),标题和副标题都设置了maxLines(1)TextOverflow.Ellipsis防溢出。.scrollBar(BarState.Off)隐藏了滚动条,使界面更加简洁。

候诊叫号提醒卡

      Row({ space: 10 }) {
        Text('⏳').fontSize(20)
        Column({ space: 2 }) {
          Text('候诊叫号提醒').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text('前方 12 人 · 预计 25 分钟 · 心血管内科 8 号').fontSize(10).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('叫号').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .backgroundColor(COLORS.main).borderRadius(14)
          .onClick(() => {
            this.currentTab = 5;
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.card)
      .borderRadius(14)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

候诊提醒卡片是一个Row布局:左侧沙漏Emoji,中间是提醒标题和详细信息(前方人数、预计时间、科室号),右侧是"叫号"按钮。按钮使用薄荷主色背景、深色文字(COLORS.dark),点击后跳转到"医院"Tab(currentTab = 5)。这种深色文字配亮色背景的设计在暗色主题中形成强烈的视觉焦点,有效引导用户操作。

十二、tabReg挂号:双列科室卡片网格

挂号页采用了与首页完全不同的布局——双列Grid卡片矩阵展示科室列表。

  @Builder
  tabReg() {
    Column({ space: 12 }) {
      Row() {
        Text('🏥 预约挂号').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('康诺附属医院 · 今日').fontSize(10).fontColor(COLORS.sub)
      }
      .width('100%')

      Grid() {
        ForEach(DEPT_LIST, (d: DeptItem) => {
          GridItem() {
            Column({ space: 6 }) {
              Text(d.icon).fontSize(24)
              Text(d.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(d.wait).fontSize(9).fontColor(COLORS.sub)
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .backgroundColor(COLORS.card)
            .borderRadius(12)
          }
        }, (d: DeptItem) => d.name)
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr 1fr')
      .columnsGap(10)
      .rowsGap(10)
      .width('100%')
      .height(320)

Grid使用'1fr 1fr'两列模板和'1fr 1fr 1fr 1fr'四行模板,将8个科室排列为2列4行的矩阵。每个科室卡片包含图标(24号字)、名称(加粗白色,单行省略)和候诊人数(灰色小字)。固定高度320px确保卡片尺寸一致,视觉整齐。

号源紧张提示

      Row({ space: 12 }) {
        ForEach(DEPT_KIND, (k: string) => {
          Row({ space: 6 }) {
            Circle().width(6).height(6).fill(COLORS.main)
            Text(`${k}号源紧张`).fontSize(10).fontColor(COLORS.sub)
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
        }, (k: string) => k)
      }
      .width('100%')
      .padding(10)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

底部是一个号源紧张提示条,遍历DEPT_KIND数组(儿科、口腔、眼科),每个科室前面有一个薄荷绿小圆点。三个提示项通过layoutWeight(1)均分宽度,justifyContent(FlexAlign.Center)居中对齐,形成了简洁的信息提示栏。

十三、tabCard就诊卡:Vision Kit中心大卡与证件列表

就诊卡页是整个应用的技术核心,它集成了Vision Kit卡证识别能力,是连接物理证件与数字档案的桥梁。

中心大卡设计

  @Builder
  tabCard() {
    Column({ space: 12 }) {
      // 中心大卡:Vision Kit 建档实名入口
      Column({ space: 10 }) {
        Text('🪪').fontSize(40)
          .opacity(this.breath ? 1 : 0.75)
        Text('电子就诊卡 · 拍卡建档').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text('基于系统级卡证识别控件完成就诊建档。\n港澳台居民持通行证建档,全院一码通行。')
          .fontSize(10)
          .fontColor(COLORS.sub)
          .textAlign(TextAlign.Center)

        Text('选择证件开始建档 ↓').fontSize(11).fontColor(COLORS.main)
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .backgroundColor(COLORS.chip)
          .borderRadius(14)
      }
      .width('100%')
      .padding(22)
      .backgroundColor(COLORS.dark)
      .borderRadius(16)
      .linearGradient({
        angle: 160,
        colors: [[COLORS.mainD, 0.0], [COLORS.dark, 0.6]]
      })

中心大卡是一个视觉焦点元素:40号字的身份证Emoji图标带有呼吸动画(透明度在1和0.75之间切换),下方是标题"电子就诊卡·拍卡建档"和说明文字。最关键的是linearGradient线性渐变——从160度角开始,薄荷深色mainD在0%位置过渡到深色dark在60%位置,营造出从左上到右下的光泽渐变效果,使大卡呈现出立体感和高级感。

证件类型列表

      Text('支持的建档证件').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)

      ForEach(DOC_LIST, (d: DocItem, i: number) => {
        Row({ space: 12 }) {
          Text(d.icon).fontSize(20)
            .width(40).height(40).textAlign(TextAlign.Center)
            .backgroundColor(COLORS.chip).borderRadius(20)

          Column({ space: 3 }) {
            Row({ space: 6 }) {
              Text(d.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              if (d.isNew) {
                Text('NEW').fontSize(8).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
                  .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                  .backgroundColor(COLORS.sec).borderRadius(6)
              }
            }

            Text(d.desc).fontSize(10).fontColor(COLORS.sub)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Text('建档').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS.chip).borderRadius(12)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .onClick(() => {
          this.scanIdx = i;
          this.scanning = true;
        })
      }, (d: DocItem) => d.name)

每个证件类型是一个Row卡片:左侧圆形图标背景,中间是证件名称和描述(如果是新支持的证件类型还会显示"NEW"标签——晴空蓝背景、深色文字),右侧是"建档"按钮。点击整行卡片会设置scanIdx为当前索引并启动识别(scanning = true),这一步是触发Vision Kit全屏识别界面的关键。

建档记录列表

      Row() {
        Text('🕘 建档记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text(`${this.scanRecords.length}`).fontSize(10).fontColor(COLORS.text3)
      }
      .width('100%')

      if (this.scanRecords.length === 0) {
        Text('暂无建档记录,点击上方证件类型开始拍卡建档')
          .fontSize(10)
          .fontColor(COLORS.text3)
          .width('100%')
          .padding(16)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
      } else {
        ForEach(this.scanRecords, (r: ScanRecord) => {
          Column({ space: 6 }) {
            Row() {
              Text(r.cardName).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
              Column().layoutWeight(1)
              Text(r.time).fontSize(9).fontColor(COLORS.text3)
            }
            .width('100%')

            Text(r.raw).fontSize(9).fontColor(COLORS.sub)
              .maxLines(4)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .width('100%')
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
        }, (r: ScanRecord, i: number) => `${r.time}-${i}`)
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

建档记录区域根据scanRecords数组的长度进行条件渲染:空状态显示居中提示文案,非空状态遍历显示每条记录。每条记录包含证件名称(薄荷主色)、时间(灰色)和原始识别数据(灰色,最多4行,超出省略)。ForEach的key生成器使用${r.time}-${i}确保唯一性。

用户进入就诊卡页

显示中心大卡+证件列表

用户点击证件类型

设置 scanIdx = i

设置 scanning = true

Stack渲染 scanView

CardRecognition 控件全屏启动

用户拍摄证件

Vision Kit 识别处理

onResult 回调

code === 200?

scanning = false 退出

提取 cardInfo 各面数据

push 到 scanRecords 数组

scanning = false 退出

返回就诊卡页显示新记录

十四、tabReport报告:清单行布局

报告页采用了最简洁的清单行布局,每行一个报告项,信息密度高且扫视效率强。

  @Builder
  tabReport() {
    Column({ space: 12 }) {
      Row() {
        Text('🧪 检验检查报告').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('2 项检测中').fontSize(10).fontColor(COLORS.main)
      }
      .width('100%')

      ForEach(REPORT_LIST, (r: ReportItem) => {
        Row() {
          Column({ space: 3 }) {
            Text(r.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(`${r.hosp} · ${r.time}`).fontSize(9).fontColor(COLORS.text3)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text(r.status).fontSize(10).fontColor(reportColor(r.status))

          Text('查看').fontSize(10).fontColor(COLORS.sub)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .backgroundColor(COLORS.chip).borderRadius(10)
            .margin({ left: 10 })
        }
        .width('100%')
        .padding({ top: 12, bottom: 12, left: 14, right: 14 })
        .backgroundColor(COLORS.card)
        .borderRadius(12)
      }, (r: ReportItem) => r.name)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

每行报告的布局是三段式:左侧是项目名称和医院日期信息(通过layoutWeight(1)占据剩余空间),中间是状态文字(颜色由reportColor函数动态计算),右侧是"查看"按钮。这种布局让用户可以快速扫视所有报告的状态——绿色表示已完成,薄荷色表示进行中,一目了然。

头部右上角的"2项检测中"是一个全局状态提示,帮助用户快速了解当前有多少报告正在处理中。

十五、tabMed用药:固定高度时间轴

用药提醒页采用了独特的时间轴布局,每条用药记录占据固定高度的行,通过竖线连接形成时间轴效果。

  @Builder
  tabMed() {
    Column() {
      Row() {
        Text('💊 今日用药提醒').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('4 次/日').fontSize(10).fontColor(COLORS.sub)
      }
      .width('100%')
      .margin({ bottom: 10 })

      ForEach(MED_LIST, (m: MedItem, i: number) => {
        Row({ space: 8 }) {
          Column() {
            Text(m.time.substring(0, 5)).fontSize(9).fontColor(COLORS.text3)
            Circle().width(8).height(8).fill(medColor(m.status)).margin({ top: 3 })
            if (i < MED_LIST.length - 1) {
              Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 3 })
            }
          }
          .width(44)
          .height(76)
          .alignItems(HorizontalAlign.Center)

时间轴的左侧列固定宽度44px、高度76px,从上到下排列三个元素:时间文字(截取前5个字符)、状态圆点(颜色由medColor计算)、连接竖线(宽2px,通过layoutWeight(1)填充剩余高度)。最后一条记录不渲染竖线,通过if (i < MED_LIST.length - 1)条件控制,这是时间轴设计的标准做法。

时间轴右侧内容卡

          Column({ space: 5 }) {
            Row() {
              Text(m.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                .layoutWeight(1)
              Text(m.status).fontSize(10).fontColor(medColor(m.status))
            }
            .width('100%')

            Text(m.note).fontSize(10).fontColor(COLORS.sub)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1)
          .height(64)
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 12, right: 10, top: 10, bottom: 10 })
          .backgroundColor(COLORS.card)
          .borderRadius(12)
        }
        .width('100%')
      }, (m: MedItem) => `${m.time}-${m.title}`)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

右侧内容卡固定高度64px,内部是药品名称(加粗白色,通过layoutWeight(1)占据左侧空间)和状态标签(颜色由medColor计算)的行布局,下方是服用说明(灰色小字,单行省略)。左右两侧通过Row({ space: 8 })组合,8px的间距恰好保持了时间轴节点与内容卡之间的视觉关联。

十六、tabHosp医院:横滑医院大卡

医院页采用了与首页Banner类似的横滑布局,但卡片尺寸更大、信息更丰富。

  @Builder
  tabHosp() {
    Column({ space: 12 }) {
      Row() {
        Text('🏬 合作医院').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('按距离排序').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Scroll() {
        Row({ space: 12 }) {
          ForEach(HOSP_LIST, (h: HospItem) => {
            Column({ space: 6 }) {
              Text('🏥').fontSize(34)
              Text(h.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(h.level).fontSize(10).fontColor(COLORS.sub)
              Row({ space: 10 }) {
                Text(h.dist).fontSize(10).fontColor(COLORS.sec)
                Text(h.wait).fontSize(10).fontColor(COLORS.main)
              }
              Text('去挂号').fontSize(10).fontWeight(FontWeight.Bold).fontColor(COLORS.dark)
                .padding({ left: 14, right: 14, top: 4, bottom: 4 })
                .backgroundColor(COLORS.main).borderRadius(10)
                .margin({ top: 4 })
                .onClick(() => {
                  this.currentTab = 1;
                })
            }
            .width(160)
            .alignItems(HorizontalAlign.Start)
            .padding(16)
            .backgroundColor(COLORS.card)
            .borderRadius(14)
          }, (h: HospItem) => h.name)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

每张医院卡片固定宽度160px,内部从上到下排列:医院图标(34号Emoji)、医院名称(加粗白色,单行省略)、等级(灰色小字)、距离和号源状态(晴空蓝和薄荷绿双色区分)、"去挂号"按钮(薄荷主色背景、深色文字,点击跳转到挂号Tab)。

距离用sec晴空蓝色、号源状态用main薄荷绿色,两种颜色在视觉上形成了冷暖对比,帮助用户快速区分两类信息。点击"去挂号"按钮会设置currentTab = 1跳转到挂号页,实现了功能模块间的导航闭环。

十七、tabPatient就诊人:四列头像墙

就诊人管理页采用了四列Grid头像墙布局,每个家庭成员以头像加名称的形式呈现。

  @Builder
  tabPatient() {
    Column({ space: 12 }) {
      Row() {
        Text('👪 就诊人管理').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('点击卡片管理').fontSize(10).fontColor(COLORS.sub)
      }
      .width('100%')

      Grid() {
        ForEach(this.patientList, (p: PatientItem, i: number) => {
          GridItem() {
            Column({ space: 6 }) {
              Text(p.emoji).fontSize(24)
                .width(48).height(48).textAlign(TextAlign.Center)
                .backgroundColor(COLORS.chip).borderRadius(24)
              Text(p.name).fontSize(10).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(p.rel === '' ? '添加家人' : p.rel).fontSize(9).fontColor(COLORS.sub)
              Text(p.cardNo === '' ? '建档后可用' : p.cardNo).fontSize(8).fontColor(COLORS.text3)
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }
          .onClick(() => {
            if (p.rel === '') {
              this.addModal = true;
            } else {
              this.editIdx = i;
              this.editModal = true;
            }
          })
        }, (p: PatientItem) => p.name)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .columnsGap(10)
      .rowsGap(10)
      .width('100%')
      .height(300)

Grid使用四列两行模板,将8个就诊人排列为2行4列。每个卡片包含圆形头像背景(48x48,chip色背景,圆角24实现圆形)、姓名(加粗白色)、关系(灰色小字)、卡号(更小的灰色字)。

点击卡片的行为通过p.rel === ''条件分支:如果rel为空字符串(即"新增就诊人"入口),则打开新增弹窗(addModal = true);否则记录当前索引并打开管理弹窗(editIdx = i; editModal = true)。这种"同一个点击事件根据数据状态执行不同操作"的设计,在ForEach中非常实用。

证件更新提示条

      Row({ space: 12 }) {
        Text('🪪').fontSize(14)
        Text('证件资料可在「就诊卡」页拍卡更新').fontSize(10).fontColor(COLORS.sub)
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('去更新').fontSize(10).fontColor(COLORS.main)
          .onClick(() => {
            this.currentTab = 2;
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

底部提示条引导用户到就诊卡页更新证件资料,点击"去更新"会跳转到currentTab = 2。注意patientList在这里使用的是this.patientList而非直接引用PATIENT_LIST常量——因为patientList@State修饰,新增和删除操作会触发UI自动更新,而直接引用常量则不会。

十八、chartCard月度图表:Canvas式柱状图

月度就诊图表通过纯ArkUI组件实现了柱状图效果,无需引入图表库。

  @Builder
  chartCard() {
    Column({ space: 12 }) {
      Row() {
        Text('📊 月度就诊(次)').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('近 6 个月').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

图表卡片标题区与其他Tab一致,左侧标题右侧时间范围说明。

柱状图主体

      Row({ space: 10 }) {
        ForEach(MONTH_NAME, (m: string, i: number) => {
          Column({ space: 6 }) {
            Column()
              .width(26)
              .height(28 + VISIT_VAL[i] / 3 * 76 + (this.breath ? 3 : 0))
              .backgroundColor(VISIT_VAL[i] === 3 ? COLORS.sec : COLORS.mainD)
              .borderRadius({ topLeft: 6, topRight: 6 })
              .opacity(this.breath ? 1 : 0.82)

            Text(m).fontSize(9).fontColor(COLORS.text3)
          }
          .layoutWeight(1)
        }, (m: string) => m)
      }
      .width('100%')

柱状图的核心是每个柱子的高度计算公式:28 + VISIT_VAL[i] / 3 * 76 + (this.breath ? 3 : 0)。其中28是基础高度(保证最小可见高度),VISIT_VAL[i] / 3 * 76是将就诊次数(1-3)映射为0-76px的增量高度(除以3是因为最大值为3),this.breath ? 3 : 0是呼吸动画的增量——每秒交替增加3px高度,产生柱子的"生长"动画效果。

柱子颜色根据就诊次数判断:VISIT_VAL[i] === 3时用晴空蓝sec表示就诊高峰,其他用薄荷深色mainD表示常规月。柱子顶部圆角(topLeft: 6, topRight: 6)和透明度呼吸动画进一步增强了视觉动感。

图例

      Row() {
        Row({ space: 6 }) {
          Column().width(10).height(10).backgroundColor(COLORS.sec).borderRadius(3)
          Text('就诊高峰').fontSize(10).fontColor(COLORS.sub)
        }
        Row({ space: 6 }).margin({ left: 16 }) {
          Column().width(10).height(10).backgroundColor(COLORS.mainD).borderRadius(3)
          Text('常规月').fontSize(10).fontColor(COLORS.sub)
        }
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.card)
    .borderRadius(12)
    .margin({ top: 12 })
  }

图例居中显示两种颜色含义:晴空蓝方块代表"就诊高峰",薄荷深色方块代表"常规月"。每个图例项由一个10x10的圆角小方块和文字组成,两组图例之间有16px的左间距。

十九、tabBar底部导航:两行Tab布局

底部导航栏是这个应用最独特的布局设计之一——7个Tab分为两行(4+3),且每个Tab的布局风格完全不同。

  @Builder
  tabItem(t: TabMeta, idx: number) {
    Column({ space: 3 }) {
      Text(t.icon).fontSize(19).opacity(idx === this.currentTab ? 1 : 0.5)
      Text(t.label).fontSize(9)
        .fontColor(idx === this.currentTab ? COLORS.tabOn : COLORS.text3)
        .fontWeight(idx === this.currentTab ? FontWeight.Bold : FontWeight.Normal)
    }
    .layoutWeight(1)
    .padding({ top: 7, bottom: 7 })
    .onClick(() => {
      this.currentTab = idx;
    })
  }

tabItem是一个可复用的Tab项Builder,接收TabMeta和索引idx两个参数。激活状态(idx === this.currentTab)的Tab图标完全不透明、文字使用薄荷主色且加粗;非激活状态的Tab图标50%透明度、文字灰色且常规字重。点击Tab项设置this.currentTab = idx即可切换内容区。

两行导航布局

  @Builder
  tabBar() {
    Column({ space: 2 }) {
      Row() {
        ForEach(TAB_ROW1, (t: TabMeta, i: number) => {
          this.tabItem(t, i)
        }, (t: TabMeta) => t.label)
      }
      .width('100%')

      Row() {
        ForEach(TAB_ROW2, (t: TabMeta, i: number) => {
          this.tabItem(t, i + 4)
        }, (t: TabMeta) => t.label)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 4, bottom: 6 })
    .backgroundColor(COLORS.card)
  }

tabBar通过两个Row分别渲染TAB_ROW1(前4个Tab)和TAB_ROW2(后3个Tab)。第二行的索引通过i + 4偏移,确保7个Tab的索引为0-6连续值。每个tabItem通过layoutWeight(1)均分行宽,使每行内Tab项等宽排列。

二十、scanView:Vision Kit卡证识别控件

这是整个应用最具技术含量的部分——Vision Kit的CardRecognition控件。

  @Builder
  scanView() {
    // Vision Kit 卡证识别控件:识别期间全屏独占,不允许任何元素遮挡
    CardRecognition({
      supportType: SCAN_TYPES[this.scanIdx],
      cardRecognitionConfig: {
        defaultShootingMode: ShootingMode.MANUAL,
        isPhotoSelectionSupported: true
      },
      onResult: ((params: CardRecognitionResult) => {
        if (params.code !== 200) {
          this.scanning = false;
          return;
        }
        const parts: string[] = [];
        if (params.cardInfo?.front !== undefined) {
          parts.push(JSON.stringify(params.cardInfo.front));
        }
        if (params.cardInfo?.back !== undefined) {
          parts.push(JSON.stringify(params.cardInfo.back));
        }
        if (params.cardInfo?.main !== undefined) {
          parts.push(JSON.stringify(params.cardInfo.main));
        }
        this.scanRecords.push(new ScanRecord('刚刚', DOC_LIST[this.scanIdx].name, parts.join('\n')));
        this.scanning = false;
      })
    })
    .width('100%')
    .height('100%')
  }

CardRecognition组件接收三个核心配置:

supportType:通过SCAN_TYPES[this.scanIdx]从映射数组中取出对应的CardType,确定要识别的证件类型。

cardRecognitionConfig:配置识别行为——defaultShootingMode: ShootingMode.MANUAL设为手动拍摄模式(用户需要主动按下快门),isPhotoSelectionSupported: true允许从相册选择照片进行识别(而非只能实时拍摄)。

onResult回调:识别完成后的处理逻辑。首先检查params.code是否为200(成功状态码),非200则直接退出识别。成功时从params.cardInfo中提取证件的正面(front)、背面(back)和主页(main)数据,分别JSON序列化后用换行符连接,最终构造一个ScanRecord实例push到scanRecords数组中。

这里使用了可选链操作符?.undefined检查来安全地访问cardInfo的各个属性——因为不同证件类型的识别结果结构不同(身份证有正反面,护照只有单面),需要逐个判断是否存在。这种防御性编程确保了无论识别结果包含哪些面的数据,都能被正确提取和存储。

识别完成后设置scanning = falsebuild方法中的条件分支会自动切换回主界面,新增的建档记录会立即显示在就诊卡页的记录列表中。

0

1

2

3

4

5

6

底部Tab栏

点击Tab项

设置 currentTab = idx

build方法if-else链

currentTab值

tabHome 横滑Banner

tabReg 双列科室

tabCard 中心大卡

tabReport 清单行

tabMed 时间轴

tabHosp 横滑医院

tabPatient 头像墙

chartCard 图表

底部Tab栏

二十一、modalOverlay:通用遮罩层

弹窗系统是这个应用的另一个亮点。首先定义一个通用的遮罩层Builder。

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => onClose())
  }

modalOverlay是一个极简的全屏遮罩层:一个全宽全高的Column,背景色为COLORS.mask(半透明黑色),点击时调用onClose回调关闭弹窗。这个Builder被三个弹窗复用,是@Builder函数实现UI复用的典型案例。点击遮罩层关闭弹窗是移动端的标准交互模式,用户无需精确点击关闭按钮即可退出弹窗。

二十二、panelAdd:新增就诊人弹窗

新增就诊人弹窗是一个底部弹出的表单面板。

  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)

      Column({ space: 12 }) {
        Text('+ 新增就诊人').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)

        Column({ space: 6 }) {
          Text('姓名').fontSize(11).fontColor(COLORS.sub)
          TextInput({ placeholder: '如:陈嘉辉' })
            .fontSize(13)
            .fontColor(COLORS.title)
            .placeholderColor(COLORS.text3)
            .backgroundColor(COLORS.chip)
            .borderRadius(10)
            .onChange((v: string) => {
              this.addTitle = v;
            })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)

Stack容器中先放遮罩层,再放内容面板。内容面板从上到下排列:标题"+新增就诊人"、姓名输入框(带placeholder提示)、关系输入框。TextInputonChange回调实时将输入值同步到this.addTitle状态变量,确保点击保存时能获取到最新输入。

关系输入与保存逻辑

        Column({ space: 6 }) {
          Text('与本人关系').fontSize(11).fontColor(COLORS.sub)
          TextInput({ placeholder: '如:父亲 / 配偶 / 女儿' })
            .fontSize(13)
            .fontColor(COLORS.title)
            .placeholderColor(COLORS.text3)
            .backgroundColor(COLORS.chip)
            .borderRadius(10)
            .onChange((v: string) => {
              this.addNote = v;
            })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)

        Row({ space: 10 }) {
          Button('取消')
            .fontSize(13)
            .fontColor(COLORS.sub)
            .backgroundColor(COLORS.chip)
            .borderRadius(14)
            .layoutWeight(1)
            .onClick(() => onClose())
          Button('保存')
            .fontSize(13)
            .fontColor(COLORS.dark)
            .backgroundColor(COLORS.main)
            .borderRadius(14)
            .layoutWeight(1)
            .onClick(() => {
              const name: string = this.addTitle === '' ? '新成员' : this.addTitle;
              const rel: string = this.addNote === '' ? '家人' : this.addNote;
              this.patientList.splice(this.patientList.length - 1, 0,
                new PatientItem('👤', name, rel, 'KNO-NEW'));
              this.addTitle = '';
              this.addNote = '';
              this.addModal = false;
            })
        }
        .width('100%')
        .margin({ top: 4 })
      }
      .width('100%')
      .padding(18)
      .backgroundColor(COLORS.card)
      .borderRadius({ topLeft: 18, topRight: 18 })
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Bottom)
  }

保存按钮的onClick逻辑值得仔细分析:首先对输入值做空值兜底(addTitle为空则用"新成员",addNote为空则用"家人"),然后通过splice(this.patientList.length - 1, 0, new PatientItem(...))在数组倒数第二个位置(即"新增就诊人"入口之前)插入新成员。这种在固定入口前插入新数据的做法确保了"新增就诊人"入口始终在列表末尾。保存完成后清空表单并关闭弹窗。

面板的borderRadius({ topLeft: 18, topRight: 18 })只设置顶部圆角,配合alignContent(Alignment.Bottom)底部对齐,呈现出从底部滑入的视觉效果。

二十三、panelEdit:管理就诊人弹窗

管理弹窗提供了"设为默认"“更换头像”"删除"三个操作。

  @Builder
  panelEdit(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)

      Column({ space: 12 }) {
        Text('管理就诊人').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text(this.editIdx >= 0 ? this.patientList[this.editIdx].name : '')
          .fontSize(12)
          .fontColor(COLORS.sub)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row({ space: 8 }) {
          ForEach(['设为默认', '更换头像'], (s: string) => {
            Text(s).fontSize(12).fontColor(COLORS.purple)
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .backgroundColor(COLORS.chip)
              .borderRadius(14)
              .onClick(() => {
                if (this.editIdx >= 0) {
                  const p = this.patientList[this.editIdx];
                  this.patientList[this.editIdx] =
                    new PatientItem(s === '设为默认' ? '⭐' : '🙂', p.name, p.rel, p.cardNo);
                }
                this.editModal = false;
              })
          }, (s: string) => s)

“设为默认"操作将就诊人头像改为星号Emoji"⭐”,“更换头像"改为微笑Emoji"🙂”。实现方式是读取当前就诊人的数据,构造一个新的PatientItem实例(只改变emoji字段),然后通过数组索引赋值替换原元素。由于patientList@State数组,赋值后会自动触发UI更新。

删除操作

          Text('删除').fontSize(12).fontColor(COLORS.red)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(COLORS.chip)
            .borderRadius(14)
            .onClick(() => {
              this.delIdx = this.editIdx;
              this.editModal = false;
              this.delModal = true;
            })
        }

        Button('关闭')
          .fontSize(13)
          .fontColor(COLORS.sub)
          .backgroundColor(COLORS.chip)
          .borderRadius(14)
          .width('100%')
          .onClick(() => onClose())
      }
      .width('100%')
      .padding(18)
      .backgroundColor(COLORS.card)
      .borderRadius({ topLeft: 18, topRight: 18 })
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Bottom)
  }

"删除"按钮使用红色文字COLORS.red以警示用户此操作的不可逆性。点击后不直接删除,而是关闭管理弹窗并打开删除确认弹窗(delModal = true),这种"二次确认"设计有效防止了误操作。delIdx = this.editIdx将当前编辑索引传递给删除流程。

二十四、panelDel:删除确认弹窗

删除确认弹窗是一个居中显示的对话框,与底部弹出的前两个弹窗在视觉上形成区分。

  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)

      Column({ space: 14 }) {
        Text('⚠️ 删除就诊人').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.red)
        Text(this.delIdx >= 0 ? `确定删除「${this.patientList[this.delIdx].name}」的档案吗?` : '')
          .fontSize(12)
          .fontColor(COLORS.sub)

        Row({ space: 10 }) {
          Button('再想想')
            .fontSize(13)
            .fontColor(COLORS.sub)
            .backgroundColor(COLORS.chip)
            .borderRadius(14)
            .layoutWeight(1)
            .onClick(() => onClose())
          Button('确认删除')
            .fontSize(13)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.red)
            .borderRadius(14)
            .layoutWeight(1)
            .onClick(() => {
              if (this.delIdx >= 0) {
                this.patientList.splice(this.delIdx, 1);
              }
              this.delModal = false;
            })
        }
        .width('100%')
      }
      .width('72%')
      .padding(18)
      .backgroundColor(COLORS.card)
      .borderRadius(16)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

删除弹窗的标题使用红色加粗字体并带警告Emoji"⚠️",提示文案动态插入被删除者的姓名(如"确定删除「陈嘉辉」的档案吗?")。两个按钮中,"再想想"使用灰色背景(取消操作),"确认删除"使用红色背景(危险操作),色彩语义与操作风险等级匹配。

确认删除通过this.patientList.splice(this.delIdx, 1)从数组中移除指定索引的元素,@State数组的变化会自动触发就诊人头像墙的UI更新。面板宽度72%配合alignContent(Alignment.Center)居中显示,与底部弹出的弹窗在视觉形态上形成区分——居中对话框更适合需要用户做出决策的场景。

二十五、七大Tab布局对比分析

这个应用最核心的设计理念是"七态异构"——7个Tab页面每个都有完全不同的布局风格。下面通过一张对比表来系统性地分析它们各自的布局特点和适用场景。

Tab名称布局风格核心组件信息密度交互特点适用场景
首页(tabHome)横滑Banner+候诊提醒卡Scroll(Horizontal)+Row横滑浏览+一键叫号日常入口页,展示优先级最高的信息
挂号(tabReg)双列科室卡片矩阵Grid(2列4行)点击科室进入挂号需要在有限空间展示多个并列选项
就诊卡(tabCard)中心大卡+证件列表+记录Column+linearGradient+ForEach拍卡建档(Vision Kit)核心功能页,连接物理证件与数字档案
报告(tabReport)清单行列表ForEach+Row查看报告详情信息密度最高,适合扫视型浏览
用药(tabMed)固定高度时间轴ForEach+Column(竖线连接)时间轴节点查看有时序关系的数据展示
医院(tabHosp)横滑医院大卡Scroll(Horizontal)+Column横滑浏览+去挂号类似首页但信息粒度更粗
就诊人(tabPatient)四列头像墙+弹窗管理Grid(4列2行)+Modal点击头像管理/新增家庭成员管理,头像墙直观亲切

从对比表可以看出,7个Tab的布局选择并非随意,而是根据每个功能页面的信息特征和用户操作意图精心设计的:

  • 信息密度高的页面(挂号、报告)使用网格或清单布局,最大化屏幕利用率。
  • 有时序关系的数据(用药提醒)使用时间轴布局,竖线连接直观展示时间流。
  • 需要突出核心功能的页面(就诊卡)使用中心大卡+渐变背景,形成视觉焦点。
  • 日常入口页(首页、医院)使用横滑布局,兼顾信息展示量和浏览流畅性。
  • 人际关系管理(就诊人)使用头像墙,Emoji头像比纯文字更亲切直观。

二十六、@Builder函数架构分析

整个组件定义了16个@Builder函数,它们构成了一个层次分明的UI构建体系。

识别层 Builder

弹窗 Builder

公共 Builder

主界面 Builder

根层 Builder

7个Tab Builder

tabHome()

tabReg()

tabCard()

tabReport()

tabMed()

tabHosp()

tabPatient()

build()

headerMed()

tabBar()

chartCard()

tabItem()

panelAdd()

panelEdit()

panelDel()

modalOverlay()

scanView()

从架构图可以看出,@Builder函数形成了清晰的三层结构:

根层build()是入口,负责整体布局编排和条件渲染控制。

页面层headerMed()、7个Tab Builder、tabBar()各自负责一个独立区域的UI构建。

组件层chartCard()在所有Tab内容后统一渲染;tabItem()tabBar()调用7次;modalOverlay()被三个弹窗复用。

这种分层设计使得每个Builder函数职责单一、代码量可控(大多在20-60行之间),既保证了可读性,又实现了高度的代码复用。

二十七、状态驱动与动画系统

这个应用的动画系统设计非常精巧——仅用一个@State breath布尔变量就驱动了全局多处动画效果。

// 头部数字呼吸
Text('7 人').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLORS.main)
  .opacity(this.breath ? 1 : 0.72)

// 就诊卡大图标呼吸
Text('🪪').fontSize(40)
  .opacity(this.breath ? 1 : 0.75)

// 柱状图呼吸(高度+透明度双动画)
Column()
  .height(28 + VISIT_VAL[i] / 3 * 76 + (this.breath ? 3 : 0))
  .opacity(this.breath ? 1 : 0.82)

三处动画都绑定到同一个breath变量:头部"7人"数字透明度在1和0.72之间切换,就诊卡大图标透明度在1和0.75之间切换,柱状图同时在高度上增加3px且透明度在1和0.82之间切换。由于breath每秒翻转一次,所有绑定它的UI元素会同步产生周期性的"呼吸"效果,使界面看起来"活"了起来。

这种"单状态变量驱动多目标动画"的设计模式有两个显著优势:一是性能开销极小,只维护一个定时器和一个状态变量;二是动画同步性完美,所有呼吸效果完全同步,不会出现各处动画节奏不一致的视觉混乱。

二十八、跨Tab导航与功能闭环

应用中多处实现了跨Tab导航,形成了功能闭环。

// 头部证件图标 → 就诊卡Tab
.onClick(() => { this.currentTab = 2; })

// 首页"叫号"按钮 → 医院Tab
.onClick(() => { this.currentTab = 5; })

// 首页"预约挂号"入口 → 挂号Tab
.onClick(() => { if (e.label === '预约挂号') { this.currentTab = 1; } })

// 医院卡片"去挂号"按钮 → 挂号Tab
.onClick(() => { this.currentTab = 1; })

// 就诊人页"去更新"按钮 → 就诊卡Tab
.onClick(() => { this.currentTab = 2; })

5处跨Tab导航形成了一个功能闭环网络:用户从首页可以快速跳转到挂号和医院;从医院可以跳转到挂号;从就诊人可以跳转到就诊卡;从头部可以直接进入就诊卡。这些导航路径覆盖了医疗场景中最常见的操作流程——预约挂号、建档管理、就诊人维护,使得用户无需通过底部Tab栏逐个切换,提升了操作效率。

二十九、数据流与状态更新机制

整个应用的数据流可以分为两条主线:静态数据流和动态数据流。

静态数据流BANNER_LISTDEPT_LISTREPORT_LISTMED_LISTHOSP_LIST等常量数组在组件加载时直接渲染,运行期间不发生变化。这些数据通过ForEach渲染到UI上,由于它们不是@State变量,不会触发响应式更新。

动态数据流patientListscanRecords是两个动态@State数组。patientList在初始化时引用PATIENT_LIST常量,但后续通过splice方法进行增删操作时,由于它是@State变量,UI会自动更新。scanRecords初始为空数组,每次Vision Kit识别成功后通过push方法添加新记录,同样触发UI更新。

// 新增就诊人:splice插入
this.patientList.splice(this.patientList.length - 1, 0,
  new PatientItem('👤', name, rel, 'KNO-NEW'));

// 删除就诊人:splice删除
this.patientList.splice(this.delIdx, 1);

// 添加识别记录:push追加
this.scanRecords.push(new ScanRecord('刚刚', DOC_LIST[this.scanIdx].name, parts.join('\n')));

三种数组操作(splice插入、splice删除、push追加)分别对应三种用户操作(新增就诊人、删除就诊人、拍卡建档),每次操作后UI自动更新,无需手动调用刷新方法。这就是ArkUI状态驱动UI的核心价值——开发者只需关注数据变化,UI同步由框架自动处理。

三十、ForEach键生成策略与渲染优化

ArkUI的ForEach组件在渲染列表数据时,需要通过键生成器(key generator)为每个数据项生成唯一标识。这个应用中采用了多种键生成策略,值得逐一分析。

// 策略一:使用单一字段作为键
ForEach(BANNER_LIST, (b: BannerItem) => { ... }, (b: BannerItem) => b.title)
ForEach(DEPT_LIST, (d: DeptItem) => { ... }, (d: DeptItem) => d.name)
ForEach(REPORT_LIST, (r: ReportItem) => { ... }, (r: ReportItem) => r.name)

// 策略二:使用组合字段作为键
ForEach(MED_LIST, (m: MedItem) => { ... }, (m: MedItem) => `${m.time}-${m.title}`)
ForEach(this.scanRecords, (r: ScanRecord, i: number) => { ... },
  (r: ScanRecord, i: number) => `${r.time}-${i}`)

第一种策略使用数据项的titlename字段作为唯一键,适用于数据项名称不会重复的场景。第二种策略使用模板字符串组合多个字段,适用于单一字段可能重复的场景——例如用药提醒中不同时间可能服用相同药品(“08:00-维生素D3滴剂"和"明日08:00-维生素D3滴剂”),通过时间加药品名的组合确保了键的唯一性。建档记录则使用时间加索引${r.time}-${i}作为键,因为多条记录的time字段可能相同(都是"刚刚"),需要通过索引区分。

键生成策略直接影响ForEach的渲染性能。当数据数组发生变化(增删改)时,ArkUI通过比对新旧键来确定哪些DOM节点需要创建、更新或销毁。设计良好的键生成器可以最大化复用已有DOM节点,避免不必要的重绘。

三十一、linearGradient渐变与视觉层次构建

在暗色主题中,纯色背景容易显得单调扁平。这个应用通过linearGradient线性渐变为关键区域增加了视觉层次。

.linearGradient({
  angle: 160,
  colors: [[COLORS.mainD, 0.0], [COLORS.dark, 0.6]]
})

就诊卡中心大卡使用了160度角的渐变:从薄荷深色#2CA98D(0%位置)过渡到深湖蓝#0D1726(60%位置)。160度角意味着渐变方向从左下指向右上,这使卡片左上角呈现薄荷绿光泽,向右下逐渐融入深色背景,营造出类似医疗仪器面板的科技质感。

渐变的两个关键参数是angle(角度)和colors(颜色停靠点数组)。colors数组中每个元素是[颜色值, 位置]的二元组,位置范围0到1。这里只在0%和60%设置了停靠点,意味着60%到100%之间会保持dark色不变,避免渐变延伸到卡片底部时颜色过浅。

除了渐变,应用还通过其他视觉手段构建层次:圆角(borderRadius从6到18不等,大圆角用于卡片、小圆角用于标签和按钮)、分割线(1px宽的line色细线分隔数据条各段)、阴影叠加(通过Stack的多层渲染实现弹窗的悬浮效果)。这些细节共同构成了一个层次丰富、视觉舒适的暗色医疗界面。

三十二、防御性编程与边界处理

在处理用户输入和外部数据时,这个应用展现了扎实的防御性编程实践。

// 弹窗保存时的空值兜底
const name: string = this.addTitle === '' ? '新成员' : this.addTitle;
const rel: string = this.addNote === '' ? '家人' : this.addNote;

// 索引合法性检查
Text(this.editIdx >= 0 ? this.patientList[this.editIdx].name : '')
Text(this.delIdx >= 0 ? `确定删除「${this.patientList[this.delIdx].name}」的档案吗?` : '')

// Vision Kit结果的安全访问
if (params.cardInfo?.front !== undefined) {
  parts.push(JSON.stringify(params.cardInfo.front));
}
if (params.cardInfo?.back !== undefined) {
  parts.push(JSON.stringify(params.cardInfo.back));
}
if (params.cardInfo?.main !== undefined) {
  parts.push(JSON.stringify(params.cardInfo.main));
}

第一处是新增就诊人弹窗保存时的空值兜底——即使用户不输入任何内容直接点击保存,也会用"新成员"和"家人"作为默认值,避免空字符串写入数据导致UI渲染异常。

第二处是索引合法性检查——在管理弹窗和删除弹窗中,通过editIdx >= 0delIdx >= 0判断索引是否有效后才访问数组元素,防止索引为初始值-1时数组越界崩溃。

第三处是Vision Kit识别结果的安全访问——使用可选链操作符?.配合undefined判断,逐个检查cardInfofrontbackmain三个面是否存在。因为不同证件类型的识别结果结构不同:身份证有正反面,护照只有单面(main),通行证的结构又各有差异。如果不做判断直接访问,可能导致undefinedJSON.stringify序列化为字符串"undefined",污染识别记录数据。

这些防御性编程实践虽然增加了少量代码量,但有效提升了应用的健壮性,确保了在各种边界条件下不会出现崩溃或数据异常。

三十三、@Observed数据模型与响应式深度

ArkUI提供了@Observed@ObjectLink两个装饰器来支持多层级的响应式数据管理。这个应用中定义了8个@Observed数据模型类,包括BannerItemDocItemDeptItemReportItemMedItemHospItemPatientItemScanRecord

@Observed装饰器的作用是将一个普通类标记为可观测的。当该类的实例作为@State@ObjectLink变量的值被UI引用时,实例属性的变化会自动触发UI更新。在这个应用中,patientList数组的元素就是@ObservedPatientItem实例,当通过数组索引赋值替换某个元素时(如管理弹窗中的"设为默认"操作),ArkUI能够检测到变化并更新对应的Grid项。

需要注意的是,@Observed只追踪对象引用的变化,而非深层属性的变化。这就是为什么管理弹窗中更换头像时需要构造一个全新的PatientItem实例进行整体替换,而非直接修改p.emoji = '⭐'——后者不会触发响应式更新。理解这一机制对于正确使用ArkUI的状态管理至关重要,也是开发者在实际项目中容易踩到的陷阱之一。通过整体替换对象引用来触发更新,是ArkUI中处理嵌套对象变更的标准做法。

三十四、综合总结

通过对这个互联网医疗应用界面的逐段拆解,我们可以总结出以下几个关键技术实践:

颜色系统的工程化设计:通过ColorPalette接口和COLORS常量实现了17种语义化命名的颜色集中管理,三级深浅层次(bg/card/chip)构建了清晰的暗色主题视觉层级,薄荷绿主色#4DE1C1和晴空蓝#6FA8FF的冷暖对比为不同类型信息提供了色彩区分。

七态异构的布局策略:7个Tab页面分别采用横滑Banner、双列网格、中心大卡、清单行、时间轴、横滑大卡、头像墙7种完全不同的布局风格,每种布局都精准匹配了对应功能的信息展示需求。这种"拒绝统一模板、量体裁衣"的设计理念值得在复杂业务场景中借鉴。

@Builder函数的分层复用:16个Builder函数形成根层、页面层、组件层三层架构,tabItem()被复用7次,modalOverlay()被三个弹窗复用,实现了高度的代码复用和职责分离。

Vision Kit卡证识别集成:通过CardRecognition控件实现了身份证、护照、港澳通行证、台湾通行证四种证件的系统级识别,SCAN_TYPES数组的索引映射设计简洁高效,onResult回调中的防御性编程确保了不同证件类型识别结果的安全处理。

单状态变量驱动全局动画breath布尔变量每秒翻转一次,同步驱动头部数字、就诊卡图标、柱状图三处呼吸动画,以极小的性能开销实现了界面的"活感"。

状态驱动的数据更新@State数组patientListscanRecords的增删操作自动触发UI更新,开发者无需手动管理刷新逻辑,@Observed数据模型类确保了深层属性变化的可观测性。

这个应用的技术实现展示了ArkUI声明式开发范式在复杂业务场景下的强大表现力——用不到1200行代码构建了一个包含7种异构布局、Vision Kit卡证识别、三套弹窗系统、全局呼吸动画的完整医疗健康应用界面,代码结构清晰、复用度高、可维护性强。对于正在使用HarmonyOS ArkUI进行行业应用开发的工程师来说,这份实现具有极高的参考价值。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型说明
应用(Application)开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service)开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项示例值说明
项目名称(Project name)rollboat应用的项目名称,建议使用英文命名
包名(Bundle name)com.rollboat.myapplication应用唯一标识,采用反向域名格式
保存路径(Save location)D:\CodeFactory\rollboat项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK)6.1.1(24)目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name)entry主模块名称,默认 entry 为应用入口模块
设备类型(Device types)☑ Phone勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法作用
@Entry标记为页面入口,可用于路由跳转
@Component声明为自定义组件
@State状态变量,数据变更时自动触发 UI 刷新
RelativeContainer相对布局容器,替代传统线性布局
.onClick()点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称阶段状态
HarmonyOS 6.1.1Release✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本SDK 版本号阶段状态
API Version 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤操作关键点
创建项目欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成使用 Stage 模型 + ArkTS 语言
查看 SDK设置 → HarmonyOS SDKSDK 已内置,无需手动安装
跨平台扩展设置 → ArkUI-X根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐