From 8be539d8a3831571e0d523db72ad5947dfd4c84e Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 21 Oct 2026 07:59:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=9C=B0=E7=82=B9?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E8=A1=A5=E5=85=A8=EF=BC=88=E9=B8=BF=E8=92=99?= =?UTF-8?q?=20Map=20Kit=20/=20Site=20Kit=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:新建/修改日程时,地点输入几个字即可用地图 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); 未开通时界面显示「地图服务不可用,可直接手动输入地点」,不影响其他功能。 --- entry/src/main/ets/common/PlaceSuggest.ets | 253 +++++++++++++++++++++ entry/src/main/ets/pages/EventEditPage.ets | 222 +++++++++++++++++- 2 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 entry/src/main/ets/common/PlaceSuggest.ets diff --git a/entry/src/main/ets/common/PlaceSuggest.ets b/entry/src/main/ets/common/PlaceSuggest.ets new file mode 100644 index 0000000..5bcef81 --- /dev/null +++ b/entry/src/main/ets/common/PlaceSuggest.ets @@ -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 { + 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 { + 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 { + 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 { + 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}`); + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 9436a88..582337c 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -13,6 +13,7 @@ import { ReminderService } from '../common/ReminderService'; import { LogUtil } from '../common/LogUtil'; import { IcsUtil } from '../common/IcsUtil'; import { RruleUtil } from '../common/RruleUtil'; +import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest'; /** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */ class SeriesBounds { @@ -34,6 +35,22 @@ class BookChoice { struct EventEditPage { @State title: string = ''; @State location: string = ''; + /** + * ⭐ 地点补全(鸿蒙 Map Kit / Site Kit): + * placeItems —— 输入框下方的候选列表(内联展开,**不新增 bindSheet**: + * 本页只能挂一个弹层,见文件末尾注释) + * placeLoading —— 是否正在请求,用于显示"搜索中" + * placeOpen —— 候选列表是否展开 + * placeHint —— 地图能力不可用时的提示(不阻塞用户手输地点) + */ + @State placeItems: PlaceItem[] = []; + @State placeLoading: boolean = false; + @State placeOpen: boolean = false; + @State placeHint: string = ''; + /** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */ + private placePicked: boolean = false; + /** 输入防抖定时器 */ + private placeTimer: number = -1; @State description: string = ''; @State allDay: boolean = false; @State startMs: number = 0; @@ -90,6 +107,11 @@ struct EventEditPage { return this.initPage(); } + /** 页面销毁:清掉地点补全的防抖定时器,避免回调打到已销毁的组件上 */ + aboutToDisappear(): void { + this.cancelSuggestTimer(); + } + private async initPage(): Promise { const context = this.getUIContext().getHostContext(); if (context === undefined) { @@ -214,6 +236,106 @@ struct EventEditPage { } } + // ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ==================== + + /** + * 输入防抖:停止输入 350ms 后才真正发请求。 + * 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。 + * ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限), + * 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。 + */ + private scheduleSuggest(v: string): void { + this.cancelSuggestTimer(); + const q: string = v.trim(); + if (q.length < 1) { + this.placeItems = []; + this.placeOpen = false; + this.placeHint = ''; + return; + } + this.placeTimer = setTimeout(() => { + this.placeTimer = -1; + this.runSuggest(q, false); + }, 350); + } + + private cancelSuggestTimer(): void { + if (this.placeTimer !== -1) { + clearTimeout(this.placeTimer); + this.placeTimer = -1; + } + } + + private async runSuggest(q: string, forceOpen: boolean): Promise { + if (this.placePicked && !forceOpen) { + return; + } + if (PlaceSuggest.isUnavailable()) { + // 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输) + this.placeHint = '地图服务不可用,可直接手动输入地点'; + this.placeOpen = false; + return; + } + // 用户输入时若带空格,PlaceSuggest 内部会 trim + this.placeLoading = true; + this.placeHint = ''; + try { + const c = this.getUIContext().getHostContext(); + const ctx: common.Context | undefined = c; + // 先补全;补全空则内部会自动退到关键字搜索 + let items: PlaceItem[] = await PlaceSuggest.query(q, ctx); + if (items.length === 0 && !PlaceSuggest.isUnavailable()) { + items = await PlaceSuggest.search(q, ctx); + } + // ⭐ 丢弃过期结果:用户又改了输入框,就别再覆盖成旧候选 + if (q !== this.location.trim()) { + return; + } + this.placeItems = items.slice(0, 8); + this.placeOpen = this.placeItems.length > 0; + if (this.placeItems.length === 0) { + this.placeHint = PlaceSuggest.isUnavailable() + ? '地图服务不可用,可直接手动输入地点' + : '没有找到匹配地点,可直接手动输入'; + } + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`地点补全异常: ${e.message}`); + this.placeHint = '地点搜索失败,可直接手动输入'; + this.placeOpen = false; + } finally { + this.placeLoading = false; + } + } + + /** 选中一条候选:写进地点输入框并收起列表 */ + private applyPlace(it: PlaceItem): void { + // 写入完整地址(formatAddress),这样同步到服务器/其他客户端时地址是完整的 + this.location = it.locationText; + this.placePicked = true; + this.placeItems = []; + this.placeOpen = false; + this.placeHint = ''; + this.cancelSuggestTimer(); + } + + /** 打开系统地图选点页(可搜索、可周边、可拖图钉) */ + private async pickOnMap(): Promise { + const c = this.getUIContext().getHostContext(); + if (c === undefined) { + return; + } + const it: PlaceItem | null = await PlaceSuggest.pickOnMap(c as common.UIAbilityContext); + if (it === null) { + // 用户取消,或地图服务不可用 + if (PlaceSuggest.isUnavailable()) { + this.placeHint = '地图服务不可用,可直接手动输入地点'; + } + return; + } + this.applyPlace(it); + } + private chosenBook(): BookChoice | null { const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey); if (found !== undefined) { @@ -1185,18 +1307,96 @@ struct EventEditPage { .borderRadius(12) .backgroundColor($r('app.color.card_bg')) - // 地点 - TextInput({ text: this.location, placeholder: '地点(可选)' }) - .height(44) - .fontSize(14) - .backgroundColor($r('app.color.card_bg')) + // 地点(输入几个字 → 系统地图服务补全候选) + Row({ space: 8 }) { + TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' }) + .height(44) + .layoutWeight(1) + .fontSize(14) + .backgroundColor($r('app.color.card_bg')) + .borderRadius(12) + .fontColor($r('app.color.text_primary')) + .placeholderColor($r('app.color.text_hint')) + .caretColor($r('app.color.brand')) + .onChange((v: string) => { + this.location = v; + // 用户重新输入时解除"已选中"状态,恢复自动补全 + this.placePicked = false; + this.scheduleSuggest(v); + }) + .onSubmit(() => { + // 回车:立刻搜一次(不等防抖) + this.cancelSuggestTimer(); + this.runSuggest(this.location, true); + }) + // 在地图上选点(系统地图界面,可选可搜可周边) + Text('地图') + .fontSize(13) + .fontColor($r('app.color.brand_text')) + .padding({ left: 12, right: 12 }) + .height(44) + .textAlign(TextAlign.Center) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .onClick(() => { + this.pickOnMap(); + }) + } + .width('100%') + + // 候选列表(内联展开;本页只能有一个 bindSheet,故不另开弹层) + if (this.placeLoading) { + Row({ space: 6 }) { + LoadingProgress().width(16).height(16).color($r('app.color.text_hint')) + Text('搜索中…') + .fontSize(12) + .fontColor($r('app.color.text_hint')) + } + .width('100%') + .padding({ left: 14, top: 6, bottom: 2 }) + } else if (this.placeOpen && this.placeItems.length > 0) { + Column() { + ForEach(this.placeItems, (it: PlaceItem, idx: number) => { + Column({ space: 2 }) { + Text(it.title) + .fontSize(14) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + .textAlign(TextAlign.Start) + if (it.subtitle !== '') { + Text(it.subtitle) + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + .textAlign(TextAlign.Start) + } + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding({ left: 14, right: 14, top: 9, bottom: 9 }) + .onClick(() => { + this.applyPlace(it); + }) + if (idx < this.placeItems.length - 1) { + Divider().color($r('app.color.shadow_color')) + } + }, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`) + } + .width('100%') .borderRadius(12) - .fontColor($r('app.color.text_primary')) - .placeholderColor($r('app.color.text_hint')) - .caretColor($r('app.color.brand')) - .onChange((v: string) => { - this.location = v; - }) + .backgroundColor($r('app.color.card_bg')) + .padding({ top: 4, bottom: 4 }) + } else if (this.placeHint !== '') { + Text(this.placeHint) + .fontSize(12) + .fontColor($r('app.color.text_hint')) + .width('100%') + .padding({ left: 14, top: 6 }) + } // 描述 TextArea({ text: this.description, placeholder: '备注(可选)' })