新增:地点输入补全(鸿蒙 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:
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { ReminderService } from '../common/ReminderService';
|
|||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
import { IcsUtil } from '../common/IcsUtil';
|
import { IcsUtil } from '../common/IcsUtil';
|
||||||
import { RruleUtil } from '../common/RruleUtil';
|
import { RruleUtil } from '../common/RruleUtil';
|
||||||
|
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
|
||||||
|
|
||||||
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
||||||
class SeriesBounds {
|
class SeriesBounds {
|
||||||
@@ -34,6 +35,22 @@ class BookChoice {
|
|||||||
struct EventEditPage {
|
struct EventEditPage {
|
||||||
@State title: string = '';
|
@State title: string = '';
|
||||||
@State location: 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 description: string = '';
|
||||||
@State allDay: boolean = false;
|
@State allDay: boolean = false;
|
||||||
@State startMs: number = 0;
|
@State startMs: number = 0;
|
||||||
@@ -90,6 +107,11 @@ struct EventEditPage {
|
|||||||
return this.initPage();
|
return this.initPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 页面销毁:清掉地点补全的防抖定时器,避免回调打到已销毁的组件上 */
|
||||||
|
aboutToDisappear(): void {
|
||||||
|
this.cancelSuggestTimer();
|
||||||
|
}
|
||||||
|
|
||||||
private async initPage(): Promise<void> {
|
private async initPage(): Promise<void> {
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
private chosenBook(): BookChoice | null {
|
||||||
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
|
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
|
||||||
if (found !== undefined) {
|
if (found !== undefined) {
|
||||||
@@ -1185,9 +1307,11 @@ struct EventEditPage {
|
|||||||
.borderRadius(12)
|
.borderRadius(12)
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
|
||||||
// 地点
|
// 地点(输入几个字 → 系统地图服务补全候选)
|
||||||
TextInput({ text: this.location, placeholder: '地点(可选)' })
|
Row({ space: 8 }) {
|
||||||
|
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
|
||||||
.height(44)
|
.height(44)
|
||||||
|
.layoutWeight(1)
|
||||||
.fontSize(14)
|
.fontSize(14)
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
.borderRadius(12)
|
.borderRadius(12)
|
||||||
@@ -1196,7 +1320,83 @@ struct EventEditPage {
|
|||||||
.caretColor($r('app.color.brand'))
|
.caretColor($r('app.color.brand'))
|
||||||
.onChange((v: string) => {
|
.onChange((v: string) => {
|
||||||
this.location = v;
|
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)
|
||||||
|
.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: '备注(可选)' })
|
TextArea({ text: this.description, placeholder: '备注(可选)' })
|
||||||
|
|||||||
Reference in New Issue
Block a user