地点输入:补地址联想,下拉带详细地址好区分同名地点 laoyang/FamilyBook#36

用户 2026-10-30:「在所有调用高德显示详细地址的地方…不能只把地点告诉我,
一定要把详细地址也放在后面。因为同一个地址可能有好几个,
我根本不知道应该选哪一个。」

网页端(addr_suggest.js)已经是这个行为,端上原本**连联想都没有**(纯文本框)。
本次把能力补到端上:

- 新增 FbGeoRepo.ets:调服务端 /api/v1/geo/tips,最少 2 字才查
- 新增 FbAddrInput.ets:候选每行两行字(地名 14px 深色 + 详细地址 12px 灰),
  同名地点靠第二行区分;防抖 260ms(与网页端同节奏);用 seq 丢弃过期响应
- 日程编辑、行程节点两处地点接入

🔴 走服务端代理而不是端上直连高德:高德 Web 服务 key 是服务端密钥,
打进安装包等于公开(反编译/抓包即得,配额会被跑光、key 可能被封)。
网页端用的 JS API key + 安全密钥是域名白名单那套,原生端没有等价机制。

⚠️ 必须是 @ComponentV2 组件而不是 @Builder:@Builder 的值类型参数
不参与状态观察(SDK r/quick-rules/05-state.md),候选列表/展开态写在
@Builder 里会数据变了界面不重建。本项目在 FbEventChip/FbTagChip/
FbCalOptionRow 上各踩过一次。

⚠️ 联想失败一律返回空候选 ⇒ 退化成普通文本框,手敲照常保存
(接口挂/没网/没配 key 都不能拖垮主流程)。

编译通过(33 tasks,17 executed)。
This commit is contained in:
2026-10-30 08:03:13 +08:00
parent f90cae0dcc
commit f4f27cea55
5 changed files with 343 additions and 5 deletions
+102
View File
@@ -0,0 +1,102 @@
/**
* 家书 FamilyBook · 地址联想仓库(鸿蒙端)
*
* 对应服务端 `GET /api/v1/geo/tips?kw=<关键词>&limit=<条数>`。
*
* 🔴 2026-10-30(工单 #36)用户原话:
* 「在所有调用高德显示详细地址的地方,也就是当我输入一个简单地址、
* 下拉显示详细地址时,不能只把地点告诉我,一定要把详细地址也放在后面。
* 因为如果只是把地址给我,同一个地址可能有好几个,
* 我根本不知道应该选哪一个。
* 所以应该把详细地址也放在后面,作为参考,让用户可以更好地选择。」
*
* 网页端(`assets/js/addr_suggest.js`)早已是这个行为,本次同步到端上。
*
* ⚠️ 为什么端上不直连高德、而要走服务端代理:
* 高德 Web 服务 key 是**服务端密钥**,打进安装包等于公开
* (反编译/抓包即可拿到,配额被跑光、key 还可能被封)。
* 网页端用的是 JS API key + 安全密钥(域名白名单那套),原生端没有等价机制。
* ⇒ 走代理。这也顺带把高德 tips 三套不齐的字段在服务端归一好了,
* 端上只管画「名字 + detail」两行。
*
* ⚠️ 这是**可选能力**:接口挂了/没配 key/没网,一律返回空数组,
* 输入框仍是普通文本框,手敲照常保存(不能让联想拖垮主流程)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
import { FbGeoTip } from '../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbGeoRepo';
/** 关键词最短长度:少于 2 个字不查(与网页端、服务端同一个阈值) */
export const FB_GEO_MIN_LEN: number = 2;
/**
* 地址联想。
*
* @param kw 用户已输入的关键词
* @param token API 令牌(服务端要求登录:这是要花配额的接口)
* @param limit 最多要几条(默认 10;端上视口有限,别要太多)
* @returns 候选列表;**任何失败都返回空数组**,不抛错
*/
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;
}
/** 从「任意 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();
}
if (typeof v === 'number') {
return (v as number).toString();
}
return '';
}