ArkUI 深墨绿政务风布局实战:政务服务行业·多场景融合·七态分栏布局·Vision Kit 卡证识别技术全解析
一、引言:当政务服务遇见 ArkUI 声明式开发

在数字化转型浪潮中,政务服务移动化已成为衡量一个城市治理现代化水平的重要标尺。从"最多跑一次"到"一网通办",再到如今的"指尖办、掌上办",政务服务平台正经历着从功能可用到体验卓越的深刻变革。鸿蒙生态的崛起,为这一变革提供了全新的技术底座——ArkUI 声明式开发框架以其简洁的语法、高效的渲染机制和丰富的系统能力,成为构建政务服务应用的理想选择。
本文将以一个名为"智办通·政务服务平台"的应用为案例,深入剖析如何利用 ArkUI 构建一个覆盖社保、公积金、户籍、出入境等多业务场景的综合性政务应用。该应用不仅需要承载海量的办事事项,还要在不同业务场景下呈现出差异化的布局风格,同时集成华为 Vision Kit 的卡证识别能力,实现身份证、港澳通行证、台湾通行证的拍卡即办。
政务服务应用的核心挑战在于:如何在有限的屏幕空间内,既保持信息层次清晰、操作路径简短,又能体现政务服务的权威性与亲和力?传统的"一刀切"列表布局往往无法满足不同业务场景的差异化需求——首页需要 Banner 推广与快捷入口,办事需要排行展示,实名需要突出识别入口,预约需要网点卡片,进度需要时间轴,证照需要横滑大卡,消息需要清单流。这就要求我们在布局设计上做出根本性的创新:每个 Tab 采用完全不同的布局策略。

本文将从颜色系统设计、数据模型构建、状态管理、Builder 函数拆分、Vision Kit 集成、Canvas 图表绘制、模态弹窗、动画效果等多个技术维度,逐段拆解这个应用的实现细节,力求让每一位开发者都能从中获得可复用的工程经验。

二、应用整体架构总览

在深入代码细节之前,我们先从宏观层面理解整个应用的技术架构。这个政务服务平台采用了"单页面 + 多 Tab + 全屏识别切换"的架构模式,所有业务功能集中在同一个 @Entry @Component 组件中,通过 currentTab 状态变量驱动条件渲染,实现七个业务场景的无缝切换。

2.1 架构层次划分
整个应用可以划分为以下几个清晰的层次:
第一层是基础配置层,包含颜色系统、常量定义和辅助函数。颜色系统通过一个 ColorPalette 接口定义了 17 个语义化颜色常量,从背景色到文字色、从主色到辅色、从分隔线到遮罩层,形成了一套完整的深墨绿政务主题方案。
第二层是数据模型层,使用 @Observed 装饰器定义了 9 个数据类,分别对应 Banner 轮播、证件列表、事项排行、网点信息、预约记录、办理步骤、电子证照、消息通知和识别记录。每个类都配有构造函数和预设的静态数据集,使应用在无后端对接时也能呈现完整的业务效果。
第三层是状态管理层,通过 @State 装饰器管理了 Tab 切换、宫格展开/收起、三个模态弹窗的显隐、动画呼吸状态、卡证识别状态以及可变数据列表等共十余个响应式状态变量。
第四层是视图渲染层,由十余个 @Builder 函数构成,每个函数负责一个独立的 UI 区块——从头部品牌区到七个 Tab 内容页,再到底部 Tab 栏、图表卡片和模态弹窗。
2.2 架构关系图
这张架构图清晰地展示了从底层数据配置到上层视图渲染的完整数据流。值得注意的是,状态管理层的 currentTab 是整个应用的"枢纽"——它同时驱动着七个 Tab 内容页的渲染切换,是条件渲染分支的根变量。而 breath 状态则是一个跨多个 Builder 函数共享的动画驱动器,在头部数据条、实名中心大卡和月度图表三处都有使用,形成了一种统一的"呼吸感"视觉节奏。
三、颜色系统设计:深墨绿政务主题的语义化色彩体系
颜色是政务应用的"第一印象"。不同于商业应用常用的明亮色调,政务服务需要传达权威、稳重和信赖感。本应用选择了一套以深墨绿为底、政务青为主、叶绿为辅的配色方案,通过一个 ColorPalette 接口实现了色彩的全面语义化管理。
3.1 ColorPalette 接口定义
首先来看颜色接口的定义:
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;
}
这个接口定义了 17 个颜色槽位,每个槽位都有明确的语义用途。bg 是页面背景色,card 是卡片背景色,chip 是标签/按钮底色,dark 是更深的卡片色用于区分层次。文字颜色分三级:title 是主标题色(最亮),sub 是副文本色(中等),text3 是辅助说明色(最暗),这种三级文字色体系确保了信息层次的清晰传达。
主色和辅色各有一个深色变体:main/mainD 是政务青的明暗双色,sec/secD 是叶绿的明暗双色。这种"明暗配对"设计非常精妙——亮色用于强调和交互态,暗色用于渐变背景和静态展示,两者配合可以营造出丰富的层次感。
3.2 COLORS 常量实例
接下来是颜色的具体取值:
const COLORS: ColorPalette = {
bg: '#0B1512',
card: '#12211C',
chip: '#1A2E27',
dark: '#0E1B16',
title: '#E8F5EF',
sub: '#A7C6BA',
text3: '#6B8C7F',
main: '#2EC4B6',
mainD: '#1B8D82',
sec: '#7BD389',
secD: '#4E9E5C',
red: '#FF6B6B',
green: '#3FD98C',
purple: '#B388FF',
line: '#1F3A31',
tabOn: '#2EC4B6',
mask: 'rgba(3,8,6,0.66)',
};
我们来逐一分析这些颜色值的设计考量。背景色 #0B1512 是一种极深的墨绿色,RGB 值为 (11, 21, 18),绿色通道略高于红色和蓝色通道,营造出一种沉稳的绿调。卡片色 #12211C 比背景色亮一档,RGB (18, 33, 28),确保卡片能从背景中"浮"出来。最有趣的是 dark 色 #0E1B16,它比 card 更深,用于实名核验中心大卡的渐变底色,在视觉上形成一种"深邃聚焦"的效果。
主色 #2EC4B6 是一种青绿色(Teal),RGB (46, 196, 182),既有青色的科技感又有绿色的生命力,非常适合政务服务的调性。辅色 #7BD389 是一种明亮的叶绿,RGB (123, 211, 137),用于强调"高峰"、“成功”、"新增"等正向语义。mask 使用了 rgba(3,8,6,0.66) 的半透明黑色,66% 的不透明度既能有效遮挡背景内容,又不会完全阻断视觉联系,是弹窗遮罩的经典选择。
3.3 颜色系统的工程价值
将所有颜色集中到一个接口对象中管理,具有三个显著的工程优势。第一,主题切换极为便捷——只需替换 COLORS 常量的取值,整个应用就会自动跟随变化,无需在每个组件中逐一修改。第二,颜色语义化后,代码可读性大幅提升,COLORS.main 比 '#2EC4B6' 更容易理解其用途。第三,避免了"魔术数字"问题,颜色值只出现一次,后续全部通过引用使用,修改时不会遗漏。
四、常量定义与数据配置
在颜色系统之后,应用定义了一系列常量来配置 Tab 结构、快捷入口和业务数据。这些常量是整个应用的数据骨架。
4.1 Tab 元数据定义
底部 Tab 栏分为两行,每行用独立的数组管理:
interface TabMeta {
icon: string;
label: string;
}
const TAB_ROW1: TabMeta[] = [
{ icon: '🏠', label: '首页' },
{ icon: '🗂️', label: '办事' },
{ icon: '🪪', label: '实名' },
{ icon: '📅', label: '预约' },
];
const TAB_ROW2: TabMeta[] = [
{ icon: '📈', label: '进度' },
{ icon: '🎫', label: '证照' },
{ icon: '📮', label: '消息' },
];
TabMeta 接口只有两个字段:icon 和 label,分别存储 Tab 的 Emoji 图标和文字标签。之所以分成 TAB_ROW1 和 TAB_ROW2 两个数组,是因为底部 Tab 采用了 4+3 的两行布局——第一行四个 Tab,第二行三个 Tab。这种设计在政务场景下有其合理性:七个业务模块的优先级不同,首页、办事、实名、预约是高频核心功能放在第一行,进度、证照、消息相对低频放在第二行。两行布局还能有效降低每个 Tab 的宽度,使图标和文字都能舒适展示。
4.2 快捷宫格入口
首页的快捷功能宫格定义如下:
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: '更多事项' },
];
EntryMeta 同样是 icon+label 的简单结构。八个快捷入口覆盖了社保、公积金、户籍、驾驶证、出入境、税务、教育七大政务领域,外加一个"更多事项"兜底入口。这八个入口在头部宫格中以 4 列布局展示,收起时只显示前 4 个,展开时全部 8 个可见。需要注意的是,"出入境"入口绑定了跳转到实名 Tab 的交互逻辑,形成了业务场景间的有机串联。
4.3 Vision Kit 证件类型映射
const SCAN_TYPES: CardType[] = [
CardType.CARD_ID,
CardType.CARD_MAINLAND_TRAVEL_PERMIT_HK_MO,
CardType.CARD_MAINLAND_TRAVEL_PERMIT_TW,
];
这是一个关键的映射常量。SCAN_TYPES 数组将三个 CardType 枚举值按顺序排列,与下文的 DOC_LIST 证件列表一一对应——索引 0 对应身份证,索引 1 对应港澳通行证,索引 2 对应台湾通行证。当用户点击某个证件类型时,代码会用 SCAN_TYPES[this.scanIdx] 取出对应的 CardType 传给 Vision Kit 的 CardRecognition 控件。这种"索引映射"模式避免了复杂的 switch-case 分支,代码简洁且易于扩展。
4.4 图表数据与时间段
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const CASE_VAL: number[] = [4, 3, 6, 5, 8, 6];
const BOOK_SLOT: string[] = ['工作日', '周六'];
MONTH_NAME 和 CASE_VAL 是月度办件量图表的数据源,分别存储月份标签和办件量数值(单位:千件)。六个月的数据呈现出 3 月 4 千件、4 月回落到 3 千件、5 月回升到 6 千件、6 月略降至 5 千件、7 月达到峰值 8 千件、8 月回落至 6 千件的波动趋势。BOOK_SLOT 定义了预约时段的两个选项,在新增预约弹窗中作为快捷选择标签使用。
五、辅助函数:状态色彩的智能映射
辅助函数虽然简短,却在整个应用中扮演着"色彩路由器"的角色,根据业务状态自动返回对应的颜色值。
5.1 caseColor:办件状态着色
function caseColor(s: string): string {
if (s === '已办结') return COLORS.green;
if (s === '办理中') return COLORS.main;
return COLORS.text3;
}
caseColor 函数接收一个状态字符串,返回对应的语义颜色。"已办结"返回绿色 #3FD98C,传达成功完成的正向信号;“办理中"返回主色 #2EC4B6,表示进行中的活跃状态;其他所有状态(如"待开始”、“待确认”)统一返回辅助文本色 #6B8C7F,以低调的灰色表示未激活。这个函数在进度时间轴、预约列表等多处被调用,确保了状态着色的一致性。
5.2 rankColor:排行名次着色
function rankColor(i: number): string {
if (i === 0) return COLORS.main;
if (i === 1) return COLORS.sec;
if (i === 2) return COLORS.purple;
return COLORS.text3;
}
rankColor 函数根据排行榜索引返回颜色——第一名(索引 0)用政务青主色,第二名(索引 1)用叶绿辅色,第三名(索引 2)用紫色 #B388FF,从第四名起统一使用灰色。这种"前三名特殊着色"的设计在排行榜场景中非常常见,能够快速引导用户关注 Top3 热门事项。紫色的引入也丰富了色彩谱系,使前三名之间有明显的视觉区分。
六、数据模型层:@Observed 装饰器与九大业务实体
ArkUI 的 @Observed 装饰器用于标记可观察的数据类。当一个类被 @Observed 修饰后,其属性的变化可以被 ArkUI 框架追踪,从而驱动 UI 的自动更新。本应用定义了 9 个 @Observed 数据类,每个类对应一种业务实体。
6.1 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('跨省通办', '312 项事项异地可办', '社保 · 公积金 · 税务'),
new BannerItem('新上线', '港澳台居民办事专区', '通行证实名 · 一站通办'),
new BannerItem('指尖办', '电子证照亮证服务', '扫码亮证 · 与实体同效'),
];
BannerItem 包含三个字段:tag 是小标签(如"跨省通办"),title 是主标题,sub 是副说明。三条 Banner 数据分别推广跨省通办、港澳台居民专区和电子证照服务,覆盖了应用的核心亮点功能。这些 Banner 在首页以横向滚动方式展示,每张宽度固定为 220vp,形成一种"信息卡片轮播"的效果。
6.2 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('🪪', '港澳居民来往内地通行证', '回乡证实名 · 拍卡即录', true),
new DocItem('🎫', '台湾居民来往大陆通行证', '台胞证实名 · 拍卡即录', true),
];
DocItem 增加了一个 isNew 布尔字段,用于标记是否为新上线的功能。港澳通行证和台湾通行证都标记为 true,在 UI 上会显示一个绿色的"NEW"角标。这三个证件类型与前面定义的 SCAN_TYPES 数组一一对应——DOC_LIST 的索引就是 SCAN_TYPES 的索引,这种设计使得点击任意证件项时,只需用同一个索引即可取到对应的 CardType。
6.3 MatterItem:热门事项排行
@Observed export class MatterItem {
name: string;
dept: string;
hot: string;
constructor(name: string, dept: string, hot: string) {
this.name = name;
this.dept = dept;
this.hot = hot;
}
}
const MATTER_LIST: MatterItem[] = [
new MatterItem('社保参保登记', '人力资源和社会保障局', '12.4 万办'),
new MatterItem('公积金提取', '住房公积金管理中心', '9.8 万办'),
new MatterItem('居住证办理', '公安局', '8.6 万办'),
new MatterItem('护照换发预约', '出入境管理局', '6.2 万办'),
new MatterItem('个体工商户注册', '市场监督管理局', '5.4 万办'),
new MatterItem('入学报名', '教育局', '4.1 万办'),
];
MatterItem 包含事项名称、承办部门和办理量三个字段。六条数据按办理量从高到低排列,社保参保登记以 12.4 万次位居榜首,入学报名以 4.1 万次垫底。每个事项的 hot 字段是一个已格式化的字符串(如"12.4 万办"),这种设计避免了在 UI 层做数字格式化的额外逻辑,直接展示即可。
6.4 SiteItem:网点信息
@Observed export class SiteItem {
name: string;
addr: string;
wait: string;
constructor(name: string, addr: string, wait: string) {
this.name = name;
this.addr = addr;
this.wait = wait;
}
}
const SITE_LIST: SiteItem[] = [
new SiteItem('市民中心 A 馆', '福中三路 · 地铁 2 号线', '候办 12 人'),
new SiteItem('湾区政务驿站', '前海自贸区 C 座', '候办 3 人'),
new SiteItem('街道便民中心', '南山街道办 1 楼', '候办 8 人'),
new SiteItem('税务办理大厅', '深南大道 6011 号', '候办 21 人'),
];
SiteItem 的三个字段分别表示网点名称、地址和候办人数。候办人数信息对政务预约场景极为关键——市民可以根据候办人数选择人少的网点和时间,减少现场等待。四条数据中,湾区政务驿站候办仅 3 人,是最优选择;税务办理大厅候办 21 人,需要做好排队准备。
6.5 BookingItem:预约记录
@Observed export class BookingItem {
matter: string;
site: string;
date: string;
status: string;
constructor(matter: string, site: string, date: string, status: string) {
this.matter = matter;
this.site = site;
this.date = date;
this.status = status;
}
}
const BOOKING_LIST: BookingItem[] = [
new BookingItem('公积金提取', '市民中心 A 馆', '08-26 10:00', '已预约'),
new BookingItem('护照换发', '出入境管理局', '08-30 14:30', '已预约'),
new BookingItem('居住证签注', '街道便民中心', '09-05 09:00', '待确认'),
new BookingItem('税务申报更正', '税务办理大厅', '08-18 16:00', '已完成'),
];
BookingItem 是预约 Tab 的核心数据模型,包含事项、网点、时间和状态四个字段。状态有三种取值:“已预约”、“待确认”、“已完成”,分别对应不同的颜色。这条数据是整个应用中唯一一个被声明为 @State 可变数组的模型——用户可以通过"新增预约"弹窗添加记录,通过"改"弹窗修改状态,通过"删"弹窗删除记录,所有操作都会实时反映到 UI 上。
6.6 StepItem:办理步骤
@Observed export class StepItem {
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 STEP_LIST: StepItem[] = [
new StepItem('08-24 09:31', '申请提交成功', '已办结', '公积金提取 · ¥36,000'),
new StepItem('08-24 09:35', '材料初审通过', '已办结', '系统自动核验'),
new StepItem('08-24 11:02', '部门复核中', '办理中', '预计 1 个工作日'),
new StepItem('—', '资金划转', '待开始', '划入绑定储蓄卡'),
new StepItem('—', '短信通知结果', '待开始', '办结后自动发送'),
];
StepItem 有四个字段:时间、标题、状态和备注。五条数据模拟了一笔公积金提取业务的完整办理流程——从申请提交、初审通过、复核中,到待完成的资金划转和短信通知。前两步已经办结(绿色),第三步正在办理中(青色),后两步尚未开始(灰色),时间轴的视觉呈现会清晰地展示这种"进度推进感"。未开始的步骤时间显示为"—",这是政务场景中常见的占位符表达。
6.7 LicenseItem:电子证照
@Observed export class LicenseItem {
icon: string;
name: string;
no: string;
valid: string;
constructor(icon: string, name: string, no: string, valid: string) {
this.icon = icon;
this.name = name;
this.no = no;
this.valid = string;
}
}
const LICENSE_LIST: LicenseItem[] = [
new LicenseItem('👤', '居民身份证', '4403***62X', '长期'),
new LicenseItem('💼', '社保卡', 'A19***7742', '2028-06'),
new LicenseItem('🚗', '驾驶证', 'C1 · 4403***21', '2030-03'),
new LicenseItem('🏠', '居住证', 'J82***0913', '2027-01'),
];
LicenseItem 代表电子证照,包含图标、名称、证号和有效期。证号做了脱敏处理(如4403***62X),符合政务信息展示的安全规范。四本证照以横向滚动大卡片的形式展示,每张卡片宽 150vp,配有"亮证"按钮,体现"扫码亮证·与实体同效"的电子证照核心理念。
6.8 MsgItem 与 ScanRecord
@Observed export class MsgItem {
icon: string;
title: string;
time: string;
constructor(icon: string, title: string, time: string) {
this.icon = icon;
this.title = title;
this.time = time;
}
}
@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;
}
}
MsgItem 是消息通知的数据模型,包含图标、标题和时间。ScanRecord 是卡证识别记录,raw 字段存储识别返回的原始 JSON 数据,最多显示 4 行。ScanRecord 没有预设静态数据——它是在运行时通过 Vision Kit 识别回调动态生成的,存储在 @State scanRecords 数组中,体现了数据的实时性。
七、组件主体与状态管理
现在我们进入应用的核心——Page1005 组件。这是整个应用的"大脑",所有的状态变量和生命周期方法都集中在此。
7.1 @State 状态变量群
@Entry
@Component
struct Page1005 {
// --- 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;
currentTab 是整个应用最核心的状态变量,初始值为 0(首页),取值范围 0-6 分别对应七个 Tab。每当用户点击底部 Tab 时,这个值就会改变,从而触发 build() 函数中的条件渲染分支重新执行,展示对应的 Tab 内容。
gridExpand 控制头部快捷宫格的展开/收起状态,初始为 true(展开)。后面的四个弹窗状态变量和两个索引变量共同管理三个模态弹窗——addModal/editModal/delModal 控制弹窗显隐,editIdx/delIdx 记录操作的目标行索引。
7.2 弹窗表单与动画状态
// --- 弹窗表单 ---
@State addTitle: string = '';
@State addNote: string = '';
// --- 动画状态 ---
@State breath: boolean = false;
timer: number = -1;
addTitle 和 addNote 是新增预约弹窗中两个 TextInput 的绑定变量,分别存储办理事项和网点时间信息。breath 是一个布尔型的"呼吸"状态——它每秒翻转一次,驱动多个 UI 元素的透明度/高度变化,产生类似呼吸灯的闪烁效果。timer 是定时器 ID,注意它没有 @State 装饰,因为它不参与 UI 渲染,只是一个内部引用。
7.3 卡证识别状态
// --- 卡证识别状态 ---
@State scanning: boolean = false;
@State scanIdx: number = -1;
@State scanRecords: ScanRecord[] = [];
// --- 可变数据 ---
@State bookingList: BookingItem[] = BOOKING_LIST;
卡证识别使用了三个状态变量协同工作。scanning 是一个布尔开关——当为 true 时,整个页面切换为全屏的 CardRecognition 识别视图,所有其他 UI 被隐藏;scanIdx 记录用户选择识别的证件类型索引,用于从 SCAN_TYPES 和 DOC_LIST 中取值;scanRecords 是识别结果的动态数组,每次识别成功后都会 push 一条新记录。bookingList 是预约列表的可变副本,初始值取自静态常量 BOOKING_LIST,支持运行时的增删改操作。
7.4 生命周期:呼吸动画的启停
aboutToAppear() {
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 1000);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、UI 渲染前调用。这里通过 setInterval 设置了一个 1000 毫秒(1 秒)的定时器,每秒将 breath 翻转一次。aboutToDisappear 在组件销毁前调用,清理定时器以避免内存泄漏。这对"启动-清理"的配对是 ArkUI 动画编程的标准范式。
breath 状态每秒翻转的效果是:头部数据条的数字透明度在 0.72 和 1.0 之间切换,实名中心大卡的图标透明度在 0.75 和 1.0 之间切换,月度图表的柱形高度在基础值和基础值+3 之间切换。这些细微的变化让整个界面始终保持一种"活着"的感觉,避免了静态界面的呆板感。
八、build() 主入口:条件渲染的全局调度
build() 函数是每个 ArkUI 组件的渲染入口,它定义了组件的 UI 结构。本应用的 build() 函数采用了一个 Stack 作为根容器,内部通过 scanning 状态进行了一级条件分支。
8.1 全屏识别与正常视图的切换
build() {
Stack() {
if (this.scanning && this.scanIdx >= 0) {
this.scanView()
} else {
Column() {
this.headerGov()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
// ... Tab 内容条件渲染
}
.padding({ left: 14, right: 14, top: 14, bottom: 18 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
this.tabBar()
}
.width('100%')
.height('100%')
当 scanning 为 true 且 scanIdx 有效时,渲染 scanView()——即 Vision Kit 的 CardRecognition 全屏识别控件。否则渲染正常的应用界面:一个 Column 从上到下依次放置头部 headerGov()、分隔线、可滚动的内容区和底部 Tab 栏。
内容区使用 Scroll 包裹,并通过 layoutWeight(1) 占据头部和底部之间的所有剩余空间。滚动条设为 BarState.Off 隐藏,保持界面整洁。内层 Column 设置了 14vp 的左右内边距和 14/18 的上下内边距,为内容留出舒适的呼吸空间。
8.2 Tab 内容的条件渲染
if (this.currentTab === 0) {
this.tabHome()
} else if (this.currentTab === 1) {
this.tabMatter()
} else if (this.currentTab === 2) {
this.tabIdentify()
} else if (this.currentTab === 3) {
this.tabBooking()
} else if (this.currentTab === 4) {
this.tabProgress()
} else if (this.currentTab === 5) {
this.tabLicense()
} else {
this.tabMsg()
}
this.chartCard()
七个 Tab 通过 if-else if-else 链式条件判断来切换渲染,currentTab 的值决定哪个 Builder 函数被执行。值得注意的是,无论切换到哪个 Tab,chartCard()(月度办件量图表)都会在 Tab 内容之后渲染——这是一个全局共享的底部信息卡片,不受 Tab 切换影响。这种"Tab 专属内容 + 全局共享内容"的布局模式在政务应用中很常见,确保关键数据始终可见。
8.3 模态弹窗的叠加渲染
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)
}
三个模态弹窗通过各自的状态变量控制显隐,使用 if 条件渲染——当状态为 true 时弹窗出现,为 false 时消失。每个弹窗都接收一个 onClose 回调函数,用于在关闭时将状态置为 false。这些弹窗叠加在 Stack 的最上层,通过遮罩层阻挡下方内容的交互。
外层 Stack 设置了全屏尺寸、背景色和内容居中对齐。alignContent(Alignment.Center) 确保所有子元素在水平和垂直方向都居中,这对于全屏识别视图和弹窗的定位非常重要。
九、头部 Builder:品牌标题与数据条
头部是政务应用的"门面",需要在有限空间内传达品牌标识、核心数据和快捷入口。headerGov() Builder 承担了这个重任。
9.1 品牌标识与办理中状态
@Builder
headerGov() {
Column({ space: 12 }) {
Row() {
Column({ space: 2 }) {
Text('智办通').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('政务服务平台 · smart gov').fontSize(10).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Row({ space: 6 }) {
Circle().width(8).height(8).fill(COLORS.sec)
Text('1 件办理中').fontSize(12).fontColor(COLORS.sec)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.chip)
.borderRadius(12)
}
.width('100%')
头部第一行分为三部分:左侧品牌标题("智办通"主标题 + 英文副标题),中间弹性占位 Column().layoutWeight(1),右侧"1 件办理中"状态胶囊。状态胶囊内有一个 8vp 的叶绿色小圆点和文字,底色为 chip 色,圆角 12vp。这个小圆点配合 breath 动画(虽然此处未直接绑定 breath,但语义上暗示活跃状态)传达"有事项正在办理"的实时感。
9.2 办件数据条
Row({ space: 14 }) {
Column({ space: 2 }) {
Text('32').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('4 本').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('312 项').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.sec)
Text('跨省通办').fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
数据条展示三个核心指标:32 件历史办件(30vp 主色大字)、4 本电子证照(20vp 标题色)、312 项跨省通办(20vp 辅色)。三个数据块之间用 1vp 宽、38vp 高的竖线分隔,分隔线颜色为 line 色。第一个数据"32"绑定了 breath 透明度动画——每秒在 0.72 和 1.0 之间切换,形成一种数据"心跳"效果,暗示数据是实时更新的。
字号的设计也值得玩味:32 用 30vp(最大),其他用 20vp。这种字号差异引导用户首先关注最重要的"历史办件数",然后再扫视其他两项,符合视觉层级原则。
9.3 亮证快捷入口
Column().layoutWeight(1)
Text('🪪').fontSize(16)
.width(34).height(34).textAlign(TextAlign.Center)
.backgroundColor(COLORS.chip).borderRadius(17)
.onClick(() => {
this.currentTab = 2;
})
}
.width('100%')
数据条最右侧是一个 34x34vp 的圆形亮证入口,点击后跳转到实名 Tab(索引 2)。这个入口的存在使用户可以从头部直接进入实名核验流程,缩短了操作路径。圆形通过 borderRadius(17)(宽度的一半)实现,背景色为 chip 色,与整体设计语言一致。
9.4 快捷宫格的展开/收起
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 = 2;
}
})
}
}, (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 使用 columnsTemplate('1fr 1fr 1fr 1fr') 定义为 4 列等宽布局。行模板根据 gridExpand 动态切换——展开时两行('1fr 1fr'),收起时一行('1fr')。ForEach 遍历 HOME_ENTRY,但只有索引小于 4 或 gridExpand 为 true 的项才会渲染 GridItem,实现了"收起留首行 4 项"的效果。
height 属性绑定了 gridExpand 状态——展开 150vp,收起 75vp。配合 .animation({ duration: 220, curve: Curve.EaseInOut }),高度变化会产生一个 220 毫米的缓入缓出动画,使展开/收起过程平滑自然。"出入境"项绑定了跳转到实名 Tab 的点击事件,与亮证入口形成双重入口路径。
9.5 展开/收起按钮
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 显示"收起 ∧"或"展开 ∨",颜色为主色。点击后翻转 gridExpand 状态,触发宫格高度动画。整个头部 Builder 用 Column 包裹,设置了 14vp 的四周内边距和 card 背景色,形成一块完整的头部卡片区域。
十、首页 Tab:横滑 Banner 与取号提醒
首页是用户进入应用后看到的第一屏,需要快速传达核心信息并提供便捷入口。tabHome() Builder 采用横滑 Banner + 取号提醒卡片的组合。
10.1 横滑 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 + Row 实现,每张卡片宽度固定 220vp。卡片内部从上到下依次是:小标签(9vp 叶绿色,dark 底色圆角胶囊)、主标题(15vp 加粗)、副说明(10vp)。标题和说明都设置了 maxLines(1) 和 textOverflow(Ellipsis),确保长文本以省略号截断而非破坏布局。滚动条设为 Off,保持视觉干净。ForEach 的键值使用了 b.title,确保每张卡片的唯一性。
10.2 取号提醒卡片
Row({ space: 10 }) {
Text('🏛️').fontSize(22)
Column({ space: 2 }) {
Text('取号提醒').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('市民中心当前候办 12 人 · 约 25 分钟').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 = 3;
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(14)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
取号提醒卡片是一个 Row,左侧是建筑物 Emoji 图标,中间是标题+说明(候办 12 人、约 25 分钟),右侧是"预约"按钮。按钮使用主色背景、深色文字(COLORS.dark),形成高对比度的 CTA(Call To Action)效果。点击后跳转到预约 Tab(索引 3),形成了从首页到预约的业务闭环。这种"信息展示 + 行动入口"的卡片模式在政务场景中极为常见。
十一、办事 Tab:大编号事项排行榜
办事 Tab 展示热门事项的办理量排行,采用"大编号 + 事项信息 + 办理量"的行式布局。
11.1 标题行与排行列表
@Builder
tabMatter() {
Column({ space: 10 }) {
Row() {
Text('🗂️ 热门事项 · 办理量排行').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('按月办件量').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
ForEach(MATTER_LIST, (m: MatterItem, i: number) => {
Row({ space: 12 }) {
Text(`${i + 1}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor(rankColor(i))
.width(34)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(m.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(m.dept).fontSize(10).fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(m.hot).fontSize(10).fontColor(COLORS.main)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (m: MatterItem) => m.name)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
标题行左侧是"热门事项·办理量排行",右侧标注"按月办件量"说明数据口径。排行列表通过 ForEach 遍历 MATTER_LIST,每行包含三部分:左侧大编号(20vp 加粗,颜色由 rankColor(i) 函数决定)、中间事项名称和承办部门(layoutWeight(1) 占据剩余空间)、右侧办理量(10vp 主色)。
编号使用了模板字符串 ${i + 1} 将索引转换为 1-based 的序号。前三名的编号颜色分别是主色青、辅色绿、紫色,从第四名起统一灰色,形成了清晰的视觉层级。事项名称和部门都做了单行省略处理,确保长名称不会破坏卡片布局。
十二、实名 Tab:Vision Kit 中心大卡与证件列表
实名核验是这个政务应用的"技术高光"所在。它集成了华为 Vision Kit 的 CardRecognition 卡证识别控件,支持身份证、港澳通行证、台湾通行证三种证件的拍卡即办。tabIdentify() Builder 的布局以一个中心大卡为视觉焦点,下方连接证件列表和核验记录。
12.1 中心大卡:渐变背景与呼吸图标
@Builder
tabIdentify() {
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港澳台居民持通行证即可关联 312 项跨省通办事项。')
.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]]
})
中心大卡是一个 Column,从上到下依次是:护照 Emoji 图标(40vp,绑定 breath 透明度动画)、标题"实名核验·拍卡即办"、两行说明文本、底部"选择证件开始核验↓"引导标签。
这张卡片最精彩的设计是 linearGradient 线性渐变背景——角度 160 度,从 0% 位置的 mainD 色(#1B8D82 深青)渐变到 60% 位置的 dark 色(#0E1B16 极深墨绿)。这种渐变营造了一种"聚光灯"效果,卡片上部偏亮(暗示这里是交互焦点),下部融入深色背景,视觉引导用户向下方的证件列表操作。breath 动画让顶部图标每秒闪烁,进一步增强了"此处可交互"的暗示。
12.2 证件列表
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)
证件列表通过 ForEach 遍历 DOC_LIST,每行包含:左侧圆形 Emoji 图标(40x40vp,chip 底色)、中间证件名称+说明、右侧"核验"按钮。港澳通行证和台湾通行证因为 isNew 为 true,名称旁边会显示一个叶绿色的"NEW"角标(8vp 字号,sec 底色,dark 文字)。
每行的 onClick 事件是整个实名核验的触发点——将 scanIdx 设为当前证件的索引 i,将 scanning 设为 true。这两个赋值会立即触发 build() 函数重新执行,进入 this.scanView() 分支,启动 Vision Kit 的全屏卡证识别流程。
12.3 核验记录展示
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 为空数组时,显示一条居中的引导文本"暂无核验记录,点击上方证件类型开始拍卡核验";当有记录时,通过 ForEach 渲染每条记录。每条记录卡片包含证件名称(12vp 主色加粗)、时间(9vp 灰色)和原始识别数据(9vp 副色,最多 4 行省略)。ForEach 的键值使用 ${r.time}-${i} 组合键,确保即使时间相同的记录也能正确区分。
12.4 卡证识别流程图
这个流程图完整描述了从用户点击证件类型到识别结果展示的全过程。Vision Kit 的 CardRecognition 控件在识别期间全屏独占,不允许任何元素遮挡——这是华为官方文档的明确要求。识别回调中通过 params.code 判断是否成功(200 表示成功),成功后从 params.cardInfo 的 front、back、main 三个属性中提取卡面信息,拼接成 JSON 字符串存入记录。
十三、预约 Tab:双列网点卡与预约管理
预约 Tab 是整个应用中交互最丰富的模块——它不仅展示网点信息和预约列表,还集成了新增、修改、删除三个模态弹窗,形成完整的 CRUD(增删改查)能力。
13.1 网点双列卡片
@Builder
tabBooking() {
Column({ space: 12 }) {
Row() {
Text('📅 网点预约').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('+ 新增预约').fontSize(12).fontColor(COLORS.main)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.chip).borderRadius(12)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
Grid() {
ForEach(SITE_LIST, (s: SiteItem) => {
GridItem() {
Column({ space: 6 }) {
Text(s.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(s.addr).fontSize(9).fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(s.wait).fontSize(10).fontColor(COLORS.sec)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
.onClick(() => {
this.addModal = true;
})
}
}, (s: SiteItem) => s.name)
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.width('100%')
.height(220)
网点区域使用 2 列 2 行的 Grid 布局,固定高度 220vp,四个网点各占一格。每张卡片包含网点名称(13vp 加粗)、地址(9vp)和候办人数(10vp 叶绿)。点击任意网点卡片都会打开新增预约弹窗,引导用户针对该网点创建预约。标题栏右侧的"+新增预约"按钮同样打开新增弹窗,提供了统一的入口。
13.2 我的预约列表
Text('我的预约').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.width('100%')
ForEach(this.bookingList, (b: BookingItem, i: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(b.matter).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${b.site} · ${b.date}`).fontSize(10).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(b.status).fontSize(10).fontColor(caseColor(b.status))
Text('改').fontSize(9).fontColor(COLORS.purple)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => {
this.editIdx = i;
this.editModal = true;
})
Text('删').fontSize(9).fontColor(COLORS.red)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => {
this.delIdx = i;
this.delModal = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (b: BookingItem) => `${b.date}-${b.matter}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
预约列表通过 ForEach 遍历 this.bookingList(注意是 @State 变量而非静态常量),每行包含:左侧事项名称+网点时间、状态标签(颜色由 caseColor 函数决定)、"改"和"删"两个操作按钮。"改"按钮用紫色文字,点击后设置 editIdx 和 editModal 打开修改弹窗;"删"按钮用红色文字,点击后设置 delIdx 和 delModal 打开删除确认弹窗。ForEach 的键值使用 ${b.date}-${b.matter} 组合键,确保每条预约的唯一性。
十四、进度 Tab:固定高度时间轴
进度 Tab 使用时间轴(Timeline)布局展示办件进度,每一步固定 76vp 高度,形成等距的视觉节奏。
14.1 时间轴标题与步骤渲染
@Builder
tabProgress() {
Column() {
Row() {
Text('📈 办件进度').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('公积金提取').fontSize(10).fontColor(COLORS.sub)
}
.width('100%')
.margin({ bottom: 10 })
ForEach(STEP_LIST, (s: StepItem, i: number) => {
Row({ space: 8 }) {
Column() {
Text(s.time.substring(0, 5)).fontSize(9).fontColor(COLORS.text3)
Circle().width(8).height(8).fill(caseColor(s.status)).margin({ top: 3 })
if (i < STEP_LIST.length - 1) {
Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 3 })
}
}
.width(44)
.height(76)
.alignItems(HorizontalAlign.Center)
Column({ space: 5 }) {
Row() {
Text(s.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1)
Text(s.status).fontSize(10).fontColor(caseColor(s.status))
}
.width('100%')
Text(s.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%')
}, (s: StepItem) => `${s.time}-${s.title}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
时间轴的每一步是一个 Row,左侧是 44vp 宽的时间轴轨道列,右侧是内容卡片。轨道列从上到下是:时间标签(取 s.time 的前 5 个字符,如"08-24")、状态圆点(8vp,颜色由 caseColor 决定)、连接线(2vp 宽,line 色,通过 layoutWeight(1) 填充剩余高度)。最后一步不渲染连接线(if (i < STEP_LIST.length - 1) 条件判断),形成时间轴的"终点"效果。
内容卡片固定高度 64vp,包含标题+状态行和备注。状态文字颜色与圆点颜色一致,通过 caseColor(s.status) 统一着色。maxLines(1) 确保备注单行显示,避免不同步骤的卡片高度不一,破坏时间轴的等距节奏。整个时间轴通过固定高度(轨道 76vp、卡片 64vp)实现了"等距节点"的视觉效果,这是进度展示的最佳实践。
十五、证照 Tab:横滑证照大卡
证照 Tab 展示用户的电子证照,采用横向滚动的大卡片布局,每张卡片宽度 150vp,模拟实体证照的视觉形态。
15.1 证照卡片横滑
@Builder
tabLicense() {
Column({ space: 12 }) {
Row() {
Text('🎫 我的证照').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('扫码亮证 · 与实体同效').fontSize(10).fontColor(COLORS.sub)
}
.width('100%')
Scroll() {
Row({ space: 12 }) {
ForEach(LICENSE_LIST, (l: LicenseItem) => {
Column({ space: 8 }) {
Text(l.icon).fontSize(34)
Text(l.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(l.no).fontSize(10).fontColor(COLORS.sub)
Text(`有效期 ${l.valid}`).fontSize(9).fontColor(COLORS.text3)
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 })
}
.width(150)
.alignItems(HorizontalAlign.Start)
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(14)
}, (l: LicenseItem) => l.no)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
证照卡片内部从上到下依次是:大 Emoji 图标(34vp)、证照名称(14vp 加粗)、脱敏证号(10vp)、有效期(9vp)和"亮证"按钮(主色底、深色字)。横滑通过 Scroll + Row 实现,方向为 Horizontal,滚动条隐藏。ForEach 的键值使用 l.no(证号),因为证号是唯一的。标题栏右侧的"扫码亮证·与实体同效"说明文字,强化了电子证照的法律效力等同实体证照的政务理念。
十六、消息 Tab:清单行布局
消息 Tab 是最简洁的一个 Tab,使用标准的图标+标题+时间的行式布局展示通知列表。
16.1 消息列表渲染
@Builder
tabMsg() {
Column({ space: 12 }) {
Row() {
Text('📮 消息通知').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('全部已读').fontSize(11).fontColor(COLORS.main)
}
.width('100%')
ForEach(MSG_LIST, (m: MsgItem) => {
Row({ space: 12 }) {
Text(m.icon).fontSize(18)
.width(36).height(36).textAlign(TextAlign.Center)
.backgroundColor(COLORS.chip).borderRadius(18)
Column({ space: 3 }) {
Text(m.title).fontSize(11).fontColor(COLORS.title)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(m.time).fontSize(9).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(12)
}, (m: MsgItem) => m.title)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
每条消息包含:左侧圆形 Emoji 图标(36x36vp,chip 底色)、右侧标题(11vp,最多 2 行省略)和时间(9vp 灰色)。标题行右侧有"全部已读"操作按钮,用主色文字标识。消息标题允许 2 行显示(maxLines(2)),比其他 Tab 的单行限制更宽松,因为消息内容通常较长,需要展示更多文字。ForEach 的键值使用 m.title,假设每条消息的标题是唯一的。
十七、月度办件量图表:纯 ArkUI 柱状图
这个应用没有使用任何第三方图表库,而是完全用 ArkUI 的 Column 组件手工绘制了一个柱状图。这是一个非常值得学习的"轻量级数据可视化"方案。
17.1 图表标题与图例
@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%')
Row({ space: 10 }) {
ForEach(MONTH_NAME, (m: string, i: number) => {
Column({ space: 6 }) {
Column()
.width(26)
.height(28 + CASE_VAL[i] / 8 * 76 + (this.breath ? 3 : 0))
.backgroundColor(CASE_VAL[i] === 8 ? 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%')
图表的核心是一个 Row 包含六个 Column(通过 ForEach 遍历 MONTH_NAME),每个 Column 代表一个月。每个柱形由一个内层 Column 实现——宽度固定 26vp,高度通过公式 28 + CASE_VAL[i] / 8 * 76 + (this.breath ? 3 : 0) 计算。
这个高度公式值得仔细拆解。基础高度 28vp 是最低高度,确保即使办件量为 0 也有可见的柱形。CASE_VAL[i] / 8 * 76 是动态高度部分——将办件量除以 8(最大值),得到 0-1 的比例,乘以 76vp 得到实际增量。以 7 月的 8 千件为例:28 + 8/8*76 = 28 + 76 = 104vp,是最高的柱形。以 4 月的 3 千件为例:28 + 3/8*76 = 28 + 28.5 = 56.5vp。最后,this.breath ? 3 : 0 在呼吸状态时额外增加 3vp 高度,配合 opacity 变化,形成柱形的"呼吸跳动"效果。
柱形颜色根据办件量决定:如果等于 8(峰值),用辅色 sec(叶绿,代表"办件高峰"),否则用 mainD(深青,代表"常规月")。borderRadius 只设置顶部圆角,模拟柱状图的视觉特征。每个柱形下方有月份标签(9vp 灰色),通过 layoutWeight(1) 等分宽度。
17.2 图表图例
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 })
}
图表底部是图例,居中显示。两个图例项各包含一个 10x10vp 的色块和说明文字——叶绿色块代表"办件高峰",深青色块代表"常规月"。图例使用 Row 包裹,通过 justifyContent(FlexAlign.Center) 居中,两项之间用 margin({ left: 16 }) 隔开。整个图表卡片用 card 背景色、12vp 圆角和 14vp 内边距包裹,并在顶部留 12vp 外边距,与其他 Tab 内容产生间距。
十八、底部 Tab 栏:两行 4+3 布局
底部 Tab 栏采用两行布局——第一行四个 Tab,第二行三个 Tab,通过 tabItem 和 tabBar 两个 Builder 函数协作实现。
18.1 单个 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 接收两个参数:TabMeta(图标和标签)和 idx(索引)。通过比较 idx 和 this.currentTab 判断当前 Tab 是否激活——激活时图标透明度为 1(完全不透明)、标签为主色加粗;未激活时图标透明度为 0.5、标签为灰色常规字重。点击事件设置 currentTab 为对应索引,触发整个页面的条件渲染切换。每个 Tab 项通过 layoutWeight(1) 等分宽度,上下内边距 7vp。
18.2 Tab 栏组装
@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 用 Column 包裹两个 Row——第一行遍历 TAB_ROW1 传入索引 0-3,第二行遍历 TAB_ROW2 传入索引 4-6(i + 4)。两行之间 2vp 间距,整体上下内边距 4/6vp,背景色为 card。这种 4+3 两行布局是本应用的一个独特设计——既避免了单行 7 个 Tab 的拥挤感,又通过两行分组暗示了功能优先级的差异。
18.3 Tab 切换流程图
这张流程图展示了 Tab 切换的完整渲染链路。从用户点击到界面更新,核心经历四个步骤:tabItem 的 onClick 设置 currentTab、build 函数重新执行、条件渲染选择对应的 Tab Builder、chartCard 作为全局组件始终渲染。整个过程由 ArkUI 的响应式系统驱动,开发者只需修改状态变量,UI 自动更新。
十九、Vision Kit 卡证识别控件
scanView() Builder 是整个应用技术含量最高的部分。它直接使用了华为 Vision Kit 的 CardRecognition 控件,实现了系统级的卡证识别。
19.1 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.CARD_ID,选择港澳通行证就传 CardType.CARD_MAINLAND_TRAVEL_PERMIT_HK_MO,选择台湾通行证就传 CardType.CARD_MAINLAND_TRAVEL_PERMIT_TW。
cardRecognitionConfig 是配置对象,包含两个属性。defaultShootingMode: ShootingMode.MANUAL 设置默认拍摄模式为手动,即用户需要手动点击拍摄按钮(而非自动识别)。isPhotoSelectionSupported: true 允许用户从相册选择照片进行识别,而不仅限于实时拍摄。这两个配置共同提供了最灵活的识别体验——用户可以实时拍卡,也可以上传已有的证件照片。
19.2 识别结果回调处理
onResult 是识别完成的回调函数,参数 params 是 CardRecognitionResult 类型。回调逻辑分三步:
第一步,状态码检查。params.code !== 200 表示识别失败(如用户取消、识别超时等),直接关闭扫描视图(scanning = false),不添加任何记录。
第二步,数据提取。params.cardInfo 可能包含 front(正面信息)、back(背面信息)和 main(主面信息)三个属性,具体哪些有值取决于证件类型——身份证有正反面,通行证可能只有主面。代码通过可选链 ?. 和 !== undefined 检查每个属性是否存在,将存在的属性 JSON.stringify 序列化后推入 parts 数组。
第三步,记录构建。用 parts.join('\n') 将各面信息用换行符连接成原始数据字符串,创建一个新的 ScanRecord 对象——时间设为"刚刚"(因为是即时记录),证件名称从 DOC_LIST[this.scanIdx] 取值,原始数据为拼接后的 JSON。将新记录 push 到 scanRecords 数组后,关闭扫描视图。
这个回调的精妙之处在于:它将 Vision Kit 返回的结构化 cardInfo 对象序列化为 JSON 字符串存储,保留了完整的原始数据,方便后续解析和展示。同时通过 DOC_LIST[this.scanIdx] 复用了证件名称,避免了在回调中重新判断证件类型的复杂逻辑。
19.3 Vision Kit 支持的卡证类型
import { CardRecognition, CardRecognitionResult, CardType, ShootingMode } from '@kit.VisionKit';
从 @kit.VisionKit 导入了四个符号:CardRecognition 是控件本身,CardRecognitionResult 是识别结果类型,CardType 是证件类型枚举,ShootingMode 是拍摄模式枚举。代码注释特别提到"6.1.1 新增通行证",说明港澳通行证和台湾通行证的识别能力是 HarmonyOS 6.1.1 版本才新增的,这体现了应用紧跟系统版本迭代的技术策略。
二十、模态弹窗体系:遮罩层与三种弹窗
应用实现了三个模态弹窗——新增预约、修改预约状态和取消预约确认,通过一个共享的遮罩层 modalOverlay 和三个独立的 panel Builder 实现。
20.1 遮罩层 Builder
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => onClose())
}
modalOverlay 是一个全屏的半透明遮罩层,背景色为 mask(rgba(3,8,6,0.66))。点击遮罩层会调用 onClose 回调关闭弹窗,这是模态弹窗的标准交互——点击遮罩区域等同于取消操作。这个 Builder 被三个弹窗复用,体现了代码的 DRY(Don’t Repeat Yourself)原则。
20.2 新增预约弹窗
@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)
Column({ space: 6 }) {
Text('网点与时间').fontSize(11).fontColor(COLORS.sub)
TextInput({ placeholder: '如:市民中心 · 周三 10:00' })
.fontSize(13)
.fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.chip)
.borderRadius(10)
.onChange((v: string) => {
this.addNote = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
新增预约弹窗使用 Stack 叠加遮罩层和内容面板。内容面板从上到下包含:标题、办理事项输入框、网点与时间输入框、时段快捷标签和操作按钮。两个 TextInput 分别绑定 addTitle 和 addNote 状态变量,通过 onChange 回调实时更新。输入框使用 chip 背景色和 10vp 圆角,占位符颜色为 text3 灰色,与整体深色主题协调。
20.3 时段标签与保存逻辑
Row({ space: 12 }) {
ForEach(BOOK_SLOT, (s: string) => {
Text(s).fontSize(11).fontColor(COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.chip)
.borderRadius(12)
}, (s: string) => s)
}
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 matter: string = this.addTitle === '' ? '待选事项' : this.addTitle;
const site: string = this.addNote === '' ? '待选网点' : this.addNote;
this.bookingList.push(new BookingItem(matter, site, '待定', '待确认'));
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)
}
时段标签通过 ForEach 遍历 BOOK_SLOT(“工作日"和"周六”)生成两个快捷选择标签。操作按钮区有"取消"和"保存"两个按钮,各占 layoutWeight(1) 等宽。保存逻辑首先检查输入是否为空——为空则使用"待选事项"和"待选网点"作为默认值,创建一个新的 BookingItem(状态"待确认"),push 到 bookingList。然后清空表单变量并关闭弹窗。这种"空值兜底"设计确保即使用户不输入任何内容也能创建一条占位预约,不会出现空记录。
弹窗通过 Stack 的 alignContent(Alignment.Bottom) 定位在屏幕底部,内容面板只有顶部圆角(topLeft: 18, topRight: 18),形成一种从底部滑出的"底部面板"视觉效果。
20.4 修改状态弹窗
@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.bookingList[this.editIdx].matter : '')
.fontSize(12)
.fontColor(COLORS.sub)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
ForEach(['已预约', '待确认', '已完成'], (s: string) => {
Text(s).fontSize(12).fontColor(caseColor(s))
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.chip)
.borderRadius(14)
.onClick(() => {
if (this.editIdx >= 0) {
const b = this.bookingList[this.editIdx];
this.bookingList[this.editIdx] = new BookingItem(b.matter, b.site, b.date, s);
}
this.editModal = false;
})
}, (s: string) => s)
}
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)
}
修改弹窗展示当前预约的事项名称(通过 editIdx 索引从 bookingList 取值),下方三个状态选项标签。每个标签的颜色由 caseColor 函数决定——"已预约"为青色、"待确认"为灰色、"已完成"为绿色。点击某个状态后,代码先取出当前预约的 b,用相同的事项、网点、时间和新状态 s 创建一个新的 BookingItem 替换原数组项,然后关闭弹窗。这种"用新对象替换旧对象"的更新方式是 ArkUI 中触发 @Observed 数据变化的标准做法——直接修改对象属性可能不会触发渲染更新,而创建新对象可以确保框架检测到变化。
20.5 删除确认弹窗
@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.bookingList[this.delIdx].matter}」的预约吗?` : '')
.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.bookingList.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)
}
删除弹窗与前两个弹窗的布局不同——它不是底部面板,而是居中对话框(alignContent(Alignment.Center),宽度 72%)。标题用红色和警告 Emoji,提示文案包含被删除的事项名称。两个按钮分别是"再想想"(取消)和"确认取消"(删除),删除操作调用 this.bookingList.splice(this.delIdx, 1) 从数组中移除对应索引的记录。
这种"底部面板用于表单输入 + 居中对话框用于确认操作"的弹窗策略,是一种优秀的 UX 设计模式——表单输入需要键盘空间所以从底部弹出,确认操作需要视觉聚焦所以居中显示。
二十一、状态管理与响应式渲染深度剖析
ArkUI 的状态管理是其声明式 UI 的核心引擎。本应用通过 @State、@Observed 和 @Builder 三层协作,构建了一个完整的响应式渲染体系。
21.1 @State 的响应式机制
@State 装饰的变量在发生变化时,会自动触发所在组件的 build() 函数重新执行。本应用有十余个 @State 变量,每个变量的变化都会引发不同范围的 UI 重建。
以 currentTab 为例,当用户点击底部 Tab 时,this.currentTab = idx 的赋值会触发 build() 重新执行,条件渲染分支根据新值选择对应的 Tab Builder 渲染。这种"状态驱动渲染"的模式使开发者无需手动操作 DOM 或调用 setState,只需修改变量值,框架自动处理 UI 更新。
breath 变量则展示了另一种响应式场景——定时器每秒翻转 breath 值,每次翻转都会触发所有使用了 this.breath 的 Builder 重新渲染,包括头部数据条的透明度、实名大卡的透明度和图表柱形的高度/透明度。这种"单一状态驱动多处 UI 变化"的能力是响应式系统的核心优势。
21.2 @Observed 与数组操作
@Observed 装饰的类实例在作为 @State 变量使用时,其属性变化也能被框架追踪。本应用的 bookingList 是 @State BookingItem[] 类型,而 BookingItem 是 @Observed 类,这意味着数组元素的属性变化和数组本身的变化都能触发 UI 更新。
在修改弹窗中,代码使用了"创建新对象替换旧对象"的策略而非直接修改属性:
const b = this.bookingList[this.editIdx];
this.bookingList[this.editIdx] = new BookingItem(b.matter, b.site, b.date, s);
这种模式确保数组引用发生变化,ArkUI 能可靠地检测到变化并触发 ForEach 重新渲染。如果直接修改 b.status = s,由于 BookingItem 的属性可能没有被 @Trace 装饰(ArkUI 较新版本的深度可观察能力),UI 可能不会更新。使用新对象替换是最安全、最兼容的更新方式。
21.3 @Builder 的函数式拆分
@Builder 装饰的函数是 ArkUI 的 UI 片段复用机制。本应用将 UI 拆分为十余个 Builder 函数,每个函数负责一个独立的 UI 区块。这种拆分带来三个好处:
第一,代码组织清晰。build() 函数只需调用 Builder 函数名,无需关心实现细节,使得整体结构一目了然。第二,复用性强。tabItem Builder 被七个 Tab 项复用,modalOverlay 被三个弹窗复用。第三,性能优化。Builder 函数是按需执行的——只有被条件渲染分支选中的 Builder 才会执行,未选中的不会浪费计算资源。
需要注意的是,@Builder 函数接收参数时,参数传递是值传递而非引用传递。例如 panelAdd(onClose: () => void) 接收一个闭包作为关闭回调,每次调用时传入的闭包都捕获了正确的 this 上下文,确保关闭操作能正确修改状态变量。
二十二、动画系统:呼吸效果与展开过渡
本应用的动画系统虽然不复杂,但设计精巧,在关键位置营造了良好的"活力感"。
22.1 呼吸动画的跨区域复用
呼吸动画通过一个 breath 布尔状态变量驱动,每秒翻转一次。这个简单的布尔量在三处产生了不同的视觉效果:
头部数据条的"32"数字:透明度在 0.72 和 1.0 之间切换,模拟数字"心跳"的闪烁效果,暗示数据实时更新。
实名中心大卡的护照图标:透明度在 0.75 和 1.0 之间切换,比头部稍暗的最低值,营造更柔和的呼吸感,引导用户关注核验入口。
月度图表的柱形:高度在基础值和基础值+3 之间切换,同时透明度在 0.82 和 1.0 之间切换,形成柱形的"跳动"效果,让图表看起来不是静态的图片而是"活"的数据可视化。
这三处动画虽然效果各异,但都由同一个 breath 变量驱动,只需一个定时器就能同时控制多处动画,体现了状态管理的简洁性和高效性。
22.2 宫格展开动画
.height(this.gridExpand ? 150 : 75)
.animation({ duration: 220, curve: Curve.EaseInOut })
头部快捷宫格的展开/收起动画通过 .animation() 修饰器实现。当 gridExpand 变化时,height 从 75vp 变为 150vp(或反向),animation 指定了 220 毫秒的过渡时长和 EaseInOut 缓动曲线。EaseInOut 曲线在动画开始和结束阶段减速、中间阶段加速,营造出自然的"弹性"感,比线性动画更符合人眼的舒适预期。
这种"状态绑定属性 + animation 修饰器"的模式是 ArkUI 声明式动画的核心范式——开发者只需声明属性的终态值和动画参数,框架自动计算和播放过渡帧,无需手动编写动画逻辑。
二十三、七大 Tab 布局对比分析
本应用最大的亮点在于七个 Tab 采用了七种完全不同的布局策略。下面通过一个对比表格全面分析它们的差异。
| Tab 名称 | 布局类型 | 核心组件 | 数据模型 | 布局特点 | 交互亮点 | 解决的问题 |
|---|---|---|---|---|---|---|
| 首页 | 横滑 Banner + 卡片 | Scroll+Row+Column | BannerItem | 横向滚动 220vp 卡片 | 点击"预约"跳转预约 Tab | 首屏信息密度与视觉吸引力的平衡 |
| 办事 | 大编号行式排行 | ForEach+Row | MatterItem | 大编号+事项信息+办理量 | 前三名彩色编号 | 热门事项的快速发现与排序展示 |
| 实名 | 中心大卡+列表+记录 | Column+linearGradient+ForEach | DocItem+ScanRecord | 渐变大卡为焦点,下方列表 | Vision Kit 全屏卡证识别 | 实名核验的视觉引导与技术集成 |
| 预约 | 双列网格+列表+弹窗 | Grid+ForEach+Stack | SiteItem+BookingItem | 2x2 网点卡+预约行+模态 | 增删改完整 CRUD | 网点选择与预约管理的闭环 |
| 进度 | 固定高度时间轴 | ForEach+Row+Column | StepItem | 等距节点+连接线+状态卡 | 状态色彩区分 | 办件进度的可视化追踪 |
| 证照 | 横滑大卡 | Scroll+Row+Column | LicenseItem | 150vp 证照卡横滑 | "亮证"行动入口 | 电子证照的实体模拟展示 |
| 消息 | 清单行 | ForEach+Row | MsgItem | 图标+标题+时间行 | "全部已读"操作 | 消息通知的快速浏览 |
从对比表格可以看出,七种布局各有侧重:首页和证照使用横滑,适合展示有限数量的卡片型内容;办事和消息使用行式列表,适合展示大量同构数据;实名使用中心大卡+列表,突出核心入口同时展示辅助信息;预约使用网格+列表+弹窗的组合,支持完整的交互操作;进度使用时间轴,最适合线性流程的进度展示。
这些布局的选择不是随意的,而是基于每个业务场景的内容特征和交互需求精心设计的。办事排行需要序号对比,所以用大编号行式;网点信息是等价的平行选项,所以用网格;进度是有序的线性流程,所以用时间轴;证照需要展示完整信息,所以用大卡片横滑。这种"内容决定布局"的设计理念值得每一位开发者学习。
24.2 七大 Tab 布局策略思维导图
二十五、工程经验与最佳实践总结
通过对这个政务服务平台应用的逐段剖析,我们可以提炼出若干可复用的工程经验和最佳实践。
25.1 颜色系统集中管理
将所有颜色定义在一个 ColorPalette 接口和 COLORS 常量中,是提升代码可维护性的关键策略。这种做法使主题切换、颜色调整和代码审查都变得极为高效。在实际项目中,还可以进一步将 COLORS 拆分为多套主题(如"深墨绿政务风"和"明亮办公风"),通过运行时切换实现主题动态化。
25.2 数据模型与 UI 解耦
九个 @Observed 数据类与静态数据集的分离,使数据层和视图层保持了清晰的边界。在实际项目中,只需将静态数据集替换为 API 请求返回的动态数据,UI 无需任何修改即可适配。这种解耦是大型应用可维护性的基石。
25.3 索引映射简化分支逻辑
SCAN_TYPES 与 DOC_LIST 的索引映射是一个简洁但高效的设计模式。当多个数组需要按索引一一对应时,用同一个索引访问不同数组,避免了复杂的条件判断。这种模式在需要将 UI 配置与系统能力关联时特别有用。
25.4 呼吸动画的单一状态驱动
用一个 breath 布尔变量驱动多处动画,是最经济的动画方案。在实际项目中,可以扩展为多个呼吸变量(如 breathFast 和 breathSlow),分别以不同频率翻转,为不同区域设置差异化的动画节奏。
25.5 纯 ArkUI 轻量图表
月度办件量图表完全用 Column 组件手工绘制,无需引入任何图表库。对于简单的柱状图、进度条等可视化需求,这种"轻量级"方案比引入第三方库更高效——包体积更小、渲染性能更好、定制自由度更高。只有当需要复杂的交互图表(如缩放、拖拽、 Tooltip)时,才值得引入专业图表库。
25.6 弹窗策略的分层设计
"底部面板用于表单输入 + 居中对话框用于确认操作"的弹窗策略,是一种值得推广的 UX 设计模式。表单弹窗从底部弹出,不遮挡上方已有内容,且靠近键盘位置便于输入;确认弹窗居中显示,制造视觉焦点,防止误操作。这种分层策略使每个弹窗都能在最合适的空间位置呈现。
25.7 条件渲染与全局共享的平衡
chartCard 作为全局共享组件,不受 Tab 切换影响始终渲染,这种设计保证了关键数据始终可见。在实际项目中,需要权衡"Tab 专属内容"和"全局共享内容"的划分——过于激进的全局共享会导致每个 Tab 信息过载,过于保守则会导致跨 Tab 数据不可见。本应用将月度办件量图表设为全局共享,是一个合理的平衡点。
25.8 文本溢出的统一处理
应用中大量使用了 maxLines(1) 或 maxLines(2) 配合 textOverflow({ overflow: TextOverflow.Ellipsis }) 的文本溢出处理。这种处理确保长文本以省略号截断,不会破坏布局。在政务场景中,事项名称、部门名称、地址等文本长度不可控,统一的溢出处理是防止布局错乱的必备手段。
二十六、Vision Kit 集成的深度思考
Vision Kit 的 CardRecognition 控件是这个应用最具技术深度的部分。它的集成不仅仅是调用一个 API,更涉及用户体验、错误处理和数据管理的多方面考量。
26.1 全屏独占的必要性
华为官方文档明确要求 CardRecognition 在识别期间全屏独占,不允许任何元素遮挡。这是因为卡证识别依赖摄像头实时画面,任何 UI 遮挡都可能影响识别准确率。应用通过 if (this.scanning && this.scanIdx >= 0) 的条件分支,在识别期间完全切换到 scanView(),排除了所有其他 UI 元素的干扰。
26.2 多证件类型的动态切换
通过 SCAN_TYPES 数组的索引映射,应用支持三种证件类型的动态切换——用户选择不同的证件,传入不同的 CardType 给 CardRecognition。这种设计的扩展性极好——未来如果 Vision Kit 新增更多卡证类型(如驾照、护照),只需在 SCAN_TYPES 和 DOC_LIST 中新增对应项即可,无需修改识别逻辑。
26.3 识别结果的原始数据保存
回调中将 cardInfo 的 front、back、main 分别 JSON.stringify 后拼接保存,保留了完整的原始识别数据。这种设计在 MVP 阶段是合理的——先保存原始数据,后续可以根据需要解析具体字段(如姓名、身份证号、地址等)。在实际生产环境中,可以进一步将 JSON 解析为结构化的证件信息对象,提供更友好的展示和验证能力。
26.4 错误处理的优雅降级
当 params.code !== 200 时,代码直接关闭扫描视图,不添加任何记录。这种"静默失败"的策略在 MVP 阶段可以接受,但在生产环境中应该增加用户提示(如 Toast 消息"识别失败,请重试"),避免用户困惑。同时,可以记录失败日志用于问题排查。
二十七、从 MVP 到生产环境的演进建议
本应用作为一个展示性原型已经相当完整,但要演进为生产级政务应用,还需要在以下几个方面增强。
27.1 数据层的后端对接
当前所有数据都是静态常量,生产环境需要替换为后端 API。建议创建一个 DataService 封装所有数据请求,保持数据模型不变,只将数据来源从常量切换为异步请求。aboutToAppear 中可以触发数据加载,配合 @State isLoading 展示加载态。
27.2 用户认证与权限管理
政务应用必须有严格的用户认证。建议集成华为账号登录或政务身份认证,在应用启动时检查登录态,未登录时跳转登录页。不同用户角色(普通市民、窗口工作人员、管理员)应看到不同的功能模块。
27.3 离线缓存与数据同步
政务服务场景中,用户可能在信号不好的政务大厅使用应用。建议引入本地数据库缓存关键数据(如预约列表、证照信息),支持离线查看。网络恢复后自动同步数据变更。
27.4 无障碍适配
政务应用需要服务所有市民,包括视障、听障等特殊群体。建议为所有交互元素添加 accessibilityText、accessibilityDescription 等无障碍属性,确保读屏软件能正确朗读内容。
27.5 国际化支持
港澳台居民是这个应用的重要用户群体,建议支持简体中文、繁体中文和英文三种语言。使用 ArkUI 的 $r() 资源引用机制,将所有文案抽取为资源文件,实现多语言切换。
二十八、完整代码结构与执行流程回顾
最后,让我们用一张流程图回顾整个应用的代码执行流程,从应用启动到各功能模块的调用关系。
这张流程图涵盖了应用从启动到渲染、从 Tab 切换到弹窗弹出、从识别启动到结果回调的完整生命周期。每一个箭头都代表一次状态变化驱动的渲染流程,体现了 ArkUI 响应式系统的自闭环特性——状态变化触发渲染,渲染中的用户交互又触发新的状态变化,形成生生不息的交互循环。
二十九、结语:政务服务的 ArkUI 实践之道
通过对"智办通·政务服务平台"的全面技术拆解,我们看到了 ArkUI 在构建复杂行业应用时的强大能力。从颜色系统的语义化管理到九大数据模型的 @Observed 追踪,从十余个 @Builder 函数的模块化拆分到 Vision Kit 卡证识别的系统级集成,从纯 ArkUI 轻量图表到三种模态弹窗的分层策略,这个应用展示了一个完整的政务服务平台技术蓝图。
核心收获可以归纳为以下几点:
第一,布局设计应该由内容驱动。七个 Tab 采用七种布局,不是追求花哨,而是每种业务场景的内容特征和交互需求不同。排行需要序号,时间轴需要等距,证照需要大卡——布局服务于内容,而非内容迁就布局。
第二,状态管理是响应式 UI 的灵魂。一个 currentTab 驱动七个 Tab 切换,一个 breath 驱动三处动画,一个 scanning 切换全屏识别视图——精简的状态变量配合条件渲染,可以表达复杂的 UI 逻辑。
第三,系统能力的集成提升应用价值。Vision Kit 的 CardRecognition 不是简单的 API 调用,而是将系统级卡证识别能力嵌入政务服务流程,真正实现了"拍卡即办"的便民体验。
第四,轻量级方案往往是最优解。纯 ArkUI 绘制的柱状图、手工构建的时间轴、自定义的模态弹窗——这些不依赖第三方库的方案在性能、可控性和维护性上都有显著优势。
政务服务数字化转型是一条长期之路,而 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 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 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.1 | Release | ✅ 已安装 |
界面顶部提示:“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 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐



所有评论(0)