diff --git a/entry/src/main/ets/components/FbAddrInput.ets b/entry/src/main/ets/components/FbAddrInput.ets index 1f61d68..9cd9871 100644 --- a/entry/src/main/ets/components/FbAddrInput.ets +++ b/entry/src/main/ets/components/FbAddrInput.ets @@ -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 { 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; // 已经有一次更新的输入了,这份丢弃 } diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index b4a150e..5e1d187 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -533,7 +533,6 @@ export struct FbEventEditSheet { label: '地点', placeholder: '地点(可选,输入后可从下拉选)', value: this.fPlace, - token: this.token(), onValue: (v: string): void => { this.fPlace = v; } diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets index 044d727..51aade9 100644 --- a/entry/src/main/ets/components/FbTripNodeSheet.ets +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -222,7 +222,6 @@ export struct FbTripNodeSheet { label: '地点', placeholder: '如「青岛北站」「崂山区·沙子口」', value: this.place, - token: FB_TOKEN_STORE.get(), onValue: (v: string): void => { this.place = v; } diff --git a/entry/src/main/ets/data/FbGeoRepo.ets b/entry/src/main/ets/data/FbGeoRepo.ets index c714cd0..3a39b49 100644 --- a/entry/src/main/ets/data/FbGeoRepo.ets +++ b/entry/src/main/ets/data/FbGeoRepo.ets @@ -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`,其中 `sites?: Array` + * `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 { - const q: string = kw.trim(); - /* 本地先挡一道:省一次网络往返,也让"字太少"的抖动更小 */ - if (q.length < FB_GEO_MIN_LEN || token === '') { - return []; - } - /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:对象字面量必须有**显式类型**, - 否则编译报 10605038。这里显式标 `FbApiOptions`, - 内层 query 也标 `Record`。 */ - const query: Record = { - '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[] = JSON.parse(res.data) as Record[]; - 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, 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 { + 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 ''; } \ No newline at end of file