新增:地点输入补全(鸿蒙 Map Kit / Site Kit)

需求:新建/修改日程时,地点输入几个字即可用地图 API 补全详细地址。

选型:鸿蒙系统内置 Map Kit,**不引入高德**。理由(详见
Caldav开发/地点输入补全-地图API选型.md):
1) 配额:高德「输入提示」个人认证仅 100 次/日、企业 1000 次/日(官方 FAQ),
   是全 App 共用额度。按 350ms 防抖、单次输入 4~8 请求算,个人额度约等于
   15~25 个用户输入一次地点;企业额度一天也烧完。华为侧「POI 搜索/地理编码
   当前不收费」,QPS 3000(官方论坛口径,HarmonyOS 5.0+),量级差 6 个数量级。
2) 合规:高德 Web 服务 Key 必须配 IP 白名单、不得出现在客户端;App 直连必然
   被反编译提取 → 额度被盗刷、账单算自己、Key 可能被冻结。唯一安全做法是自建
   服务端代理,会把「App 本体独立好用」的产品定位破坏掉。
3) 商用:高德个人认证仅限"研发/测试/个人兴趣",上架商店属企业认证场景。
4) Site Kit 全部接口**无需任何权限**(d.ts 无 @permission),完全不需要
   申请 LOCATION,契合"权限只在用户主动点该功能时申请"的硬约束。

实现:
- 新增 common/PlaceSuggest.ets(纯服务类):
  query()=queryAutoComplete、search()=searchByText(补全失败兜底)、
  pickOnMap()=sceneMap.chooseLocation(系统地图选点)、isUnavailable() 供界面降级。
  · 请求序号 seq 丢弃过期结果(边打字边搜时后发先至);
  · 异常一律返回空数组、绝不抛给调用方 —— 补全坏了日程照样能存;
  · 1002600004/1002600003 判定"能力未开通"→ 置 unavailable,只提示一次,
    之后不再反复打网络。
- EventEditPage:地点行改为 TextInput + 「地图」按钮 + 内联候选列表。
  · 输入停止 350ms 防抖;回车立即搜一次;
  · 候选**内联展开**,不新增 bindSheet(硬约束:单页只能一个 bindSheet,
    本页已用于时间/重复选择);
  · 选中后写入完整地址(formatAddress) 并收起,placePicked 防止再次自动弹出;
  · aboutToDisappear 清防抖定时器。
- LOCATION 的 ICS 转义无需改动:IcsUtil.escape() 已处理 , ; \ 换行,
  地图地址里的逗号不会破坏格式。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
⚠️ 功能待 AGC 开通「地图服务」+ 重签 Profile 后才生效(否则抛 1002600004);
   未开通时界面显示「地图服务不可用,可直接手动输入地点」,不影响其他功能。
This commit is contained in:
2026-10-21 07:59:21 +08:00
parent 29898d5dff
commit 8be539d8a3
2 changed files with 464 additions and 11 deletions
+253
View File
@@ -0,0 +1,253 @@
// entry/src/main/ets/common/PlaceSuggest.ets
// 地点补全/选点:基于鸿蒙系统内置的 Map Kit(Site Kit + sceneMap)
//
// 为什么用华为 Map Kit 而不是高德:
// 1) 系统内置、无需第三方 SDK 依赖,也不需要我们自己维护一个 API Key;
// 2) Site Kit 的 queryAutoComplete / searchByText / reverseGeocode **不需要任何权限**
// (SDK 声明里没有 @permission),符合本项目「权限只在用户主动点该功能时申请」的硬约束;
// 3) HarmonyOS 5.0 及以上 Map Kit 的地图加载/POI 搜索/地理编码当前免费(官方论坛口径)。
//
// ⚠️ 前置条件(必须由用户在 AGC 完成,代码无法替代):
// AGC 项目 → 开放能力管理 → 打开「地图服务」,并**重新申请 Profile**(旧的 Profile 不含地图能力)。
// 未开通时调用会抛 1002600004(The Map permission is not enabled)。
// 因此本类所有入口都必须**优雅降级**:失败只返回空列表,绝不影响日程本身的保存。
import { site, sceneMap, mapCommon } from '@kit.MapKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** 一条地点候选(界面直接消费) */
export class PlaceItem {
/** Site Kit 的地点 ID,可用于 searchById 取详情 */
siteId: string = '';
/** 地点名称,如「星巴克(中关村店)」 */
name: string = '';
/** 完整格式化地址,如「北京市海淀区中关村大街1号」 */
address: string = '';
/** 纬度(无法取得时为 0) */
lat: number = 0;
/** 经度(无法取得时为 0) */
lng: number = 0;
/** 是否有有效坐标 */
hasCoord: boolean = false;
/** 界面上第一行显示:有名称用名称,否则用地址 */
get title(): string {
return this.name !== '' ? this.name : this.address;
}
/** 界面上第二行显示:名称+地址重复时不显示 */
get subtitle(): string {
if (this.address === '') {
return '';
}
if (this.name === '' || this.address === this.name) {
return '';
}
return this.address;
}
/** 写入日程 location 字段的文本:优先完整地址,退化为名称 */
get locationText(): string {
return this.address !== '' ? this.address : this.name;
}
}
export class PlaceSuggest {
/** 地图能力是否可用(false 时界面应显示提示而不是反复重试) */
private static unavailable: boolean = false;
/** 最近一次失败原因(给界面显示,便于用户/开发者判断是"没开通"还是"网络问题") */
private static lastError: string = '';
/** 当前在途请求序号:用于丢弃过期结果(边打字边搜时后发先至) */
private static seq: number = 0;
static isUnavailable(): boolean {
return PlaceSuggest.unavailable;
}
static getLastError(): string {
return PlaceSuggest.lastError;
}
/**
* 关键字补全:输入几个字返回候选地点。
* 失败/未开通一律返回空数组(不抛异常,不影响其他功能)。
* @param keyword 用户已输入的关键字
* @param context 可选,传了就用带 context 的重载(定位更准)
*/
static async query(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '') {
return [];
}
if (PlaceSuggest.unavailable) {
return [];
}
// 序号机制:只接受最后一次请求的结果
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
try {
const params: site.QueryAutoCompleteParams = {
query: q,
language: 'zh-CN',
isCityLimit: false
};
const res: site.QueryAutoCompleteResult = context !== undefined
? await site.queryAutoComplete(context, params)
: await site.queryAutoComplete(params);
if (mySeq !== PlaceSuggest.seq) {
// 已有更新的请求发出,丢弃本次结果
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('补全', err);
// 补全失败时退一步用关键字搜索(部分环境 autoComplete 不可用但 searchByText 可用)
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
}
/**
* 关键字搜索(补全失败时的兜底,也用于用户直接点"搜索")。
*/
static async search(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
const q: string = keyword.trim();
if (q === '' || PlaceSuggest.unavailable) {
return [];
}
PlaceSuggest.seq++;
const mySeq: number = PlaceSuggest.seq;
return await PlaceSuggest.searchFallback(q, context, mySeq);
}
private static async searchFallback(keyword: string, context: common.Context | undefined,
mySeq: number): Promise<PlaceItem[]> {
try {
const params: site.SearchByTextParams = {
query: keyword,
language: 'zh-CN'
};
const res: site.SearchByTextResult = context !== undefined
? await site.searchByText(context, params)
: await site.searchByText(params);
if (mySeq !== PlaceSuggest.seq) {
return [];
}
return PlaceSuggest.toItems(res.sites);
} catch (err) {
PlaceSuggest.handleError('搜索', err);
return [];
}
}
/**
* 打开系统地图选点页,返回用户选中的地点(用户取消返回 null)。
* 这是最省事也最不容易出错的方式:地图、搜索、周边都由系统界面提供。
*/
static async pickOnMap(context: common.UIAbilityContext): Promise<PlaceItem | null> {
try {
const options: sceneMap.LocationChoosingOptions = {
language: 'zh-CN',
searchEnabled: true,
showNearbyPoi: true
};
const r: sceneMap.LocationChoosingResult = await sceneMap.chooseLocation(context, options);
const item: PlaceItem = new PlaceItem();
item.siteId = r.siteId !== undefined ? r.siteId : '';
item.name = r.name !== undefined ? r.name : '';
item.address = r.address !== undefined ? r.address : '';
if (r.location !== undefined) {
item.lat = r.location.latitude;
item.lng = r.location.longitude;
item.hasCoord = true;
}
// 两者都空视为取消/无效选择
if (item.name === '' && item.address === '') {
return null;
}
return item;
} catch (err) {
PlaceSuggest.handleError('地图选点', err);
return null;
}
}
/** 生成"名称 — 地址"这种便于粘贴进备注/地点的单行文本 */
static formatOneLine(item: PlaceItem): string {
if (item.name !== '' && item.address !== '' && item.name !== item.address) {
return `${item.name}|${item.address}`;
}
return item.locationText;
}
private static toItems(sites: site.Site[] | undefined): PlaceItem[] {
const out: PlaceItem[] = [];
if (sites === undefined) {
return out;
}
for (let i = 0; i < sites.length; i++) {
const s: site.Site = sites[i];
const item: PlaceItem = new PlaceItem();
item.siteId = s.siteId !== undefined ? s.siteId : '';
item.name = s.name !== undefined ? s.name : '';
// formatAddress 是"省市区+街道门牌"的完整串,最适合写进 LOCATION
item.address = s.formatAddress !== undefined ? s.formatAddress : '';
if (item.address === '' && s.addressComponent !== undefined) {
// 极少数数据只有结构化 addressComponent(字段仅 countryName/locality/city/streetNumber),
// 按"国 → 城市 → 区县(locality) → 街道门牌"拼一个保底地址
item.address = PlaceSuggest.joinAddress(s.addressComponent);
}
if (s.location !== undefined) {
item.lat = s.location.latitude;
item.lng = s.location.longitude;
item.hasCoord = true;
}
if (item.name !== '' || item.address !== '') {
out.push(item);
}
}
return out;
}
/** 从结构化地址组件拼一条保底地址(SDK 里就这几个字段) */
private static joinAddress(ac: site.AddressComponent): string {
const parts: string[] = [];
if (ac.countryName !== undefined && ac.countryName !== '') {
parts.push(ac.countryName);
}
if (ac.city !== undefined && ac.city.cityName !== undefined && ac.city.cityName !== '') {
parts.push(ac.city.cityName);
}
if (ac.locality !== undefined && ac.locality !== '') {
parts.push(ac.locality);
}
if (ac.streetNumber !== undefined) {
const sn: site.StreetNumber = ac.streetNumber;
if (sn.streetName !== undefined && sn.streetName !== '') {
parts.push(sn.streetName);
}
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
parts.push(sn.streetNumber);
}
}
return parts.join('');
}
/**
* 统一错误处理:
* - 1002600004 地图权限未开通 → 标记不可用,界面不再重试(避免每次打字都打一遍网络)
* - 1002600003 鉴权失败(Profile 未含地图能力)
* - 1002600002 连不上服务器
*/
private static handleError(op: string, err: Object): void {
const e = err as BusinessError;
const code: number = e.code !== undefined ? e.code : 0;
PlaceSuggest.lastError = `${op}失败(code=${code}): ${e.message}`;
if (code === 1002600004 || code === 1002600003) {
PlaceSuggest.unavailable = true;
LogUtil.write(`[地点] ${op}不可用(code=${code}):需在 AGC 打开「地图服务」并重新申请 Profile`);
} else {
LogUtil.write(`[地点] ${PlaceSuggest.lastError}`);
}
}
}