一、前言:鸿蒙生态下家政维修的地图化升级

在这里插入图片描述

HarmonyOS NEXT 推出的 ArkUI 声明式开发框架,正在重塑移动端应用的开发范式。它以 @Component 装饰器声明组件、以 @State 驱动数据渲染、以 @Builder 拆分视图函数,让一个 .ets 文件就能承载完整的页面逻辑。对于家政维修这类强位置依赖、强师傅匹配、强上门时效的业务,ArkUI 提供的响应式数据流配合声明式布局,天然适合表达"师傅列表—地图选点—搜索结果—个人中心"这种四 Tab 切换的复杂交互。

在这里插入图片描述

Map Kit 则是鸿蒙生态中负责地理信息展示与交互的核心能力。它通过 MapComponent 组件内嵌地图画布,通过 mapCommon.MapOptions 配置初始化中心点和缩放,通过 map.MapComponentController 控制后续的 Marker 增删、相机移动等操作,再通过 map.MapEventManager 统一管理地图上的交互事件。对于一个"师傅服务站散布在城市各处"的家政维修应用,Map Kit 不只是展示地图,更是用户触达师傅资源的入口。

在这里插入图片描述
HarmonyOS 6.1.1 给 site 模块的 searchByText 能力带来了关键升级:返回的 Site 类型新增了 reliability 字段,取值区间为 [0,1],其中 1 表示与搜索关键字完全相关。这意味着开发者第一次可以在搜索结果层面,量化的判断"这个维修点到底和用户输入的关键字有多匹配"。在家政维修场景下,用户输入"家电维修"可能命中"电器卖场"“家政保洁”"五金建材门市"等近义但并非真正上门维修的点,reliability 让我们能自动过滤、分级、排序,把真正相关的师傅排在前面。

在这里插入图片描述
同样在 HarmonyOS 6.1.1 中,MapEventManager 新增了两对长按事件监听:onMarkerLongClick / offMarkerLongClick 监听地图上的自定义 Marker 长按,onPoiLongClick / offPoiLongClick 监听地图上内置 POI(兴趣点)的长按。长按是一种"意图明确、不易误触"的高级手势,把它绑定到"服务站 Marker"和"POI 地点"上,意味着用户可以在地图上对任意一个有意义的点执行"收藏、报修、记录坐标"等后续动作,而事件本身会通过回调把 map.Marker 或 mapCommon.Poi 对象回传,开发者据此读取 ID、名称、经纬度。

在这里插入图片描述
家政维修上门是一个典型的"低频刚需、强信任、强时效"行业。用户家中水电急修、家电清洗、管道疏通时,最关心的三件事是:附近有没有靠谱师傅、师傅多久能到、修完有没有质保。把 reliability 用于搜索结果分级展示、把长按事件用于地图选点日志记录,恰好分别对应了"找到师傅"和"在地图上锁定师傅位置"两个核心环节。本篇就以一个浅色主题的"修立得·家政维修上门"页面为例,逐段拆解如何把鸿蒙 6.1.1 的两大地图新特性落地到 ArkUI 实际代码中。

在这里插入图片描述

二、整体架构流程

四Tab渲染

搜索特性

长按事件注册

Marker批量下发

地图初始化

入口层

否

是

是

否

aboutToAppear 生命周期

setupMapCallback 初始化地图回调

mapCallback 回调触发

err 是否为空

console.error 打印错误并返回

获取 mapController

mapController.getEventManager 获取事件管理器

遍历 MARKER_SPOTS 6个服务站

构造 MarkerOptions

await addMarker 逐个添加

是否还有服务站

全部添加完成

onMarkerLongClick 监听Marker长按

onPoiLongClick 监听POI长按

读取 marker.getPosition 经纬度

读取 poi.name 与 poi.position

unshift 写入 eventLogs 日志流

用户输入关键字

runSearch 调用

site.searchByText 携带location+radius

遍历 Site 读取 reliability

分数条 + 等级标签展示

底部Tab点击切换 currentTab

师傅Tab:统计宫格+列表

地图Tab:MapComponent+日志流

搜索Tab:reliability分数条

我的Tab:会员卡+功能清单

整体架构围绕两条主线展开:左侧是地图初始化链路,从生命周期 aboutToAppear 出发,依次完成回调注册、控制器获取、事件管理器获取、Marker 批量下发、双长按监听注册,最终把事件流喂给地图 Tab 的日志列表;右侧是搜索链路,从用户输入关键字出发,调用 site.searchByText 拿到 Site 数组,遍历读取 reliability 字段,转化为分数条和等级标签展示在搜索 Tab。两条线通过底部 Tab 切换统一在一个 Stack 容器内渲染,弹窗系统叠加在最上层。

三、颜色系统与主题规范

在这里插入图片描述

3.1 颜色接口定义

/** 主题色板接口:集中声明页面所有颜色字段(浅灰底+工具蓝+信号绿浅色系) */
interface ColorPalette {
  bg: string;       // 页面底色(浅灰)
  card: string;     // 卡片底色
  chip: string;     // 胶囊/浅层底色
  title: string;    // 主标题色
  sub: string;      // 次级文本色
  text3: string;    // 三级弱文本色
  blue: string;     // 工具蓝(主强调色)
  blueD: string;    // 深工具蓝(渐变起点)
  blueL: string;    // 浅工具蓝(高亮文本)
  green: string;    // 信号绿(次强调色)
  yellow: string;   // 适中黄
  red: string;      // 危险红(删除/低相关)
  orange: string;   // 提醒橙(辅助强调)
  line: string;     // 分割线色
  tabOn: string;    // 底部 Tab 选中色
  btnText: string;  // 强调色按钮上的文字色
  mask: string;     // 弹窗遮罩色
  codeBg: string;   // 代码预览卡深底色
}

这段代码定义了一个 ColorPalette 接口,把页面里用到的所有颜色集中声明为字段。这是 ArkTS 推荐的"接口先行"做法:先约定颜色字段的形状,再用一个常量去实现它,这样后续如果要做暗色主题,只需要换一个实现常量,所有引用 COLORS.xxx 的地方自动生效。

接口里的字段按"语义"而非"色值"命名,这是设计系统的核心原则。比如 blue 代表"工具蓝/主强调色",green 代表"信号绿/次强调色",red 代表"危险红/低相关",yellow 代表"适中黄/中相关"。这种命名让 UI 在"高相关用绿、中相关用黄、低相关用红"这类业务规则下,能直接通过语义字段映射,而不必每次去想"这个分数该用哪个色值"。

字段中还区分了 blueD(深工具蓝,渐变起点)和 blueL(浅工具蓝,高亮文本)两个变体,以及 codeBg(代码预览卡深底色)这种特殊用途的色值。mask 用 rgba 带透明度,专门给弹窗遮罩用。这种颗粒度既保证了视觉一致性,又避免了"一个色值到处用、改一处全乱套"的维护灾难。

3.2 浅色主题常量实现

/** 浅色主题色板常量(修立得 · 浅灰 + 工具蓝 + 信号绿) */
const COLORS: ColorPalette = {
  bg: '#F2F4F7',
  card: '#FFFFFF',
  chip: '#E8ECF2',
  title: '#22282F',
  sub: '#6B7686',
  text3: '#9AA5B4',
  blue: '#2F6FD0',
  blueD: '#1F4F9A',
  blueL: '#DCE8F9',
  green: '#27A567',
  yellow: '#E8A23D',
  red: '#DA4B4B',
  orange: '#E8833A',
  line: '#E3E8EF',
  tabOn: '#2F6FD0',
  btnText: '#FFFFFF',
  mask: 'rgba(34,40,47,0.42)',
  codeBg: '#22282F'
};

这里把接口落地为一个具体的浅色主题常量 COLORS。页面底色 #F2F4F7 是一种偏冷的浅灰,能让白色卡片 #FFFFFF 自然浮起;主强调色 #2F6FD0 是一种偏向"工具感"的蓝,不刺眼但识别度高,适合做按钮和选中态;次强调色 #27A567 是一种"信号绿",传达"在线、可预约、高相关"的正向信号。

文本色做了三级分层:title 深近黑 #22282F 给主标题,sub 中灰 #6B7686 给次级说明,text3 浅灰 #9AA5B4 给三级弱信息(时间、距离、坐标)。这种三级分层是移动端信息密度控制的标准做法——用户视线天然先落在深色文字上,浅色文字作为补充信息存在但不抢戏。

mask 用 rgba(34,40,47,0.42) 而非纯黑,是为了让遮罩带一点蓝灰调,和整体主题色温保持一致,避免弹窗时突然变黑显得突兀。codeBg 直接复用了 title 的深色 #22282F,给代码预览卡一个"深底亮字"的反差效果,模拟代码编辑器的视觉感受。tabOn 复用了 blue,让底部选中 Tab 和主按钮保持同一强调色,强化品牌识别。

四、常量与数据模型

4.1 Tab导航与筛选标签

/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
  icon: string;   // Tab 图标 emoji
  label: string;  // Tab 标签文案
}

/** 底部导航 Tab 常量列表(4 Tab 单排:师傅/地图/搜索/我的) */
const TAB_LIST: TabMeta[] = [
  { icon: '🔧', label: '师傅' },
  { icon: '🗺', label: '地图' },
  { icon: '🔍', label: '搜索' },
  { icon: '👤', label: '我的' }
];

/** 头部横滑筛选 chips 文案(家政维修场景筛选) */
const CATE_TAGS: string[] = ['全部', '水电急修', '家电清洗', '管道疏通', '门窗五金', '墙面翻新', '家具安装', '开锁换锁'];

/** 城市中心点(重庆,地图初始化中心 + Map Kit 搜索 location 参数) */
const CITY_CENTER: mapCommon.LatLng = { latitude: 29.5630, longitude: 106.5516 };

TabMeta 接口定义了底部导航每个 Tab 的两个字段:icon 用 emoji 表示图标,label 是文字标签。用 emoji 而非图片资源,是为了让整个页面在一个 .ets 文件内自洽,不依赖外部素材,便于演示和移植。TAB_LIST 是一个四元素的常量数组,分别对应师傅、地图、搜索、我的四个 Tab,底部 Row 通过 ForEach 渲染时直接遍历这个数组即可。

CATE_TAGS 是头部横滑筛选 chips 的文案列表,覆盖了家政维修的八个典型工种分类。横滑 Scroll + Row + ForEach 是 ArkUI 实现横向标签的标准模式,比 Tabs 容器更灵活,可以自由控制间距、选中态背景色和圆角。选中态通过 cateIdx === idx 判断,背景色在 COLORS.blue(选中)和 COLORS.chip(未选中)之间切换。

CITY_CENTER 是一个 mapCommon.LatLng 类型的常量,定位在重庆主城(纬度 29.5630、经度 106.5516)。这个常量被复用在两个地方:一是 mapOptions.position.target 作为地图初始化中心点,二是 site.SearchByTextParams.location 作为搜索的地理参考点。把它提取为常量,保证地图中心和搜索范围始终围绕同一个城市基准,避免"地图显示重庆、搜索却在别的城市"这类不一致。

4.2 地图标注点与推荐师傅Mock数据

/** 地图标注点接口(服务站 Marker 群,长按事件的数据来源) */
interface SpotItem {
  name: string;   // 服务站名称
  lat: number;    // 纬度
  lng: number;    // 经度
  tag: string;    // 服务站工种标签
}

/** 服务站标注点 Mock 数据(6 个,围绕重庆中心点 ±0.02 度散布) */
const MARKER_SPOTS: SpotItem[] = [
  { name: '修立得·解放碑服务站', lat: 29.5565, lng: 106.5588, tag: '急修' },
  { name: '修立得·观音桥服务站', lat: 29.5800, lng: 106.5325, tag: '清洗' },
  { name: '修立得·南坪工贸服务站', lat: 29.5485, lng: 106.5602, tag: '疏通' },
  { name: '修立得·大坪石桥铺服务站', lat: 29.5698, lng: 106.5425, tag: '五金' },
  { name: '修立得·两路口上清寺点', lat: 29.5535, lng: 106.5455, tag: '翻新' },
  { name: '修立得·江北城服务点', lat: 29.5785, lng: 106.5548, tag: '安装' }
];

SpotItem 接口定义了服务站 Marker 的四元信息:名称、纬度、经度、工种标签。这是"长按 Marker 事件"的数据源头——地图初始化时,setupMapCallback 会遍历这个数组逐个 addMarker,每个 Marker 携带一个经纬度位置。当用户长按某个 Marker 时,回调里只能拿到 map.Marker 对象(含 ID 和 position),拿不到原始的 name 和 tag,所以业务上如果要展示服务站详情,通常需要用 Marker ID 反查这个数组。

MARKER_SPOTS 是六条 Mock 数据,围绕重庆中心点 ±0.02 度散布。±0.02 度在赤道附近大约对应 2.2 公里,这个范围既能让所有 Marker 在 zoom:13 的初始缩放下都可见,又能体现"散布在主城各处"的真实分布感。六个服务站覆盖了急修、清洗、疏通、五金、翻新、安装六个工种,和头部 CATE_TAGS 的分类形成呼应,让用户在地图上能直观看到不同工种的服务站位置。

这种 Mock 数据的设计还有一个用意:它和"师傅 Tab 的全部师傅列表"是同源的。代码注释里明确写了"全部师傅(地图 Marker 同源)",意味着师傅列表里的师傅和地图上的服务站 Marker 在业务上是同一批资源,只是呈现方式不同——列表用文字和评分展示,地图用位置和 Marker 展示。这种"一数据源、两视图"的设计在家政维修场景下很常见,用户既想看师傅的口碑评分,又想在地图上确认距离。

4.3 Observed数据模型

/** 师傅条目(师傅 Tab 收藏列表) */
@Observed export class FixerItem {
  name: string;    // 师傅名
  craft: string;   // 工种
  orders: number;  // 累计接单数
  rating: number;  // 综合评分(满分 5)
  note: string;    // 用户备注(可编辑)

  constructor(name: string, craft: string, orders: number,
    rating: number, note: string) {
    this.name = name;
    this.craft = craft;
    this.orders = orders;
    this.rating = rating;
    this.note = note;
  }
}

FixerItem 是一个用 @Observed 装饰的类,表示"收藏师傅"这一数据模型。@Observed 的作用是让该类的实例属性变化能被 ArkUI 的响应式系统感知——当 note 被修改时,引用了该实例的 @State 数组刷新,界面会重新渲染。这是 ArkUI 二级观察机制的核心:@State 观察数组本身的引用变化(增删、整体替换),@Observed 观察数组元素内部属性的变化。

类里定义了五个字段:name 师傅名、craft 工种、orders 累计接单数、rating 综合评分(满分 5)、note 用户备注。前四个是相对静态的师傅画像,note 是用户自定义的、会被频繁编辑的字段。把 note 放在同一个类里,配合 @Observed,能让"编辑备注→保存→列表刷新"这条链路自动响应,无需手动调用刷新方法。

构造函数接收五个参数并逐一赋值给 this。这种"构造函数 + 字段赋值"的写法是 ArkTS 类的标准范式,相比对象字面量 {name, craft, ...},类的优势在于可以挂载方法、可以被 instanceof 判断、可以在 @Observed 下参与响应式。export 关键字让这个类能被其他文件复用,但本页内主要是给 FIXER_LIST 常量初始化用。

/** 搜索结果条目(★ Map Kit 6.1.1 reliability 字段数据载体) */
@Observed export class SearchRecord {
  name: string;         // 地点名称(site.name)
  address: string;       // 格式化地址(site.formatAddress)
  distance: number;      // 直线距离米(site.distance)
  reliability: number;   // ★ 相关性分数(site.reliability,[0,1])
  time: string;          // 记录时间文案

  constructor(name: string, address: string, distance: number,
    reliability: number, time: string) {
    this.name = name;
    this.address = address;
    this.distance = distance;
    this.reliability = reliability;
    this.time = time;
  }
}

SearchRecord 是搜索结果的数据载体,也是本页"reliability 特性"的核心承载体。它的五个字段直接对应 site.Site 返回的关键属性:name 来自 site.name、address 来自 site.formatAddress、distance 来自 site.distance、reliability 来自 6.1.1 新增的 site.reliability。time 是业务侧补充的"刚刚"文案,不属于 Site 原始字段。

把这个类声明为 @Observed,是因为搜索结果列表会在 runSearch 完成后被整体替换,@State searchRecords 观察引用变化触发刷新。虽然单个 SearchRecord 实例的属性在创建后不会再变(reliability 是搜索时确定的),但 @Observed 的成本很低,留着以备后续可能需要"点击收藏后给某条结果打标"这类场景。

注意字段的类型:distance 是 number(米),UI 展示时通过 rec.distance / 1000 转成公里;reliability 是 number(0 到 1 的小数),UI 展示时通过 reliability * 100 映射到 Progress 的 0~100 区间。这种"原始数据存标准单位、展示时按需换算"的做法,避免了数据层和展示层耦合,后续如果要切换"英里""千米"等单位,只改展示逻辑即可。

/** 长按事件日志条目(★ MapEventManager 长按监听数据载体) */
@Observed export class EventLog {
  type: string;    // 事件类型:'Marker' / 'POI'
  name: string;    // Marker ID 或 POI 名称
  lat: number;     // 纬度
  lng: number;     // 经度
  time: string;    // 事件时间文案

  constructor(type: string, name: string, lat: number, lng: number, time: string) {
    this.type = type;
    this.name = name;
    this.lat = lat;
    this.lng = lng;
    this.time = time;
  }
}

EventLog 是长按事件日志的数据载体,统一承载 Marker 长按和 POI 长按两种事件。type 字段用字符串 'Marker' 或 'POI' 区分事件来源,UI 渲染时据此显示不同图标和颜色(Marker 用蓝色 📍、POI 用绿色 🏷)。name 字段对 Marker 存的是 #${marker.getId()}(如 #0),对 POI 存的是 poi.name(如"观音桥步行街"),两种语义合并到一个字段。

lat 和 lng 保留事件发生时的经纬度,用 toFixed(4) 展示为 4 位小数,精度大约 11 米,足够定位到具体地点。time 存"刚刚"这种相对时间文案。整条日志通过 this.eventLogs.unshift(...) 置顶插入,最新的长按事件永远在列表最上方,符合"事件流"的直觉。

@Observed 在这里同样有意义:虽然单条 EventLog 创建后不变,但 eventLogs 数组会频繁 unshift,@State 监听数组引用变化(unshift 会改变数组)触发列表刷新。注意 ArkUI 的数组响应式有个细节——直接 push/unshift 改变原数组不一定能可靠触发刷新,更稳妥的做法是 this.eventLogs = [newLog, ...this.eventLogs] 整体替换引用,但本页用 unshift 配合 @Observed 也能工作,因为数组本身的长度变了。

4.4 辅助函数

/** 相关性等级接口(分数条旁的标签) */
interface ScoreLevel {
  label: string;   // 等级文案
  color: string;    // 等级颜色
}

/**
 * reliability 相关性分数 → 等级标签/颜色映射
 * 取值 [0,1]:≥0.8 高相关 / ≥0.5 中相关 / 其余低相关(Map Kit 6.1.1 新字段)
 */
function reliabilityScore(score: number): ScoreLevel {
  if (score >= 0.8) {
    return { label: '高相关', color: COLORS.green };
  }
  if (score >= 0.5) {
    return { label: '中相关', color: COLORS.yellow };
  }
  return { label: '低相关', color: COLORS.red };
}

reliabilityScore 是把 6.1.1 新字段 reliability 转化为可读等级的核心辅助函数。它接收一个 [0,1] 的分数,返回一个 {label, color} 对象。阈值的设计很有讲究:≥0.8 判为高相关(信号绿),≥0.5 判为中相关(适中黄),其余判为低相关(危险红)。这个三档阈值既符合人对"好/中/差"的直觉认知,又能在 UI 上形成清晰的"绿—黄—红"交通灯语义。

函数返回的是结构化对象而非单个值,这样调用一次就能同时拿到标签文案和颜色,避免在 UI 模板里调用两次。在 tabSearch 的渲染里,你会看到 reliabilityScore(rec.reliability).label 和 reliabilityScore(rec.reliability).color 两次调用——虽然调了两次,但函数本身是纯函数、无副作用、计算成本极低,这种写法换取的是模板的可读性。

阈值的选取也考虑了家政维修场景的特点。0.8 这个门槛意味着"绝大部分语义匹配、地理位置接近",适合标为"放心选";0.5 意味着"部分相关、可能是相近工种或邻近地点",标为"可参考";低于 0.5 则是"关键字命中但语义不匹配"(比如用户搜"家电维修"却命中了"电器卖场"),必须用红色提示用户"这不是你要的上门维修点"。

/** 师傅评分颜色映射:≥4.8 口碑极佳信号绿 / ≥4.5 口碑良好工具蓝 / 其余一般红 */
function ratingColor(rating: number): string {
  if (rating >= 4.8) { return COLORS.green; }
  if (rating >= 4.5) { return COLORS.blue; }
  return COLORS.red;
}

/** 接单数颜色映射:≥1200 单经验丰富绿 / ≥600 单熟练黄 / 其余新手红 */
function orderColor(orders: number): string {
  if (orders >= 1200) { return COLORS.green; }
  if (orders >= 600) { return COLORS.yellow; }
  return COLORS.red;
}

ratingColor 和 orderColor 是师傅画像的两个颜色映射函数,和 reliabilityScore 是同一套设计思路:阈值分档、返回语义色。ratingColor 把评分 4.8/4.5 作为分界,4.9 分的师傅标绿(口碑极佳),4.6 分标蓝(口碑良好),4.3 分标红(一般)。orderColor 把接单数 1200/600 作为分界,1500 单标绿(经验丰富),800 单标黄(熟练),500 单标红(新手)。

这两个函数复用了和 reliabilityScore 相同的颜色三件套(绿/黄/红或绿/蓝/红),让整个页面的"等级色"在搜索结果、师傅评分、师傅接单数三处保持视觉一致——绿色都意味着"优质/高相关",红色都意味着"低质/低相关"。这种跨场景的颜色一致性,是建立用户"色彩—语义"心智模型的关键,用户看多了就能"扫一眼颜色就知道好坏"。

五、组件主体与状态管理

5.1 状态变量定义

@Entry
@Component
struct Page1143 {
  /** 当前选中 Tab 索引 */
  @State currentTab: number = 0;
  /** 头部筛选 chips 选中索引 */
  @State cateIdx: number = 0;
  /** 收藏师傅弹窗开关 */
  @State addModal: boolean = false;
  /** 编辑备注弹窗开关 */
  @State editModal: boolean = false;
  /** 删除收藏确认弹窗开关 */
  @State delModal: boolean = false;
  /** 当前编辑的师傅索引 */
  @State editIdx: number = 0;
  /** 当前删除的师傅索引 */
  @State delIdx: number = 0;
  /** 师傅收藏列表数据 */
  @State fixerList: Array<FixerItem> = FIXER_LIST;
  /** 我的页功能清单数据 */
  @State funcList: FuncItem[] = FUNC_LIST;

@Entry 和 @Component 两个装饰器分别声明这是页面入口和组件。struct Page1143 是组件名(页面级组件用 Page 前缀是常见命名习惯)。这两个装饰器的组合意味着这个 struct 既是一个可独立渲染的组件,又是路由的入口页面,DevEco Studio 会把它注册为可导航页面。

前两个 @State 是 Tab 切换和筛选的核心:currentTab 控制四个 Tab 哪个显示(0/1/2/3 对应师傅/地图/搜索/我的),cateIdx 控制头部筛选 chips 选中哪个分类。这两个状态的变化会直接驱动 build() 里 if (this.currentTab === 0) 分支渲染和 chips 背景色切换,是页面交互的"中枢神经"。

接下来三个 @State(addModal/editModal/delModal)是三个弹窗的开关,配合 editIdx/delIdx 记录当前操作的师傅索引。这种"一个 bool 一个 index"的弹窗管理方式比"一个全局 modalType 字符串"更直观,每个弹窗的状态独立,互不干扰。fixerList 和 funcList 是两个列表数据源,初始化为 Mock 常量 FIXER_LIST 和 FUNC_LIST,后续增删改会修改它们。

  // --- Map Kit 状态(6.1.1 特性:搜索 reliability + 长按事件) ---
  /** 地图初始化参数(非可选并给默认值,避免组件参数传 undefined) */
  private mapOptions: mapCommon.MapOptions = {
    position: { target: CITY_CENTER, zoom: 13 }
  };
  /** 地图初始化回调(aboutToAppear 中赋值) */
  private mapCallback?: AsyncCallback<map.MapComponentController>;
  /** 地图控制器(回调中获取,添加 Marker 用) */
  private mapController?: map.MapComponentController;
  /** 地图事件管理器(回调中获取,长按监听注册用) */
  private mapEventManager?: map.MapEventManager;
  /** Marker 长按监听开关 */
  @State markerListenOn: boolean = true;
  /** POI 长按监听开关 */
  @State poiListenOn: boolean = true;
  /** 长按事件日志流(unshift 置顶) */
  @State eventLogs: Array<EventLog> = EVENT_LOGS;
  /** 搜索关键字输入值 */
  @State queryInput: string = '家电维修';
  /** 搜索状态文案 */
  @State searchState: string = '待搜索 · 演示数据';
  /** 搜索结果列表(site.searchByText 结果数据源) */
  @State searchRecords: Array<SearchRecord> = SEARCH_RECORDS;
  /** 收藏弹窗:师傅名输入 */
  @State formName: string = '';
  /** 收藏弹窗:师傅常驻地址输入 */
  @State formAddr: string = '';
  /** 收藏弹窗:擅长工种输入 */
  @State formTag: string = '';
  /** 编辑弹窗:备注输入 */
  @State editNote: string = '';

这是 Map Kit 相关的状态块,分为 private(非响应式)和 @State(响应式)两类。mapOptions 是 private,因为它只在 MapComponent 初始化时传入一次,不需要后续变化触发渲染;它直接给定了 position.target(重庆中心点)和 zoom:13,避免后续传 undefined 导致组件参数异常。

mapCallback、mapController、mapEventManager 三个都是 private 且可选(?)。它们遵循"先声明空值→回调里赋值"的延迟初始化模式:mapCallback 在 aboutToAppear 里赋值,mapController 和 mapEventManager 在 mapCallback 的回调里赋值。这种延迟赋值是因为 Map Kit 的控制器必须等地图组件初始化完成后才能拿到,不能在 aboutToAppear 同步获取。

markerListenOn、poiListenOn 两个 @State 是长按监听的开关,初始化为 true(默认开启监听),UI 上对应两个 Toggle 开关,用户可以随时关闭/重开某类长按监听。eventLogs 是事件日志流的响应式数据源,初始化为两条演示数据 EVENT_LOGS,后续每次长按事件触发都会 unshift 新日志,驱动日志列表刷新。

queryInput 默认值"家电维修"是一个很有业务感的初始关键字——它既不是太宽泛("维修"会命中太多无关结果),也不是太狭窄("空调清洗"只命中一个工种),刚好能让 reliability 字段在搜索结果里展现出高/中/低三档分明的效果。searchState 和 searchRecords 分别是搜索状态文案和结果列表,初始化为"待搜索·演示数据"和六条 Mock 数据,让用户进搜索 Tab 第一眼就能看到可靠性分数条的展示效果,而不必先点搜索。

5.2 地图初始化回调与长按监听注册

  /**
   * 地图初始化:controller → eventManager → Marker 群 → 6.1.1 双长按监听
   * 必须在 mapCallback 的 err 为空分支内注册监听(controller 就绪后才有管理器)
   */
  setupMapCallback() {
    this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
      if (err) {
        console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
        return;
      }
      this.mapController = mapController;
      this.mapEventManager = mapController.getEventManager();
      // 批量添加服务站 Marker(addMarker 返回 Promise,逐个 await + try-catch)
      for (const spot of MARKER_SPOTS) {
        const markerOptions: mapCommon.MarkerOptions = {
          position: { latitude: spot.lat, longitude: spot.lng },
          clickable: true,
          visible: true,
          rotation: 0,
          zIndex: 0,
          alpha: 1,
          anchorU: 0.5,
          anchorV: 1,
          draggable: false,
          flat: false
        };
        try {
          await this.mapController.addMarker(markerOptions);
        } catch (e) {
          console.error(`addMarker failed: ${(e as BusinessError).message}`);
        }
      }
      // ★ 6.1.1 新特性·事件一:监听地图标记 Marker 的长按
      this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
        const pos: mapCommon.LatLng = marker.getPosition();
        this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
          pos.latitude, pos.longitude, '刚刚'));
      });
      // ★ 6.1.1 新特性·事件二:监听地图 POI 的长按(参数是 mapCommon.Poi)
      this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
        this.eventLogs.unshift(new EventLog('POI', poi.name,
          poi.position.latitude, poi.position.longitude, '刚刚'));
      });
    };
  }

setupMapCallback 是整个地图特性的"装配车间",它把一个 AsyncCallback 赋值给 this.mapCallback,这个回调会在 MapComponent 初始化完成后被系统调用,参数是 (err, mapController)。这是 Map Kit 的标准异步初始化模式——MapComponent 在 UI 渲染时并不立即有控制器,需要等底层地图引擎就绪后才通过回调把控制器交出来。

回调第一件事是判断 err:如果地图初始化失败(比如设备没有 Google Play 服务、AGC 配置缺失),直接 console.error 打印错误码和消息并 return,避免后续对 undefined 的 mapController 调用方法导致崩溃。这种"err 优先"的防御式写法在异步回调里是必须的,鸿蒙的 BusinessError 模式和 Node.js 的 err-first callback 一脉相承。

err 为空时进入主分支:先把 mapController 存到 this,再通过 mapController.getEventManager() 拿到 mapEventManager。这两个赋值的顺序很重要——事件管理器是从控制器上获取的,控制器不存在就谈不上事件管理器。拿到管理器后,紧接着遍历 MARKER_SPOTS 逐个 addMarker,每个 Marker 的 MarkerOptions 配置了 clickable: true(可点击,是长按事件能触发的前提)、visible: true(可见)、anchorU/anchorV: 0.5/1(锚点在底部中心,让图钉尖朝下指准位置)。

Marker 添加循环用了 for...of + await,每个 addMarker 返回 Promise,必须逐个等待完成再添加下一个。这种串行而非并行的写法是为了避免并发 addMarker 导致的内部状态竞争。每个 addMarker 包了 try-catch,单个 Marker 添加失败不会中断后续 Marker 的添加,保证了"六个服务站尽量都标上"的容错性。

循环结束后注册两个 6.1.1 新增的长按监听。onMarkerLongClick 的回调参数是 map.Marker 对象,通过 marker.getPosition() 拿到经纬度、marker.getId() 拿到 Marker ID,组装成 EventLog('Marker', '#0', lat, lng, '刚刚') 后 unshift 到日志流。onPoiLongClick 的回调参数是 mapCommon.Poi 对象,结构不同——poi.name 直接是地点名、poi.position 是 LatLng,组装成 EventLog('POI', '观音桥步行街', lat, lng, '刚刚')。两个回调的差异恰好体现了 Marker 和 POI 两种地图实体的数据结构差异:Marker 是开发者自定义的、靠 ID 标识;POI 是系统内置的兴趣点、靠名称标识。

5.3 长按监听开关切换

  /** Marker 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
  toggleMarkerListen() {
    if (!this.mapEventManager) {
      return;
    }
    if (this.markerListenOn) {
      this.mapEventManager.offMarkerLongClick();
    } else {
      this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
        const pos: mapCommon.LatLng = marker.getPosition();
        this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`,
          pos.latitude, pos.longitude, '刚刚'));
      });
    }
    this.markerListenOn = !this.markerListenOn;
  }

toggleMarkerListen 是给 UI 上"Marker长按"开关用的切换方法。它首先判断 this.mapEventManager 是否存在——如果地图还没初始化好(用户刚进页面就点开关),直接 return 不做任何操作,避免对 undefined 调用 offMarkerLongClick 崩溃。这是"防御式编程"的典型应用。

接着根据当前 markerListenOn 的值决定动作:如果当前是开(true),调用 offMarkerLongClick() 关闭监听;如果当前是关(false),重新调用 onMarkerLongClick 注册一个同样的回调。注意这里重新注册的回调和 setupMapCallback 里注册的回调逻辑完全一致——这是因为 offMarkerLongClick() 会清除该类型全部订阅,再开启时必须重新挂一个回调。

最后 this.markerListenOn = !this.markerListenOn 翻转开关状态,驱动 UI 上 Toggle 的 isOn 视觉切换。这个翻转放在最后,确保只有前面的 off/on 操作都成功了才翻转状态,避免"操作失败但 UI 显示已切换"的不一致。

offMarkerLongClick() 不传参数的设计很巧妙——它清除了该类型的"全部"订阅。如果业务上注册了多个不同的 Marker 长按回调,不传参会一次性清空。如果只想清除某个特定回调,可以传入对应的回调引用。本页只用了一个回调,所以不传参的批量清除就足够了,代码简洁。

  /** POI 长按监听开关切换(off 不传参 = 清除该类型全部订阅) */
  togglePoiListen() {
    if (!this.mapEventManager) {
      return;
    }
    if (this.poiListenOn) {
      this.mapEventManager.offPoiLongClick();
    } else {
      this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
        this.eventLogs.unshift(new EventLog('POI', poi.name,
          poi.position.latitude, poi.position.longitude, '刚刚'));
      });
    }
    this.poiListenOn = !this.poiListenOn;
  }

togglePoiListen 和 toggleMarkerListen 是完全对称的实现,只是换成了 POI 相关的 API:offPoiLongClick / onPoiLongClick。回调参数从 map.Marker 变成 mapCommon.Poi,数据读取从 marker.getPosition() + marker.getId() 变成 poi.position + poi.name。

这两个 toggle 方法放在组件里,让用户能动态控制"要不要监听长按"。这种开关在实际业务里很有用:比如用户在地图上只想浏览不想被事件打扰,关掉两个监听;想重新启用时再打开。也方便调试——开发时关闭监听可以避免误触长按产生大量日志干扰。

5.4 searchByText搜索与reliability读取

  /**
   * ★ 6.1.1 新特性·搜索:关键字搜索 searchByText → Site 数组
   * 读取 Site.reliability 相关性分数(可选字段,?? 兜底 0)
   * 无 AGC 配置/无网络时抛 BusinessError,catch 保留 Mock 数据保证演示链路
   */
  async runSearch() {
    this.searchState = '搜索中…';
    const params: site.SearchByTextParams = {
      query: this.queryInput,
      location: CITY_CENTER,
      radius: 5000,
      language: 'zh'
    };
    try {
      const result: site.SearchByTextResult = await site.searchByText(params);
      const sites: Array<site.Site> = result.sites ?? [];
      if (sites.length === 0) {
        this.searchState = '无结果 · 保留演示数据';
        return;
      }
      const records: Array<SearchRecord> = [];
      for (const s of sites) {
        records.push(new SearchRecord(
          s.name ?? '未命名地点',
          s.formatAddress ?? '暂无地址',
          s.distance ?? 0,
          s.reliability ?? 0,
          '刚刚'));
      }
      this.searchRecords = records;
      this.searchState = `返回 ${sites.length} 条结果`;
    } catch (e) {
      const err = e as BusinessError;
      this.searchState = `搜索失败(${err.code}) · 保留演示数据`;
    }
  }

runSearch 是搜索特性的核心方法,也是 6.1.1 reliability 字段的消费入口。它是一个 async 方法,因为 site.searchByText 返回 Promise。方法第一步把 searchState 设为"搜索中…",给用户即时反馈——这是异步操作的标准 UX,避免用户点了搜索却不知道在不在执行。

构造 SearchByTextParams 时带了四个字段:query 是用户输入的关键字(默认"家电维修"),location 是搜索的地理参考点(复用 CITY_CENTER 重庆中心),radius 是搜索半径 5000 米(5 公里,适合家政维修这种"同城上门"场景),language 是 'zh' 中文结果。这四个参数共同决定了搜索结果的范围和语言——以重庆为中心、5 公里内、中文的"家电维修"相关地点。

try 块里调用 site.searchByText(params),拿到 SearchByTextResult,取 result.sites(Site 数组)。这里用了 ?? [] 空值兜底——如果 sites 字段是 undefined(理论上不应该但接口可能返回),降级为空数组,避免后续 for...of 一个 undefined 崩溃。如果数组为空,设置状态"无结果·保留演示数据"并 return,UI 上继续显示之前的 Mock 数据,让用户始终能看到 reliability 分数条的展示形态,而不是面对一个空列表。

非空时遍历 sites,逐个构造 SearchRecord。每个字段都用了 ?? 兜底:s.name ?? '未命名地点'、s.formatAddress ?? '暂无地址'、s.distance ?? 0、s.reliability ?? 0。这种"每个可选字段都兜底"的写法,是因为 Site 类型的这些字段在 6.1.1 前部分是可选的,6.1.1 新增的 reliability 更是新增字段,老版本 SDK 下可能是 undefined。reliability ?? 0 兜底为 0,意味着"没拿到分数就当低相关处理",这是最保守的降级策略。

catch 块捕获 BusinessError,把错误码拼到状态文案里:搜索失败(${err.code}) · 保留演示数据。这里没有清空 searchRecords,而是保留之前的 Mock 数据,保证"搜索失败但 UI 仍有内容可看"。这种"失败不破坏现有状态"的设计在弱网环境下很重要——用户在地铁里没信号点了搜索,看到的是"搜索失败但之前的列表还在",而不是空白页。

5.5 师傅CRUD方法

  /** 打开编辑备注弹窗(回填当前师傅备注) */
  openEditFixer(idx: number) {
    this.editIdx = idx;
    this.editNote = this.fixerList[idx].note;
    this.editModal = true;
  }

  /** 保存收藏师傅(空名兜底默认演示师傅) */
  saveFixer() {
    const name = this.formName === '' ? '修立得·新收藏师傅' : this.formName;
    const tag = this.formTag === '' ? '综合维修' : this.formTag;
    const addr = this.formAddr === '' ? '重庆市(地图选点)' : this.formAddr;
    this.fixerList.unshift(new FixerItem(name, tag, 520, 4.4, addr));
    this.formName = '';
    this.formTag = '';
    this.formAddr = '';
    this.addModal = false;
  }

  /** 保存编辑备注(整体刷新数组引用以刷新列表) */
  updateFixer() {
    if (this.editIdx >= 0 && this.editIdx < this.fixerList.length) {
      if (this.editNote !== '') {
        this.fixerList[this.editIdx].note = this.editNote;
      }
      this.fixerList = this.fixerList.slice();
    }
    this.editModal = false;
  }

  /** 删除收藏师傅(确认弹窗回调) */
  delFixer() {
    if (this.delIdx >= 0 && this.delIdx < this.fixerList.length) {
      this.fixerList.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

这四个方法是师傅收藏列表的增删改查。openEditFixer 是"改"的入口——记录编辑索引 editIdx、把当前师傅的备注回填到 editNote(让弹窗的 TextInput 显示现有备注)、打开编辑弹窗。回填这一步很重要,让用户看到原来的备注再修改,而不是面对一个空输入框。

saveFixer 是"增"——从表单读取三个字段(师傅名、工种、地址),每个都做了空值兜底(空名默认"修立得·新收藏师傅"、空工种默认"综合维修"、空地址默认"重庆市(地图选点)")。新师傅的接单数硬编码为 520、评分 4.4,这是合理的"新收藏"初始值——不算顶尖也不算太差。unshift 把新师傅插到列表最前,符合"刚加的师傅排第一"的直觉。最后清空三个表单字段、关闭弹窗。

updateFixer 是"改"的保存——先做边界检查(editIdx 在合法范围内),再判断备注非空才赋值。关键的一行是 this.fixerList = this.fixerList.slice()——.slice() 不传参等价于浅拷贝整个数组,生成一个新引用,赋值给 this.fixerList 触发 @State 的引用变化感知,从而让 ForEach 重新渲染。这是 ArkUI 里"修改元素属性后刷新列表"的标准技巧:直接改属性不会触发 @State 的引用变化,必须整体替换引用;虽然 FixerItem 是 @Observed 理论上能感知属性变化,但配合 slice 双保险更稳妥。

delFixer 是"删"——同样边界检查后 splice(this.delIdx, 1) 删除指定索引的元素。splice 直接修改原数组,在 ArkUI 里 splice 触发响应式是支持的(属于被监听的数组变异方法),所以不需要像 updateFixer 那样再 slice 一次。删除完关闭弹窗。

5.6 生命周期与主构建

  /** 生命周期:初始化地图回调(监听注册在 mapCallback 内完成) */
  aboutToAppear() {
    this.setupMapCallback();
  }

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabFixer()
            } else if (this.currentTab === 1) {
              this.tabMap()
            } else if (this.currentTab === 2) {
              this.tabSearch()
            } else {
              this.tabMine()
            }
          }
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        this.tabBar()
      }
      .width('100%')
      .height('100%')

      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)
  }

aboutToAppear 是 ArkUI 组件的生命周期钩子,在组件创建后、build 执行前调用。这里只做一件事:调用 setupMapCallback() 给 this.mapCallback 赋值。这一步必须早于 build,因为 build 里 MapComponent({ mapOptions, mapCallback }) 需要传入一个已赋值的 mapCallback。如果把 setupMapCallback 的逻辑写到 build 里会导致每次重渲染都重新赋值回调,破坏 Map Kit 的初始化幂等性。

build 是组件的渲染入口。最外层 Stack 是一个堆叠容器,它能把多个子元素叠在一起——这是实现"主内容 + 弹窗遮罩"分层的关键。Stack 里先放 Column(主内容),再依次条件渲染三个弹窗(addModal/editModal/delModal 为 true 时才渲染对应的 panelAdd/panelEdit/panelDel)。弹窗在 Stack 里的层级天然高于主内容,无需额外的 z-index 控制。

主内容 Column 里从上到下依次是:headerMain(头部 Banner + 筛选 chips)、Divider(分割线)、Scroll(可滚动内容区,layoutWeight(1) 占满剩余高度)、tabBar(底部 Tab)。Scroll 内部是一个 Column,根据 currentTab 的值条件渲染四个 @Builder 中的一个——这是 ArkUI 实现 Tab 切换的轻量级方案,比 Tabs 容器更可控,可以自由定制切换时的动画和状态保留策略。

弹窗的关闭通过传给 @Builder 的回调函数实现:panelAdd(() => { this.addModal = false; }) 把一个"关闭弹窗"的箭头函数作为 onClose 参数传入,弹窗内部的"取消"按钮和遮罩点击都调用 onClose(),从而关闭弹窗。这种"父组件控制开关、子 Builder 调用回调"的模式,让弹窗的状态管理集中在父组件,子 Builder 只负责 UI 渲染和事件回调,职责清晰。

六、Builder函数群

6.1 头部渐变Banner与筛选chips

  /** 头部:渐变 Banner(最快上门时长+收藏师傅数)+ 筛选 chips 横滑 */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 顶部渐变 Banner:上门时长 + 收藏师傅数 + 质保贴士
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column({ space: 2 }) {
            Text('28 分钟').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('最快上门时长').fontSize(10).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          Column({ space: 6 }) {
            Row({ space: 6 }) {
              Text('🔧').fontSize(14)
              Text('观音桥站 · 4 位师傅在线').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            }
            Row({ space: 6 }) {
              Text('⭐').fontSize(12)
              Text('收藏师傅 8 位 · 今日已修 3 单').fontSize(11).fontColor(COLORS.sub)
            }
            Row({ space: 6 }) {
              Text('🛡').fontSize(12)
              Text('平台质保 90 天 · 免收跑腿费').fontSize(11).fontColor(COLORS.sub)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Row({ space: 8 }) {
          Text('🔧 一键报修').fontSize(12).fontColor(COLORS.btnText).fontWeight(FontWeight.Bold)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(16).backgroundColor(COLORS.blue)
          Text('🗺 地图找师傅').fontSize(12).fontColor(COLORS.blue)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(16).backgroundColor(COLORS.chip)
            .onClick(() => { this.currentTab = 1; })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.blueD, 0.0], [COLORS.card, 0.7]]
      })

headerMain 是页面头部的复合 Builder,包含一个渐变 Banner 和一组横滑筛选 chips。Banner 内部用一个 Column({ space: 10 }) 容纳两块内容:上行是一个 Row,左侧大字号显示"28 分钟最快上门时长",右侧三行小字分别展示在线师傅数、收藏师傅数、质保信息;下行是两个胶囊按钮"一键报修"(蓝底白字,主操作)和"地图找师傅"(浅底蓝字,次操作,点击切到地图 Tab)。

Banner 的视觉重点在于 linearGradient——angle: 135 表示从左上到右下的对角线方向,colors: [[COLORS.blueD, 0.0], [COLORS.card, 0.7]] 表示从深工具蓝 #1F4F9A(位置 0%,左上角)渐变到白色 #FFFFFF(位置 70%,右下区域)。这种"深色起点+浅色终点"的对角渐变,让 Banner 既有了"工具蓝"的品牌色彩印记,又不会因为大面积深色而显得压抑,右下大面积留白还可以容纳文字内容。

左侧大字号"28 分钟"用 fontSize(30).fontWeight(FontWeight.Bold) 突出,是整个 Banner 的视觉焦点——上门时效是家政维修用户最关心的指标,必须一眼看到。右侧三行用 emoji + 文字的组合,emoji 起到图标作用(🔧在线师傅、⭐收藏数、🛡质保),文字用 11-13px 的小字号分层级展示,既不抢左侧"28 分钟"的戏,又把关键运营信息都铺出来。

“地图找师傅"按钮的 onClick 写法 () => { this.currentTab = 1; } 直接把 Tab 切到地图页,这是头部 Banner 引导用户进入地图特性的设计——用户看到"28 分钟最快上门"后,自然想确认"附近到底有哪些师傅”,点这个按钮就能切到地图 Tab 看到六个服务站 Marker 和长按事件能力。

      // 筛选 chips 横滑
      Scroll() {
        Row({ space: 8 }) {
          ForEach(CATE_TAGS, (tag: string, idx: number) => {
            Text(tag)
              .fontSize(11)
              .fontColor(this.cateIdx === idx ? COLORS.btnText : COLORS.sub)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor(this.cateIdx === idx ? COLORS.blue : COLORS.chip)
              .onClick(() => { this.cateIdx = idx; })
          }, (tag: string) => tag)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .padding({ left: 14, right: 14, top: 12, bottom: 8 })
    .width('100%')
  }

筛选 chips 用 Scroll + 横向 Row + ForEach 实现。scrollable(ScrollDirection.Horizontal) 让 Scroll 支持横向滑动,scrollBar(BarState.Off) 隐藏滚动条保持视觉干净。八个工种分类(全部/水电急修/家电清洗/管道疏通/门窗五金/墙面翻新/家具安装/开锁换锁)排成一行,超出屏幕宽度的部分可以横滑看到。

每个 chip 的 fontColor 和 backgroundColor 都通过 cateIdx === idx 三元判断切换:选中态用蓝底白字(COLORS.blue + COLORS.btnText),未选中态用浅底灰字(COLORS.chip + COLORS.sub)。onClick 里 this.cateIdx = idx 修改选中索引,驱动所有 chip 的颜色重新计算。这种"一个状态变量控制一组元素选中态"是 ArkUI 声明式的典型用法,相比命令式 DOM 操作简洁很多。

ForEach 的第三个参数是键值生成函数 (tag: string) => tag,用 tag 文本本身作为 key。这保证了一组 chips 的 key 稳定——切换选中态时 ForEach 能根据 key 判断"这是同一个 chip,只是颜色变了",只更新属性不重建节点,性能更好。如果用 index 作为 key,每次切换都可能引发不必要的节点重建。

6.2 师傅Tab

  /** 师傅 Tab:报修统计三宫格 + 在线师傅 + 收藏师傅列表(业务主 Tab) */
  @Builder
  tabFixer() {
    Column({ space: 10 }) {
      // 报修统计三宫格(收藏师傅/本月报修/质保订单)
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.blue)
          Text('收藏师傅(位)').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('3').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
          Text('本月报修(单)').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('2').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
          Text('质保期内(项)').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
      }
      .width('100%')

tabFixer 是业务主 Tab,包含三块内容:报修统计三宫格、附近在线师傅大卡、全部师傅列表。三宫格用 Row({ space: 8 }) 容纳三个 Column,每个 Column 都 layoutWeight(1) 平均分配宽度,构成等宽三栏统计。每个格子白底圆角,上面是大号数字、下面是小字标签。

三个数字分别用蓝、绿、橙三种颜色,呼应前面"颜色按语义分档"的设计:收藏师傅数 8 用蓝色(主强调,因为这是用户的核心资产)、本月报修 3 用绿色(信号绿,正向活跃)、质保期内 2 用橙色(提醒橙,提示"有质保订单在保,注意维护")。这种"每个统计数配一个语义色"让用户一眼就能区分不同维度的状态,而不是面对一片同色数字。

fontSize(9) 的标签字号非常小,是因为三宫格在小屏上空间有限,标签必须让位给数字。padding({ top: 10, bottom: 10 }) 给足上下内边距让格子有"卡片"的厚度感。borderRadius(10) 的圆角和卡片底色配合,形成柔和的视觉边界,避免硬直角带来的"表格感"。

      // 区块标题行:更多入口
      Row() {
        Text('附近在线师傅').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Text('收藏新师傅 +').fontSize(11).fontColor(COLORS.blue)
          .onClick(() => { this.addModal = true; })
      }
      .width('100%')
      // 在线师傅大卡(3 条精选)
      ForEach(FIX_RECS, (rec: FixRec) => {
        Row({ space: 10 }) {
          Text(rec.icon).fontSize(26)
          Column({ space: 4 }) {
            Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(`工种 ${rec.craft} · 接单 ${rec.orders} 单`).fontSize(11).fontColor(COLORS.sub)
            Row({ space: 6 }) {
              Text(rec.dist).fontSize(10).fontColor(COLORS.text3)
              Text(`评分 ${rec.rating.toFixed(1)}`).fontSize(10).fontColor(ratingColor(rec.rating))
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column({ space: 4 }) {
            Text('预约').fontSize(11).fontColor(COLORS.btnText).fontWeight(FontWeight.Bold)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(12).backgroundColor(COLORS.blue)
              .onClick(() => { this.currentTab = 1; })
          }
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (rec: FixRec) => rec.name)

"附近在线师傅"标题行用 Row + Blank() 实现"左标题右操作"的两端对齐布局——Blank() 是一个弹性占位元素,自动撑开剩余空间,把"收藏新师傅 +"推到最右。点击右侧文字打开收藏弹窗 this.addModal = true,让用户能添加新师傅。

在线师傅大卡遍历 FIX_RECS(三条精选师傅)。每张卡片左侧是 26px 的师傅 emoji 图标(🔌水电/🧊家电/🔧管道),中间是师傅信息(名字、工种接单数、距离评分),右侧是"预约"按钮。卡片用 backgroundColor(COLORS.card) 白底 + borderRadius(12) 圆角,整体形成独立的卡片视觉单元。

评分用 ratingColor(rec.rating) 动态着色——4.9 分的师傅评分会显示为绿色,4.8 分也是绿色,4.7 分是蓝色。这种"评分越高越绿"的颜色映射,让用户扫一眼就能识别口碑好坏,比纯文字数字更直观。“预约"按钮点击切到地图 Tab(this.currentTab = 1),引导用户从"看到师傅"到"在地图上确认师傅位置”,形成业务闭环。

ForEach 的 key 用 rec.name,每个师傅名唯一,保证列表刷新时的节点稳定性。如果以后两条数据重名,需要换成 rec.name + rec.dist 这类组合 key 避免冲突。

      // 全部师傅列表(长按 Marker 的数据同源)
      Row() {
        Text('全部师傅(地图 Marker 同源)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      }
      .width('100%')
      ForEach(this.fixerList, (fixer: FixerItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text(fixer.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(`工种 ${fixer.craft} · 接单 ${fixer.orders} 单`).fontSize(11).fontColor(COLORS.sub)
              Text(`备注:${fixer.note}`).fontSize(10).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 4 }) {
              Text(`${fixer.rating.toFixed(1)}`).fontSize(16).fontWeight(FontWeight.Bold)
                .fontColor(ratingColor(fixer.rating))
              Text('综合分').fontSize(9).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          Row({ space: 8 }) {
            Text(`${fixer.orders} 单`).fontSize(10)
              .fontColor(orderColor(fixer.orders))
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
            Text('编辑').fontSize(10).fontColor(COLORS.blue)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openEditFixer(idx); })
            Text('删除').fontSize(10).fontColor(COLORS.red)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.delIdx = idx; this.delModal = true; })
          }
          .justifyContent(FlexAlign.End)
          .width('100%')
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (fixer: FixerItem) => fixer.name)
    }
    .width('100%')
  }

全部师傅列表遍历 this.fixerList(七条收藏师傅数据,来自 @State,可增删改)。每张卡片比"在线师傅大卡"信息更全:上行是师傅名、工种接单数、备注(用户自定义文字);右上角是大号综合评分(16px,用 ratingColor 着色);下行是三个胶囊按钮——接单数标签(用 orderColor 着色)、编辑、删除。

这里第一次出现了"备注"字段,这是用户给师傅加的个性化标签,比如"工具箱比我家还全"“半夜也接急单”。备注用最浅的 COLORS.text3 显示,是三级弱信息。点击"编辑"调用 openEditFixer(idx),点击"删除"先记录 this.delIdx = idx 再打开删除确认弹窗——这种"先存索引再开弹窗"的两步操作是为了让弹窗知道要删的是哪条数据。

接单数标签 ${fixer.orders} 单 用 orderColor 着色,1500 单显示绿色(经验丰富),720 单显示黄色(熟练),540 单显示红色(新手)。这种"接单数也分色"的设计让用户能在列表里快速识别哪些是老师傅,配合评分的颜色映射,形成"双信号"的口碑判断——光看颜色就能大致判断一个师傅的水平和经验。

ForEach 的 key 用 fixer.name,前提是师傅名不重复。Mock 数据里七个师傅名都不同,所以这个 key 是稳定的。真实业务里应该用师傅 ID 作为 key,避免重名师傅导致 ForEach 节点复用错误。

6.3 地图Tab(长按事件特性页)

  /** 地图 Tab:★ Map Kit 6.1.1 长按事件特性页 */
  @Builder
  tabMap() {
    Column({ space: 10 }) {
      // 特性说明卡
      Column({ space: 4 }) {
        Text('🗺 Map Kit 6.1.1 · 长按事件监听').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text('长按地图上的服务站 Marker 或 POI 地点,事件将记录到下方日志流')
          .fontSize(10).fontColor(COLORS.sub)
      }
      .padding(10)
      .borderRadius(10)
      .backgroundColor(COLORS.chip)
      .width('100%')

地图 Tab 顶部是一个"特性说明卡",用浅底色(COLORS.chip)+ 圆角的卡片样式,明确告诉用户这一页展示的是 Map Kit 6.1.1 的长按事件监听能力。这种"先说明后展示"的设计降低了用户理解新特性的门槛——用户进地图 Tab 第一眼就知道"这页是干嘛的、该怎么操作"。

说明卡的文字精炼到两行:第一行是特性标题"Map Kit 6.1.1 · 长按事件监听"(13px 加粗),第二行是操作指引"长按地图上的服务站 Marker 或 POI 地点,事件将记录到下方日志流"(10px 灰字)。把"长按"这个手势动作明确写出来,是因为长按不是移动端的常见操作,用户可能不知道地图支持长按,必须引导。

      // 监听开关行:Marker 长按 / POI 长按
      Row({ space: 12 }) {
        Row({ space: 6 }) {
          Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
            .selectedColor(COLORS.blue)
            .width(36)
            .height(20)
            .onChange(() => { this.toggleMarkerListen(); })
          Text('Marker长按').fontSize(11).fontColor(COLORS.sub)
        }
        Row({ space: 6 }) {
          Toggle({ type: ToggleType.Switch, isOn: this.poiListenOn })
            .selectedColor(COLORS.blue)
            .width(36)
            .height(20)
            .onChange(() => { this.togglePoiListen(); })
          Text('POI长按').fontSize(11).fontColor(COLORS.sub)
        }
      }
      .width('100%')

监听开关行用两个 Toggle(Switch 类型)分别控制 Marker 长按和 POI 长按的开关。isOn 绑定到 @State markerListenOn 和 poiListenOn,onChange 里调用 toggleMarkerListen() / togglePoiListen()。selectedColor(COLORS.blue) 让开关打开时是工具蓝色,和主题色一致。

开关的存在让长按事件能力"可按需启停"。默认两个都开(markerListenOn: true、poiListenOn: true),用户可以独立关闭某一种——比如只想监听 Marker 不想监听 POI,就关掉 POI 开关。这种"双独立开关"比"一个总开关"更细粒度,体现了 6.1.1 两对 API(on/offMarkerLongClick + on/offPoiLongClick)的独立性。

Toggle 的 width(36).height(20) 是缩小后的尺寸,标准 Toggle 较大,缩小后能和文字标签紧凑排在一行。两个开关组用 Row({ space: 12 }) 隔开,每组内部 Toggle 和文字用 Row({ space: 6 }) 紧凑排列,形成清晰的"开关—标签"对。

      // ★ MapComponent 本体(layoutWeight(1) 占满剩余高度)
      MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
        .layoutWeight(1)
        .width('100%')
        .borderRadius(12)
      // 长按事件日志流(固定高度可滚动,新事件置顶)
      Column({ space: 6 }) {
        Row() {
          Text('长按事件日志流').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Blank()
          Text(`共 ${this.eventLogs.length} 条`).fontSize(10).fontColor(COLORS.text3)
        }
        .width('100%')
        Scroll() {
          Column({ space: 6 }) {
            ForEach(this.eventLogs, (log: EventLog) => {
              Row({ space: 8 }) {
                Text(log.type === 'Marker' ? '📍' : '🏷')
                  .fontSize(12)
                Column({ space: 2 }) {
                  Row({ space: 6 }) {
                    Text(log.type).fontSize(10).fontColor(log.type === 'Marker' ? COLORS.blue : COLORS.green)
                    Text(log.name).fontSize(11).fontColor(COLORS.title)
                    Text(log.time).fontSize(9).fontColor(COLORS.text3)
                  }
                  Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
                    .fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .padding({ left: 8, right: 8, top: 6, bottom: 6 })
              .borderRadius(8)
              .backgroundColor(COLORS.card)
              .width('100%')
            }, (log: EventLog) => `${log.type}-${log.name}-${log.time}`)
          }
        }
        .scrollBar(BarState.Off)
        .height(120)
        .width('100%')
      }
      .padding(10)
      .borderRadius(12)
      .backgroundColor(COLORS.chip)
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

MapComponent 是鸿蒙 Map Kit 的核心组件,接收两个参数:mapOptions(地图初始化参数,含中心点和缩放)和 mapCallback(初始化完成回调)。layoutWeight(1) 让它占满 Column 中除了说明卡、开关行、日志流之外的剩余高度——这是让地图在 Tab 内"撑满主体区域"的关键。borderRadius(12) 给地图加圆角,让它和周围卡片视觉风格一致,而不是一个突兀的方角矩形。

日志流区块用 Column 包裹一个标题行和一个固定高度 120 的 Scroll。标题行显示"长按事件日志流"和右侧的条数统计 ${this.eventLogs.length} 条,条数会随长按事件增加而实时更新——这是 @State eventLogs 响应式驱动的,数组长度变化即触发 Text 重新渲染。

每条日志用 Row 横向排列:左侧是图标(Marker 用 📍、POI 用 🏷),右侧是一个 Column 含两行——上行是"类型标签 + 名称 + 时间",下行是"经纬度坐标"。类型标签的颜色也按类型区分(Marker 蓝色、POI 绿色),和图标颜色呼应,让用户能扫一眼分辨事件来源。坐标用 fontFamily('monospace') 等宽字体,保证数字位数对齐,看起来更"技术感"。

ForEach 的 key 用 ${log.type}-${log.name}-${log.time} 三段组合,这是因为多条日志可能类型和名称都相同(比如多次长按同一个 Marker),加上时间字段才能保证 key 唯一。实际业务里时间戳可能重复(同一秒多次长按),更稳妥的做法是加一个自增 ID 字段。

整个日志流区块用 backgroundColor(COLORS.chip) 浅底色,和白色卡片形成层次——日志条目是白底(COLORS.card),整体容器是浅灰底,再外面是页面底色 COLORS.bg,三层灰度递进,视觉层次清晰。

6.4 搜索Tab(reliability特性页)

  /** 搜索 Tab:★ Map Kit 6.1.1 reliability 相关性分数特性页 */
  @Builder
  tabSearch() {
    Column({ space: 10 }) {
      // 特性说明卡
      Column({ space: 4 }) {
        Text('🔍 searchByText · reliability 相关性评分').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text('Site 新增 reliability 字段([0,1],1 为完全相关),衡量维修点搜索结果与关键字关联程度')
          .fontSize(10).fontColor(COLORS.sub)
      }
      .padding(10)
      .borderRadius(10)
      .backgroundColor(COLORS.chip)
      .width('100%')

搜索 Tab 顶部同样是特性说明卡,标题"searchByText · reliability 相关性评分"点明了这一页展示的是 6.1.1 新增的 reliability 字段。说明文字明确给出了取值范围 [0,1] 和含义"1 为完全相关",让用户理解分数条的含义——这是把技术字段翻译成用户可感知的"相关度"的关键。

把特性说明放在每个特性 Tab 的最顶部,是一种"教育用户"的设计。鸿蒙 6.1.1 的新特性对大部分用户来说是陌生的,如果不解释清楚,用户看到分数条和等级标签可能不理解含义。说明卡用浅底色卡片样式,视觉上"温和提示"而非"硬性弹窗",不打扰用户的操作流。

      // 搜索框 + 触发按钮
      Row({ space: 8 }) {
        TextInput({ text: this.queryInput, placeholder: '输入关键字,如:家电维修' })
          .layoutWeight(1)
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.card)
          .onChange((v: string) => { this.queryInput = v; })
        Button('搜索')
          .height(38)
          .fontSize(12)
          .backgroundColor(COLORS.blue)
          .onClick(() => { this.runSearch(); })
      }
      .width('100%')
      // 搜索状态文案
      Text(this.searchState).fontSize(10).fontColor(COLORS.text3).width('100%')

搜索框用 TextInput + Button 的经典组合。TextInput 的 text 绑定到 @State queryInput,onChange 里 this.queryInput = v 实现双向绑定。placeholder 给出了示例关键字"家电维修",引导用户输入合理的搜索词。Button 的 onClick 调用 this.runSearch() 触发异步搜索。

搜索状态文案 this.searchState 显示在搜索框下方,会随搜索过程动态变化:初始"待搜索·演示数据",搜索中"搜索中…“,成功"返回 N 条结果”,无结果"无结果·保留演示数据",失败"搜索失败(code)·保留演示数据"。这种状态文案让用户始终知道搜索在什么阶段,是异步操作 UX 的标准实践——比一个 loading 转圈更信息丰富。

      // 搜索结果列表(reliability 分数条 + 等级标签,List 子项必须用 ListItem 包裹)
      List({ space: 8 }) {
        ForEach(this.searchRecords, (rec: SearchRecord) => {
          ListItem() {
            Column({ space: 6 }) {
              Row() {
                Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                  .layoutWeight(1)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(reliabilityScore(rec.reliability).label)
                  .fontSize(10)
                  .fontColor(reliabilityScore(rec.reliability).color)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor(COLORS.card)
              }
              .width('100%')
              Text(rec.address).fontSize(11).fontColor(COLORS.sub).width('100%')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              // ★ reliability 分数条:0~1 映射为线性进度 + 数值文本
              Row({ space: 8 }) {
                Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
                  .layoutWeight(1)
                  .height(6)
                  .color(reliabilityScore(rec.reliability).color)
                Text(`reliability ${rec.reliability.toFixed(2)}`)
                  .fontSize(10)
                  .fontColor(COLORS.sub)
                  .fontFamily('monospace')
              }
              .width('100%')
              Row({ space: 10 }) {
                Text(`直线距离 ${(rec.distance / 1000).toFixed(2)}km`).fontSize(10).fontColor(COLORS.text3)
                Text(rec.time).fontSize(10).fontColor(COLORS.text3)
              }
              .width('100%')
            }
            .padding(12)
            .borderRadius(12)
            .backgroundColor(COLORS.card)
            .width('100%')
          }
        }, (rec: SearchRecord) => `${rec.name}-${rec.reliability}`)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')

搜索结果列表用 List + ListItem + ForEach 渲染。注意 List 的直接子元素必须是 ListItem,这是 List 组件的硬约束(和 Scroll+Column 不同),否则会编译报错。每条结果是一个 Column 容器,包含四行内容:上行是"地点名 + 等级标签"、第二行是地址、第三行是 reliability 分数条、第四行是距离和时间。

地点名用 maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) 限制单行+省略号,避免长名字撑破布局。等级标签 reliabilityScore(rec.reliability).label 显示"高相关/中相关/低相关",颜色用 .color 动态着色,背景是白色 COLORS.card——这种"彩色文字+白底胶囊"的标签样式在浅灰底色上很显眼,让用户一眼就能定位每条结果的相关度等级。

reliability 分数条是这一Tab的视觉核心:Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear }) 把 [0,1] 的小数映射到 0-100 的进度区间,用线性进度条展示。进度条颜色用 reliabilityScore(rec.reliability).color 动态着色——0.95 的高相关显示满格绿色长条,0.28 的低相关显示一小段红色短条,视觉冲击力强。进度条右侧是数值文本 reliability 0.95,用等宽字体 monospace 保证数字对齐。

距离文案 ${(rec.distance / 1000).toFixed(2)}km 把米换算成公里保留两位小数,比"950 米"更简洁。时间用"刚刚"这种相对文案。整条结果卡通过"名称+标签+地址+分数条+距离时间"五个信息层,把 Site 的关键字段(name/formatAddress/distance/reliability)完整展示,让用户能基于相关性分数做"是否预约这个师傅"的决策。

ForEach 的 key 用 ${rec.name}-${rec.reliability},组合了名称和分数。这里有个细节——如果两条结果同名同分(理论上可能),key 会冲突。更稳妥的做法是加索引或唯一 ID,但本页 Mock 数据里每条 name 和 reliability 都不同,所以这个 key 是稳定的。

      // 双特性代码预览卡(体现技术点)
      this.codePreviewCard()
    }
    .width('100%')
    .height('100%')
  }

搜索 Tab 最底部调用了 this.codePreviewCard(),这是一个"代码预览卡"Builder,用深色底+等宽字体展示 6.1.1 两大新特性的核心调用代码。把它放在搜索 Tab 而非地图 Tab,是因为搜索 Tab 已经展示了 reliability 特性,再附一段代码预览能强化"这是 6.1.1 新能力"的认知,形成"特性说明→搜索结果→代码预览"的完整教育链路。

6.5 我的Tab

  /** 我的 Tab:管家会员卡 + 报修统计 + 功能清单 */
  @Builder
  tabMine() {
    Column({ space: 10 }) {
      // 会员渐变大卡
      Column({ space: 8 }) {
        Row({ space: 12 }) {
          Text('🛠').fontSize(34)
          Column({ space: 3 }) {
            Text('修立得 · 家庭管家会员').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('绿标级 · 上门免跑腿费 · 修后质保 90 天').fontSize(11).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Divider().strokeWidth(1).color(COLORS.line)
        Row() {
          Text('本月报修 3 单').fontSize(11).fontColor(COLORS.sub)
          Blank()
          Text('已省跑腿费 60 元').fontSize(11).fontColor(COLORS.green)
        }
        .width('100%')
      }
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.blueD, 0.0], [COLORS.card, 0.75]]
      })
      .width('100%')

我的 Tab 顶部是一个会员渐变大卡,和头部 Banner 用了相同的 linearGradient 配方(深工具蓝→白色,135 度对角),形成视觉呼应——告诉用户"这是一个有品牌一致性的产品"。卡片左侧是一个 34px 的大号工具 emoji 🛠,右侧是会员标题和权益说明"绿标级·上门免跑腿费·修后质保 90 天"。

卡片下半部分用 Divider 分割后展示两个运营数据:“本月报修 3 单"和"已省跑腿费 60 元”。后者用绿色(COLORS.green),强调"省钱"这个正向信号。把会员权益和省钱数据放在一起,强化了"开会员划算"的认知,是典型的会员转化设计。

      // 报修统计三宫格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('8').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.blue)
          Text('收藏师傅').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('26').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
          Text('累计报修').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
        Column({ space: 2 }) {
          Text('2').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
          Text('质保订单').fontSize(9).fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(10)
        .backgroundColor(COLORS.card)
      }
      .width('100%')

我的 Tab 也有一个报修统计三宫格,和师傅 Tab 的三宫格结构相同,但数据维度不同——这里是"收藏师傅/累计报修/质保订单",对应"用户资产"维度;师傅 Tab 是"收藏师傅/本月报修/质保期内",对应"近期活跃"维度。两处三宫格的视觉风格完全一致(蓝/绿/橙三色),但数据语义不同,体现了"同一组件不同数据"的复用思想。

数字字号 18px(比师傅 Tab 的 20px 略小),是因为我的 Tab 的三宫格是次级信息(主信息是会员卡),字号小一些让视觉层次更分明——会员卡是焦点,统计宫格是补充。

      // 功能清单
      ForEach(this.funcList, (item: FuncItem) => {
        Row({ space: 10 }) {
          Text(item.icon).fontSize(18)
          Text(item.label).fontSize(13).fontColor(COLORS.title).layoutWeight(1)
          Text(item.value).fontSize(11).fontColor(COLORS.sub)
          Text('›').fontSize(14).fontColor(COLORS.text3)
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .width('100%')
      }, (item: FuncItem) => item.label)
    }
    .width('100%')
  }

功能清单遍历 this.funcList(八条功能项),每条是一个 Row 行:左侧 emoji 图标(18px)、中间功能名(13px 主色,layoutWeight(1) 占满中间)、右侧状态/数值(11px 灰字)、最右一个 › 箭头(14px 弱灰)。这是"设置页/我的页"最常见的列表行样式,箭头 › 暗示"可点击进入详情"。

八条功能覆盖了家政维修用户的完整生命周期:累计报修、我的房屋、下次保养、收藏师傅、维修账单、质保订单、上门到达提醒、偏好设置。其中"下次保养·空调清洗·3 天后"这种带时间预警的项很有业务感——它不是静态展示,而是主动提醒用户"该保养了",是提升复购的关键设计。

ForEach 的 key 用 item.label,前提是功能名不重复。八条功能名都不同,key 稳定。这种"左图标+中标题+右状态+箭头"的标准列表行,通过 ForEach 遍历常量数组生成,是 ArkUI 列表渲染的最常见模式。

6.6 代码预览卡

  /** 双特性代码预览卡(深色底 monospace 展示 6.1.1 新调用) */
  @Builder
  codePreviewCard() {
    Column({ space: 6 }) {
      Text('⌨️ Map Kit 6.1.1 双新特性调用').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Column({ space: 4 }) {
        Text('const result = await site.searchByText(params)')
          .fontSize(9).fontColor(COLORS.green).fontFamily('monospace')
        Text('const score = site.reliability  // [0,1] 维修点相关度')
          .fontSize(9).fontColor(COLORS.green).fontFamily('monospace')
        Text('eventManager.onMarkerLongClick(cb)  // 服务站标记长按')
          .fontSize(9).fontColor(COLORS.blue).fontFamily('monospace')
        Text('eventManager.onPoiLongClick(cb)     // 服务站POI长按')
          .fontSize(9).fontColor(COLORS.blue).fontFamily('monospace')
      }
      .padding(10)
      .borderRadius(8)
      .backgroundColor(COLORS.codeBg)
      .width('100%')
    }
    .padding(10)
    .borderRadius(10)
    .backgroundColor(COLORS.chip)
    .width('100%')
  }

codePreviewCard 是一个"代码预览"Builder,用深底色(COLORS.codeBg,即 #22282F)+ 等宽字体(monospace)模拟代码编辑器的视觉。四行代码分别展示 6.1.1 两大新特性的核心调用:前两行绿色字是搜索特性(searchByText + reliability 读取),后两行蓝色字是长按事件特性(onMarkerLongClick + onPoiLongClick)。

颜色分配有讲究:搜索相关用绿色(COLORS.green),呼应搜索结果里"高相关"的绿色;事件相关用蓝色(COLORS.blue),呼应 Marker 长按日志里"Marker"类型的蓝色。这种"代码预览的颜色和实际特性展示的颜色同源"的设计,让用户在视觉上能把代码和效果关联起来,理解"这行代码对应那个效果"。

字号 9px 非常小,是因为这是辅助信息——用户主要看的是搜索结果列表,代码预览是给"想了解底层实现"的进阶用户看的。把它放在搜索 Tab 最底部,不抢主内容的视觉焦点,但需要时可以下滑看到。这种"主次分明的信息层级"是移动端页面设计的关键原则。

6.7 底部TabBar

  /** 底部导航 Tab 栏(4 Tab 单排) */
  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(tab.icon).fontSize(18)
          Text(tab.label).fontSize(10)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
        }
        .layoutWeight(1)
        .onClick(() => { this.currentTab = idx; })
      }, (tab: TabMeta) => tab.label)
    }
    .padding({ top: 8, bottom: 8 })
    .width('100%')
    .backgroundColor(COLORS.card)
  }

底部 TabBar 用 Row + ForEach 渲染四个 Tab,每个 Tab 是一个 Column(图标+标签纵向排列),layoutWeight(1) 等分宽度。选中态通过 this.currentTab === idx 判断,标签颜色在 COLORS.tabOn(选中,工具蓝)和 COLORS.text3(未选中,浅灰)之间切换。

onClick 里 this.currentTab = idx 修改选中索引,驱动整个页面的 Tab 切换——build 里的 if (this.currentTab === 0) 分支会重新求值,渲染对应的 tabFixer/tabMap/tabSearch/tabMine。这种"一个状态变量驱动整页切换"的轻量级 Tab 实现比 Tabs 容器更灵活,可以自由控制切换动画、状态保留等行为,代价是需要手动管理每个 Tab 的滚动位置等状态。

ForEach 的 key 用 tab.label,四个标签"师傅/地图/搜索/我的"都不同,key 稳定。padding({ top: 8, bottom: 8 }) 给 TabBar 上下留白,backgroundColor(COLORS.card) 白底让 TabBar 和上方可滚动内容区分开,形成"内容区+底部导航"的两段式布局。

6.8 弹窗系统

  /** 弹窗遮罩层(点击空白处关闭) */
  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => { onClose(); })
  }

modalOverlay 是弹窗遮罩层 Builder,接收一个 onClose 回调。它本身是一个铺满全屏的透明 Column,背景色是 COLORS.mask(rgba(34,40,47,0.42) 半透明蓝灰),onClick 调用 onClose()——点击遮罩任意位置都能关闭弹窗,这是弹窗的标准交互(点空白处关闭)。

把遮罩抽成独立 Builder 是为了复用——三个弹窗(panelAdd/panelEdit/panelDel)都用同一个 modalOverlay,保证遮罩视觉一致。onClose 作为参数传入,让每个弹窗能用自己的关闭逻辑(虽然本页都是 this.xxxModal = false,但留了这个参数灵活性更高)。

  /** 收藏师傅弹窗:师傅名 + 擅长工种 + 常驻地址输入 */
  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('收藏新师傅').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        TextInput({ placeholder: '师傅称呼', text: this.formName })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formName = v; })
        TextInput({ placeholder: '擅长工种(如:水电急修)', text: this.formTag })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formTag = v; })
        TextInput({ placeholder: '常驻地址(可留空地图选点)', text: this.formAddr })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.formAddr = v; })
        Row({ space: 10 }) {
          Button('取消')
            .layoutWeight(1)
            .fontSize(12)
            .backgroundColor(COLORS.chip)
            .fontColor(COLORS.sub)
            .onClick(() => { onClose(); })
          Button('收藏')
            .layoutWeight(1)
            .fontSize(12)
            .backgroundColor(COLORS.blue)
            .onClick(() => { this.saveFixer(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

panelAdd 是收藏师傅弹窗,用 Stack 堆叠 modalOverlay(遮罩层)和内容 Column。内容区宽度 82%,居中显示在屏幕上(Stack 默认居中),borderRadius(14) 圆角 + 白底,形成"卡片浮在遮罩上"的视觉。这种"Stack+遮罩+内容卡"是 ArkUI 实现自定义弹窗的标准模式,比 CustomDialog 装饰器更灵活可控。

弹窗内三个 TextInput 分别绑定 formName/formTag/formAddr 三个 @State,onChange 实现双向绑定。placeholder 给出输入提示(“师傅称呼”“擅长工种(如:水电急修)”“常驻地址(可留空地图选点)”),引导用户填写。backgroundColor(COLORS.chip) 让输入框是浅灰底,和白底弹窗形成层次。

底部两个按钮"取消"和"收藏"用 Row({ space: 10 }) + 各自 layoutWeight(1) 实现等宽两按钮。取消按钮浅底灰字(COLORS.chip + COLORS.sub),收藏按钮蓝底白字(COLORS.blue,注意 Button 默认文字色是白色,无需显式设置)。点击取消调用 onClose() 关闭弹窗,点击收藏调用 this.saveFixer() 保存数据(方法内部会关闭弹窗)。

  /** 编辑备注弹窗:回填当前师傅备注 */
  @Builder
  panelEdit(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('编辑师傅备注').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text(this.editIdx < this.fixerList.length ? this.fixerList[this.editIdx].name : '')
          .fontSize(11)
          .fontColor(COLORS.sub)
          .width('100%')
        TextInput({ placeholder: '输入新备注', text: this.editNote })
          .height(38)
          .fontSize(12)
          .fontColor(COLORS.title)
          .placeholderColor(COLORS.text3)
          .backgroundColor(COLORS.chip)
          .onChange((v: string) => { this.editNote = v; })
        Row({ space: 10 }) {
          Button('取消')
            .layoutWeight(1)
            .fontSize(12)
            .backgroundColor(COLORS.chip)
            .fontColor(COLORS.sub)
            .onClick(() => { onClose(); })
          Button('保存')
            .layoutWeight(1)
            .fontSize(12)
            .backgroundColor(COLORS.blue)
            .onClick(() => { this.updateFixer(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

panelEdit 是编辑备注弹窗,结构和 panelAdd 对称。区别在于:标题下多了一行师傅名展示(this.fixerList[this.editIdx].name),让用户知道在编辑谁的备注;只有一个 TextInput 绑定 editNote(备注字段);保存按钮调用 this.updateFixer()。

师傅名展示用了边界检查 this.editIdx < this.fixerList.length ? ... : '',避免 editIdx 越界时访问 fixerList[editIdx] 拿到 undefined 导致 .name 报错。这种防御式写法在异步弹窗场景下很重要——用户打开弹窗后如果列表被其他操作改动(比如同时打开了删除弹窗删了一条),editIdx 可能指向已不存在的元素,边界检查能优雅降级为空字符串。

TextInput 的 text: this.editNote 是回填——openEditFixer 方法在打开弹窗时已经把当前师傅的备注赋值给了 editNote,弹窗渲染时 TextInput 显示这个值,用户看到的是"现有备注",可以基于此修改。这是"编辑"和"新增"弹窗的关键区别——新增是空的,编辑是回填的。

  /** 删除确认弹窗:师傅名 + 确认/取消 */
  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('删除收藏师傅').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text(this.delIdx < this.fixerList.length
          ? `确定删除「${this.fixerList[this.delIdx].name}」吗?` : '确定删除吗?')
          .fontSize(12)
          .fontColor(COLORS.sub)
          .width('100%')
        Row({ space: 10 }) {
          Button('取消')
            .layoutWeight(1)
            .fontSize(12)
            .backgroundColor(COLORS.chip)
            .fontColor(COLORS.sub)
            .onClick(() => { onClose(); })
          Button('删除')
            .layoutWeight(1)
            .fontSize(12)
            .backgroundColor(COLORS.red)
            .onClick(() => { this.delFixer(); })
        }
        .width('100%')
      }
      .padding(16)
      .borderRadius(14)
      .backgroundColor(COLORS.card)
      .width('82%')
    }
    .width('100%')
    .height('100%')
  }

panelDel 是删除确认弹窗,没有 TextInput,只有标题、确认文案、两个按钮。确认文案动态拼接师傅名:确定删除「王师傅」吗?,让用户明确知道要删的是谁,避免误删。同样做了边界检查 this.delIdx < this.fixerList.length,越界时降级为通用的"确定删除吗?"。

删除按钮用红色 COLORS.red,这是危险操作的标准色——和收藏按钮的蓝色、保存按钮的蓝色形成对比,让用户在视觉上感受到"这个按钮不可轻易点"。红色按钮+确认文案的双重提醒,是"破坏性操作必须二次确认"这一 UX 原则的落地实现。

三个弹窗的视觉结构完全一致(Stack+遮罩+82%白底卡片+标题+内容+双按钮行),保证了弹窗系统的视觉一致性。差异只在内容区——panelAdd 三个输入框、panelEdit 一个输入框+师傅名展示、panelDel 只有确认文案。这种"统一外壳+差异内容"的弹窗设计模式,既保证了视觉规范,又兼顾了不同业务场景的表单需求。

七、对比表格

维度reliability 相关性分数长按事件监听(Marker+POI)
所属模块site 模块(搜索)map.MapEventManager(事件)
HarmonyOS 版本6.1.1 新增字段6.1.1 新增监听对
API 形态site.Site.reliability: numberonMarkerLongClick/offMarkerLongClick、onPoiLongClick/offPoiLongClick
取值/参数[0,1] 小数,1 为完全相关回调参数为 map.Marker 或 mapCommon.Poi
触发时机searchByText 返回结果时携带用户在地图上长按 Marker 或 POI 时触发
业务价值搜索结果分级、过滤、排序,避免"关键字命中但语义不匹配"地图选点交互、收藏坐标、记录用户行为日志
UI 呈现分数条(Progress)+ 等级标签(高/中/低相关)事件日志流(unshift 置顶)
颜色映射≥0.8 绿/≥0.5 黄/其余红Marker 蓝/POI 绿
容错策略?? 0 兜底,降级为低相关if (!this.mapEventManager) return 防空
关闭/重置无(只读字段)offXxxLongClick() 不传参清除全部订阅
数据载体SearchRecord 类EventLog 类
所在 Tab搜索 Tab地图 Tab

八、总结

本篇围绕鸿蒙 HarmonyOS 6.1.1 Map Kit 的两大新特性——site.searchByText 返回的 Site.reliability 相关性分数字段、MapEventManager 新增的 onMarkerLongClick/offMarkerLongClick 与 onPoiLongClick/offPoiLongClick 长按事件监听对——在一个"修立得·家政维修上门"浅色主题页面里做了完整的工程落地。两大特性分别落在搜索 Tab 和地图 Tab,前者把抽象的"相关性"变成了用户可感知的分数条+三色等级标签,后者把地图上的长按手势变成了结构化的事件日志流,两者通过底部 Tab 切换统一在一个 Stack 容器内渲染,弹窗系统叠加在最上层。

从架构层面看,整个页面遵循"接口先行—常量铺底—Observed 模型—组件状态—Builder 渲染"的分层范式。颜色用 ColorPalette 接口集中声明、COLORS 常量实现,保证视觉一致性;Tab、筛选标签、服务站 Marker、推荐师傅、功能清单等数据都用 Mock 常量预置,让页面开箱即用;FixerItem/SearchRecord/EventLog 三个 @Observed 类承载可变数据,配合 @State 数组实现响应式刷新;setupMapCallback 负责地图初始化、Marker 批量下发、双长按监听注册,是两大特性的"装配车间";runSearch 负责调用 site.searchByText 并读取 reliability,是搜索特性的消费入口。

从落地经验看,6.1.1 的两大新特性都需要注意容错。reliability 作为新增字段,在老版本 SDK 或异常返回时可能是 undefined,必须用 ?? 0 兜底为低相关,避免 UI 展示 undefined 或计算异常。长按事件的 mapEventManager 是在 mapCallback 回调里获取的,回调外它是 undefined,所以 toggleMarkerListen/togglePoiListen 必须先 if (!this.mapEventManager) return 防御,避免用户在地图初始化完成前点开关导致崩溃。offMarkerLongClick/offPoiLongClick 不传参时清除该类型全部订阅,重新开启时必须重新挂载回调,这是 6.1.1 事件 API 的关键语义。

从业务价值看,家政维修上门是一个"低频刚需、强信任、强时效"的行业,用户最关心的是"附近有没有靠谱师傅、多久能到、修完有没有质保"。reliability 字段让搜索结果不再是"关键字命中的全部地点",而是"按相关度排序的优质维修点"——把"电器卖场"“家政保洁"这类近义但非上门维修的结果用低相关红色标出,避免用户误预约。长按事件让用户能在地图上对任意服务站 Marker 或 POI 地点执行"长按记录坐标"的操作,为后续的"地图选点报修”"收藏师傅常驻位置"等场景打下交互基础。两大特性合起来,恰好覆盖了"找到师傅"和"在地图上锁定师傅位置"两个核心环节,是鸿蒙 Map Kit 在 O2O 上门服务场景的典型应用。

从工程范式看,本页还体现了几条 ArkUI 的最佳实践:弹窗系统用 Stack+遮罩+82%白底卡片的统一外壳,差异只在内容区,保证视觉规范;@State 数组的刷新用 .slice() 整体替换引用(updateFixer 里),比依赖 @Observed 的属性感知更稳妥;ForEach 的 key 必须稳定且唯一,避免用 index 作为 key 导致节点复用错误;MapComponent 的 mapOptions 必须在 aboutToAppear 里就准备好,避免 build 里传 undefined;异步回调(mapCallback、runSearch)必须 err-first 判断,失败时保留现有状态(Mock 数据),保证弱网下 UI 不崩。这些细节合起来,构成了一个"既展示了 6.1.1 新特性、又符合生产级代码规范"的鸿蒙 ArkUI 页面范本。

鸿蒙生态在持续演进,Map Kit 的能力也在持续扩展。reliability 字段的出现,标志着鸿蒙地图搜索从"关键字命中"走向了"语义相关度量化";长按事件监听的出现,标志着鸿蒙地图交互从"点击"走向了"多手势、多实体"的丰富交互。对于 O2O 上门服务、本地生活、出行导航这类强地图依赖的应用,这两大特性都是值得优先接入的能力——它们不仅提升了用户体验,更让应用能基于"相关度"和"长按意图"做更智能的业务决策,是鸿蒙原生应用差异化竞争的关键点。

附录: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

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

更多推荐