地点联想:改用鸿蒙自带 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:
@@ -44,8 +44,6 @@ export struct FbAddrInput {
|
|||||||
@Param placeholder: string = '';
|
@Param placeholder: string = '';
|
||||||
/** 当前值(父组件持有,**受控**) */
|
/** 当前值(父组件持有,**受控**) */
|
||||||
@Param value: string = '';
|
@Param value: string = '';
|
||||||
/** API 令牌(空串 = 不联想,纯文本框) */
|
|
||||||
@Param token: string = '';
|
|
||||||
/** 最多显示几条候选(默认 8;端上视口有限) */
|
/** 最多显示几条候选(默认 8;端上视口有限) */
|
||||||
@Param limit: number = 8;
|
@Param limit: number = 8;
|
||||||
/** 值变化(用户打字 / 选中候选都会触发) */
|
/** 值变化(用户打字 / 选中候选都会触发) */
|
||||||
@@ -97,7 +95,9 @@ export struct FbAddrInput {
|
|||||||
private async search(kw: string): Promise<void> {
|
private async search(kw: string): Promise<void> {
|
||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
const my: number = this.seq;
|
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) {
|
if (my !== this.seq) {
|
||||||
return; // 已经有一次更新的输入了,这份丢弃
|
return; // 已经有一次更新的输入了,这份丢弃
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,7 +533,6 @@ export struct FbEventEditSheet {
|
|||||||
label: '地点',
|
label: '地点',
|
||||||
placeholder: '地点(可选,输入后可从下拉选)',
|
placeholder: '地点(可选,输入后可从下拉选)',
|
||||||
value: this.fPlace,
|
value: this.fPlace,
|
||||||
token: this.token(),
|
|
||||||
onValue: (v: string): void => {
|
onValue: (v: string): void => {
|
||||||
this.fPlace = v;
|
this.fPlace = v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ export struct FbTripNodeSheet {
|
|||||||
label: '地点',
|
label: '地点',
|
||||||
placeholder: '如「青岛北站」「崂山区·沙子口」',
|
placeholder: '如「青岛北站」「崂山区·沙子口」',
|
||||||
value: this.place,
|
value: this.place,
|
||||||
token: FB_TOKEN_STORE.get(),
|
|
||||||
onValue: (v: string): void => {
|
onValue: (v: string): void => {
|
||||||
this.place = v;
|
this.place = v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,187 @@
|
|||||||
/**
|
/**
|
||||||
* 家书 FamilyBook · 地址联想仓库(鸿蒙端)
|
* 家书 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 的排查记录);
|
||||||
|
* ② 不需要我们自己的服务端接口与配额;
|
||||||
|
* ③ 数据源与设备上的华为地图一致,点选后导航也更对得上。
|
||||||
*
|
*
|
||||||
* ⚠️ 为什么端上不直连高德、而要走服务端代理:
|
* 📌 接口来源与关键字段(`@hms.core.map.site.d.ts`,已核对声明文件):
|
||||||
* 高德 Web 服务 key 是**服务端密钥**,打进安装包等于公开
|
* `site.queryAutoComplete({ query, location?, radius?, language? })`
|
||||||
* (反编译/抓包即可拿到,配额被跑光、key 还可能被封)。
|
* → `Promise<QueryAutoCompleteResult>`,其中 `sites?: Array<Site>`
|
||||||
* 网页端用的是 JS API key + 安全密钥(域名白名单那套),原生端没有等价机制。
|
* `Site` 字段:
|
||||||
* ⇒ 走代理。这也顺带把高德 tips 三套不齐的字段在服务端归一好了,
|
* · `name?: string` 地点名(用户认的名字)
|
||||||
* 端上只管画「名字 + detail」两行。
|
* · `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 { hilog } from '@kit.PerformanceAnalysisKit';
|
||||||
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { FbGeoTip } from '../model/Types';
|
import { FbGeoTip } from '../model/Types';
|
||||||
|
|
||||||
const DOMAIN: number = 0x0000;
|
const DOMAIN: number = 0x0000;
|
||||||
const TAG: string = 'FbGeoRepo';
|
const TAG: string = 'FbGeoRepo';
|
||||||
|
|
||||||
/** 关键词最短长度:少于 2 个字不查(与网页端、服务端同一个阈值) */
|
/** 关键词最短长度:少于 2 个字不查(与网页端同一个阈值) */
|
||||||
export const FB_GEO_MIN_LEN: number = 2;
|
export const FB_GEO_MIN_LEN: number = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 地址联想。
|
* Map Kit 的经纬度字段(`mapCommon.LatLng`)。
|
||||||
*
|
*
|
||||||
* @param kw 用户已输入的关键词
|
* ⚠️ 单独声明而不是 `import { mapCommon } from '@kit.MapKit'`:
|
||||||
* @param token API 令牌(服务端要求登录:这是要花配额的接口)
|
* 只为读 `longitude/latitude` 两个数字,结构上完全兼容
|
||||||
* @param limit 最多要几条(默认 10;端上视口有限,别要太多)
|
* (`LatLng` 就是这两个字段)。少一个 import 也让 `Site` 的类型推断更直接。
|
||||||
* @returns 候选列表;**任何失败都返回空数组**,不抛错
|
|
||||||
*/
|
*/
|
||||||
export async function fbGeoTips(kw: string, token: string, limit: number = 10): Promise<FbGeoTip[]> {
|
interface FbLatLng {
|
||||||
const q: string = kw.trim();
|
longitude: number;
|
||||||
/* 本地先挡一道:省一次网络往返,也让"字太少"的抖动更小 */
|
latitude: number;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从「任意 JSON 对象」里安全取字符串(高德/服务端都可能给 null 或数字) */
|
/**
|
||||||
function str(o: Record<string, Object>, k: string): string {
|
* 地址联想(Map Kit 自动补全)。
|
||||||
const v: Object | undefined = o[k];
|
*
|
||||||
if (typeof v === 'string') {
|
* @param kw 用户已输入的关键词
|
||||||
return (v as string).trim();
|
* @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 '';
|
return '';
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user