地点联想:改用鸿蒙自带 Map Kit,不再走高德 laoyang/FamilyBook#33

用户指示「端侧不用高德,用鸿蒙自带的接口」。

改用 @kit.MapKit 的 site.queryAutoComplete(),顺带解决原方案两个问题:
  ① 高德 Web 服务 key 是服务端密钥,本不该出现在客户端;
  ② 线上那个 key 实际是 JS API 类型、调 Web 服务接口一直失败(见 #33 排查记录)。
现在端上不需要任何外部 key,也不需要我们自己的服务端接口与配额。

改动:
- FbGeoRepo:site.queryAutoComplete({query}) -> sites[]
  取 name + formatAddress + location。
  formatAddress 的 SDK 注释原文是 "Formatted detailed address of a place",
  正是用户要的「作为参考的详细地址」。
  formatAddress 缺失时用 locality + streetName + streetNumber 兜底。
- FbAddrInput:去掉 token 参数(MapKit 鉴权走 AGC 应用认证,与 API 令牌无关)。
- FbEventEditSheet / FbTripNodeSheet:两处调用同步去掉 token。

错误码全部降级为空结果、不打扰用户(声明文件列明的):
  - 1002600003 App authentication failed
  - 1002600004 The Map permission is not enabled
  - 1002603001 Zero result(查不到,属正常业务结果,不刷错误日志)
  - 1002600002 Failed to connect to the Map Kit server
联想挂掉 => 退化成普通文本框,手敲照常保存。

字段名一律以声明文件 @hms.core.map.site.d.ts 为准:
StreetNumber 的字段是 streetName / streetNumber,
第一版凭印象写成 street / number,编译直接报
"Property 'street' does not exist on type 'StreetNumber'",已修正。

编译通过(33 tasks,17 executed)。
This commit is contained in:
2026-10-30 08:12:15 +08:00
parent f4f27cea55
commit 7bc26dd7e1
4 changed files with 159 additions and 76 deletions
@@ -44,8 +44,6 @@ export struct FbAddrInput {
@Param placeholder: string = '';
/** 当前值(父组件持有,**受控**) */
@Param value: string = '';
/** API 令牌(空串 = 不联想,纯文本框) */
@Param token: string = '';
/** 最多显示几条候选(默认 8;端上视口有限) */
@Param limit: number = 8;
/** 值变化(用户打字 / 选中候选都会触发) */
@@ -97,7 +95,9 @@ export struct FbAddrInput {
private async search(kw: string): Promise<void> {
this.seq += 1;
const my: number = this.seq;
const list: FbGeoTip[] = await fbGeoTips(kw, this.token, this.limit);
/* ⚠️ 不再传 token:改用鸿蒙自带 Map Kit 的 `site.queryAutoComplete()`,
鉴权走 AGC 应用认证,与我们的 API 令牌无关(见 FbGeoRepo 文件头)。 */
const list: FbGeoTip[] = await fbGeoTips(kw, this.limit);
if (my !== this.seq) {
return; // 已经有一次更新的输入了,这份丢弃
}
@@ -533,7 +533,6 @@ export struct FbEventEditSheet {
label: '地点',
placeholder: '地点(可选,输入后可从下拉选)',
value: this.fPlace,
token: this.token(),
onValue: (v: string): void => {
this.fPlace = v;
}
@@ -222,7 +222,6 @@ export struct FbTripNodeSheet {
label: '地点',
placeholder: '如「青岛北站」「崂山区·沙子口」',
value: this.place,
token: FB_TOKEN_STORE.get(),
onValue: (v: string): void => {
this.place = v;
}
+156 -71
View File
@@ -1,102 +1,187 @@
/**
* 家书 FamilyBook · 地址联想仓库(鸿蒙端)
*
* 对应服务端 `GET /api/v1/geo/tips?kw=<关键词>&limit=<条数>`。
* 用**鸿蒙自带**的 Map Kit(花瓣地图服务)地点联想,**不调高德**。
*
* 🔴 2026-10-30(工单 #36)用户原话:
* 🔴 2026-10-30(工单 #33)用户原话:
* 「在所有调用高德显示详细地址的地方,也就是当我输入一个简单地址、
* 下拉显示详细地址时,不能只把地点告诉我,一定要把详细地址也放在后面。
* 因为如果只是把地址给我,同一个地址可能有好几个,
* 我根本不知道应该选哪一个。
* 所以应该把详细地址也放在后面,作为参考,让用户可以更好地选择。」
*
* 网页端(`assets/js/addr_suggest.js`)早已是这个行为,本次同步到端上。
* 🔴🔴 2026-10-30 用户追加指示:「端侧不用高德,用鸿蒙自带的接口。」
* ⇒ 改用 `@kit.MapKit` 的 `site.queryAutoComplete()`。
* 这个改动同时省掉了原来那套服务端代理的麻烦:
* ① 高德 Web 服务 key 是服务端密钥,本就不该出现在客户端
* (而且线上那个 key 实际是 JS API 类型、调 Web 服务接口一直失败,
* 见工单 #33 的排查记录);
* ② 不需要我们自己的服务端接口与配额;
* ③ 数据源与设备上的华为地图一致,点选后导航也更对得上。
*
* ⚠️ 为什么端上不直连高德、而要走服务端代理:
* 高德 Web 服务 key 是**服务端密钥**,打进安装包等于公开
* (反编译/抓包即可拿到,配额被跑光、key 还可能被封)。
* 网页端用的是 JS API key + 安全密钥(域名白名单那套),原生端没有等价机制。
* ⇒ 走代理。这也顺带把高德 tips 三套不齐的字段在服务端归一好了,
* 端上只管画「名字 + detail」两行。
* 📌 接口来源与关键字段(`@hms.core.map.site.d.ts`,已核对声明文件):
* `site.queryAutoComplete({ query, location?, radius?, language? })`
* → `Promise<QueryAutoCompleteResult>`,其中 `sites?: Array<Site>`
* `Site` 字段:
* · `name?: string` 地点名(用户认的名字)
* · `formatAddress?: string` **格式化后的详细地址** ← 用户要的"参考"
* · `addressComponent` 结构化地址(locality / city / streetNumber…)
* · `location?: mapCommon.LatLng` 经纬度(将来精确导航可用)
*
* ⚠️ 这是**可选能力**:接口挂了/没配 key/没网,一律返回空数组,
* 输入框仍是普通文本框,手敲照常保存(不能让联想拖垮主流程)。
* ⚠️ 可能抛的错误码(声明文件里写明的),全部**降级为空结果**,不打扰用户:
* · `1002600003 App authentication failed` —— AGC 应用认证没过
* · `1002600004 The Map permission is not enabled` —— AGC 没开 Map Kit 服务
* · `1002603001 Zero result` —— 查不到(**正常情况**,不是错误)
* · `1002600002 Failed to connect to the Map Kit server`
*
* ⚠️ 这是**可选能力**:任何失败都返回空数组 ⇒ 输入框退化成普通文本框,
* 用户手敲照常保存。联想挂掉绝不能拖垮"记一条日程"这件正事。
*/
import { site } from '@kit.MapKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbGeoTip } from '../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbGeoRepo';
/** 关键词最短长度:少于 2 个字不查(与网页端、服务端同一个阈值) */
/** 关键词最短长度:少于 2 个字不查(与网页端同一个阈值) */
export const FB_GEO_MIN_LEN: number = 2;
/**
* 地址联想。
* Map Kit 的经纬度字段(`mapCommon.LatLng`)。
*
* @param kw 用户已输入的关键词
* @param token API 令牌(服务端要求登录:这是要花配额的接口)
* @param limit 最多要几条(默认 10;端上视口有限,别要太多)
* @returns 候选列表;**任何失败都返回空数组**,不抛错
* ⚠️ 单独声明而不是 `import { mapCommon } from '@kit.MapKit'`:
* 只为读 `longitude/latitude` 两个数字,结构上完全兼容
* (`LatLng` 就是这两个字段)。少一个 import 也让 `Site` 的类型推断更直接。
*/
export async function fbGeoTips(kw: string, token: string, limit: number = 10): Promise<FbGeoTip[]> {
const q: string = kw.trim();
/* 本地先挡一道:省一次网络往返,也让"字太少"的抖动更小 */
if (q.length < FB_GEO_MIN_LEN || token === '') {
return [];
}
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:对象字面量必须有**显式类型**,
否则编译报 10605038。这里显式标 `FbApiOptions`,
内层 query 也标 `Record<string, string>`。 */
const query: Record<string, string> = {
'kw': q,
'limit': limit.toString()
};
const opts: FbApiOptions = {
token: token,
query: query
};
const res: FbApiResult = await fbApi('/api/v1/geo/tips', opts);
if (!res.ok) {
/* ⚠️ 只记日志、不打扰用户:联想失败时用户正对着输入框,
弹一个"地址联想失败"只会挡住他自己打字。 */
hilog.warn(DOMAIN, TAG, '地址联想失败:%{public}s', res.error);
return [];
}
const out: FbGeoTip[] = [];
try {
const rows: Record<string, Object>[] = JSON.parse(res.data) as Record<string, Object>[];
for (const r of rows) {
const name: string = str(r, 'name');
if (name === '') {
continue;
}
out.push({
name: name,
detail: str(r, 'detail'),
district: str(r, 'district'),
address: str(r, 'address'),
lng: str(r, 'lng'),
lat: str(r, 'lat')
});
}
} catch (err) {
hilog.error(DOMAIN, TAG, '地址联想解析失败:%{public}s', JSON.stringify(err));
return [];
}
return out;
interface FbLatLng {
longitude: number;
latitude: number;
}
/** 从「任意 JSON 对象」里安全取字符串(高德/服务端都可能给 null 或数字) */
function str(o: Record<string, Object>, k: string): string {
const v: Object | undefined = o[k];
if (typeof v === 'string') {
return (v as string).trim();
/**
* 地址联想(Map Kit 自动补全)。
*
* @param kw 用户已输入的关键词
* @param limit 最多要几条(默认 8;端上下拉面板视口有限)
* @returns 候选列表;**任何失败都返回空数组**,不抛错
*
* ⚠️ 不需要 token:Map Kit 是本机 HMS Core 能力,
* 鉴权走 AGC 应用认证(错误码 1002600003),与我们的 API 令牌无关。
*/
export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
const q: string = kw.trim();
/* 本地先挡一道:省一次往返,也让"字太少"的抖动更小 */
if (q.length < FB_GEO_MIN_LEN) {
return [];
}
if (typeof v === 'number') {
return (v as number).toString();
try {
const params: site.QueryAutoCompleteParams = { query: q };
const res: site.QueryAutoCompleteResult = await site.queryAutoComplete(params);
const sites: site.Site[] | undefined = res.sites;
if (sites === undefined || sites.length === 0) {
/* ⚠️ 查不到**不是错误**:用户打了半截字本来就可能没结果。
直接给空,让面板收起,别弹提示。 */
return [];
}
const out: FbGeoTip[] = [];
for (const s of sites) {
const name: string = (s.name !== undefined ? s.name : '').trim();
/* 🔴 `formatAddress` 就是"作为参考的详细地址"(SDK 注释原文:
"Formatted detailed address of a place")。
个别条目(纯行政区划)可能没有它 ⇒ 退回到结构化字段拼一个;
再不行就留空(端上会整行不画,不留一条空灰线)。 */
let detail: string = (s.formatAddress !== undefined ? s.formatAddress : '').trim();
if (detail === '') {
detail = composeAddress(s);
}
/* 名字和地址都空 ⇒ 面板上就是一行空白,跳过 */
if (name === '' && detail === '') {
continue;
}
let lng: string = '';
let lat: string = '';
const loc: FbLatLng | undefined = s.location as FbLatLng | undefined;
if (loc !== undefined && loc !== null) {
lng = numberToStr(loc.longitude);
lat = numberToStr(loc.latitude);
}
out.push({
name: name !== '' ? name : detail,
detail: detail,
district: localityOf(s),
address: detail,
lng: lng,
lat: lat
});
if (out.length >= limit) {
break;
}
}
return out;
} catch (err) {
const e: BusinessError = err as BusinessError;
/* `1002603001 Zero result` 是"查不到",属正常业务结果,不当错误刷日志 */
if (e.code !== 1002603001) {
hilog.warn(DOMAIN, TAG, '地址联想失败:code=%{public}d %{public}s', e.code, e.message);
}
return [];
}
}
/** 数字转字符串(Map Kit 给 number;对外统一成字符串,与网页端一致) */
function numberToStr(v: number): string {
if (typeof v !== 'number' || isNaN(v)) {
return '';
}
/* 保留 6 位小数:与网页端高德 `location` 的精度一致,够定位到门口 */
return v.toFixed(6);
}
/**
* 从 `addressComponent` 里拼一个地址(`formatAddress` 缺失时的兜底)。
*
* ⚠️ Map Kit 的 `AddressComponent` 里 **`city` 只有 `cityCode`/`cityId`、没有城市名**,
* 所以这里能用的主要是:
* · `locality`(地区名,通常是"青岛市崂山区"这种)
* · `streetNumber.streetName` / `.streetNumber`(街道 + 门牌)
* ⚠️ 字段名是 **`streetName`** 与 **`streetNumber`**,不是 `street`/`number`
* (声明文件 `@hms.core.map.site.d.ts` 的 `interface StreetNumber` 里就这两个,
* 我第一版凭印象写成 `street`/`number`,编译直接报
* "Property 'street' does not exist on type 'StreetNumber'")。
* 拼不出就返回空串 —— **绝不瞎编**。
*/
function composeAddress(s: site.Site): string {
const parts: string[] = [];
const ac: site.AddressComponent | undefined = s.addressComponent;
if (ac !== undefined && ac !== null) {
if (ac.locality !== undefined && ac.locality !== '') {
parts.push(ac.locality);
}
const sn: site.StreetNumber | undefined = ac.streetNumber;
if (sn !== undefined && sn !== null) {
if (sn.streetName !== undefined && sn.streetName !== '') {
parts.push(sn.streetName);
}
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
parts.push(sn.streetNumber);
}
}
}
return parts.join(' ').trim();
}
/** 取"地区名"(放进 `district`;端上一般不直接显示,留给将来分组) */
function localityOf(s: site.Site): string {
const ac: site.AddressComponent | undefined = s.addressComponent;
if (ac !== undefined && ac !== null && ac.locality !== undefined) {
return ac.locality;
}
return '';
}