2026-10-30 08:03:13 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 FamilyBook · 地址联想输入框(鸿蒙端)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 2026-10-30(工单 #36)用户原话:
|
|
|
|
|
|
* 「在所有调用高德显示详细地址的地方,也就是当我输入一个简单地址、
|
|
|
|
|
|
* 下拉显示详细地址时,不能只把地点告诉我,一定要把详细地址也放在后面。
|
|
|
|
|
|
* 因为如果只是把地址给我,同一个地址可能有好几个,
|
|
|
|
|
|
* 我根本不知道应该选哪一个。
|
|
|
|
|
|
* 所以应该把详细地址也放在后面,作为参考,让用户可以更好地选择。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 网页端(`assets/js/addr_suggest.js`)已经是这个行为,本组件把它同步到端上。
|
|
|
|
|
|
* 候选每行两行字:**地名(深色)+ 详细地址(灰色小字)**,
|
|
|
|
|
|
* 同名地点靠第二行区分。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(本项目反复踩过的坑):
|
|
|
|
|
|
* `@Builder` 的**值类型参数不参与状态观察**(SDK 规则 `r/quick-rules/05-state.md`:
|
|
|
|
|
|
* 按值传递「状态变化刷新 ❌」)。候选列表、展开/收起都是会变的,
|
|
|
|
|
|
* 写在 `@Builder` 里会出现"数据变了但界面不重建"——
|
|
|
|
|
|
* 表现为"输入了没反应""点候选没变化",而逻辑其实是对的。
|
|
|
|
|
|
* 见 `FbEventChip.ets` / `FbTagChip.ets` / `FbCalOptionRow.ets` 的文件头,
|
|
|
|
|
|
* 本项目在这个坑上栽过三次,统一解法是抽成 `@ComponentV2`。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 联想的失败**绝不能影响主流程**:
|
|
|
|
|
|
* 接口挂、没网、没配 key ⇒ 一律拿到空候选 ⇒ 它就退化成普通输入框,
|
|
|
|
|
|
* 用户手敲照常保存。这条与网页端同一套降级策略。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
|
|
|
|
|
import { FbColors, FbMetric } from '../common/Theme';
|
|
|
|
|
|
import { FbGeoTip } from '../model/Types';
|
|
|
|
|
|
import { FB_GEO_MIN_LEN, fbGeoTips } from '../data/FbGeoRepo';
|
2026-11-05 12:41:04 +08:00
|
|
|
|
import { FbDiag } from '../common/FbDiag';
|
2026-10-30 08:03:13 +08:00
|
|
|
|
|
|
|
|
|
|
const DOMAIN: number = 0x0000;
|
|
|
|
|
|
const TAG: string = 'FbAddrInput';
|
|
|
|
|
|
|
|
|
|
|
|
/** 防抖时长(毫秒)—— 与网页端 `addr_suggest.js` 的 260ms 对齐 */
|
|
|
|
|
|
const DEBOUNCE_MS: number = 260;
|
|
|
|
|
|
|
|
|
|
|
|
@ComponentV2
|
|
|
|
|
|
export struct FbAddrInput {
|
|
|
|
|
|
/** 标题(如「地点」) */
|
|
|
|
|
|
@Param label: string = '地点';
|
|
|
|
|
|
/** 输入框占位文字 */
|
|
|
|
|
|
@Param placeholder: string = '';
|
|
|
|
|
|
/** 当前值(父组件持有,**受控**) */
|
|
|
|
|
|
@Param value: string = '';
|
|
|
|
|
|
/** 最多显示几条候选(默认 8;端上视口有限) */
|
|
|
|
|
|
@Param limit: number = 8;
|
|
|
|
|
|
/** 值变化(用户打字 / 选中候选都会触发) */
|
|
|
|
|
|
@Event onValue: (v: string) => void = (_v: string): void => {
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-11-05 12:41:04 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* **结构化选中回调**(#51)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 用户:「鸿蒙端如果添加一个行程,出发地和目的地地址是不是也能通过
|
|
|
|
|
|
* 高德地图 API 拿到详细地址?」
|
|
|
|
|
|
*
|
|
|
|
|
|
* `FbGeoTip` 本来就有 `detail` / `address` / `lng` / `lat`(高德返回就有),
|
|
|
|
|
|
* 但 `onValue` 只传了"地名 · 详细地址"**拼好的字符串** ⇒ 父组件拿不到坐标,
|
|
|
|
|
|
* 也没法分别写 `from_place` / `from_addr` / `from_lng` / `from_lat`。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 加这个回调把整条 tip 交出去。
|
|
|
|
|
|
* ⚠️ 保留 `onValue`(6 处调用方只关心文字,改签名改动面太大)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Event onPick: (t: FbGeoTip) => void = (_t: FbGeoTip): void => {
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-30 08:03:13 +08:00
|
|
|
|
/** 候选列表 */
|
|
|
|
|
|
@Local tips: FbGeoTip[] = [];
|
|
|
|
|
|
/** 是否展开候选面板 */
|
|
|
|
|
|
@Local open: boolean = false;
|
|
|
|
|
|
/** 防抖计时器句柄(-1 = 没有待执行) */
|
|
|
|
|
|
@Local private timer: number = -1;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 请求序号 —— 用来丢弃过期响应。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 必须有:用户打字快时会有多个请求在飞,
|
|
|
|
|
|
* 先发的可能后回(网络抖动),直接铺上去会出现
|
|
|
|
|
|
* "输到第 4 个字,候选却是第 2 个字的结果"。
|
|
|
|
|
|
* 每次发起自增,回来时不等于当前值就丢弃。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local private seq: number = 0;
|
|
|
|
|
|
|
2026-11-05 12:41:04 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 最后一次**程序写入**的值(选中候选后回写的那种)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 2026-11-03 用户报:「点选之后,那个框还会再弹出来一次」。
|
|
|
|
|
|
* 根因:`onValue()` 回写 → 父组件改 `value` → `TextInput` 重渲染
|
|
|
|
|
|
* → 再次触发 `onChange` → 又发起一次搜索 → 候选框重新弹出。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 解法照 SyncCalendar(`EventEditPage.ets` 的 `lastAppliedText`):
|
|
|
|
|
|
* 在 onChange 里精确比对,命中就**直接 return**,不再搜。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local private lastApplied: string = '';
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 首次 `onChange` 是否已经发生过。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 2026-11-05(用户:「一打开之后,马上弹出来两个框让我选地址」):
|
|
|
|
|
|
* ArkUI 会为**新挂载**的 `TextInput` 触发一次 `onChange`(即使 text 没变),
|
|
|
|
|
|
* 而编辑浮层打开时 `value` 已经预填了地址 ⇒ 那一次 onChange 被当成
|
|
|
|
|
|
* "用户输入" ⇒ 去搜 ⇒ 有候选 ⇒ `bindPopup` 弹出。
|
|
|
|
|
|
* 两个地址框都这样 ⇒ 一进浮层就弹两个候选框。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 第一次 onChange 只认作初始化回填,**不搜索**。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local private primed: boolean = false;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* **时间窗抑制**(#53):选中医选后的这段时间内,一律不搜索。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 前三次修"选中后又弹一次"都靠值比对(原串 → trim),都没修好。
|
|
|
|
|
|
* 根因是值比对这条路本身不可靠:ArkUI 在 TextInput 内容被程序改写时,
|
|
|
|
|
|
* onChange 的**触发次数和时机不确定**(重渲染、光标定位、输入法同步各一次),
|
|
|
|
|
|
* 只要有一次值与记录的不一致,就会搜一次、框就重弹。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 改用时间窗:选中后 600ms 内任何 onChange 都直接返回。
|
|
|
|
|
|
* 600ms 足以覆盖所有回写触发的 onChange,而人不可能在这段时间打完第二个字。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private suppressUntil: number = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** 选中后抑制搜索的时长(毫秒) */
|
|
|
|
|
|
private static readonly SUPPRESS_MS: number = 600;
|
|
|
|
|
|
|
2026-10-30 08:03:13 +08:00
|
|
|
|
/** 收起面板并清空候选 */
|
|
|
|
|
|
private close(): void {
|
|
|
|
|
|
this.open = false;
|
|
|
|
|
|
this.tips = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 输入变化 → 防抖 → 查候选 */
|
|
|
|
|
|
private onType(v: string): void {
|
2026-11-05 12:41:04 +08:00
|
|
|
|
/* 🔴 #53:**时间窗抑制**(最优先,先于一切值比对)。
|
|
|
|
|
|
刚选中候选后的 600ms 内,任何 onChange 都不搜 ——
|
|
|
|
|
|
不管 ArkUI 触发几次、值有没有微妙差异。 */
|
|
|
|
|
|
if (Date.now() < this.suppressUntil) {
|
|
|
|
|
|
FbDiag.log('GEO', 'onChange 在抑制窗口内,跳过 v=[' + v + ']');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 🔴 2026-11-05:**新挂载时的那次 onChange 是初始化,不是用户输入**。
|
|
|
|
|
|
编辑浮层预填了地址 ⇒ 那一次 onChange 会去搜 ⇒ 候选框一进页面就弹出来。
|
|
|
|
|
|
⇒ 第一次直接吞掉(同时记下 lastApplied,防止后续误判)。
|
|
|
|
|
|
⚠️ 不能用"判 lastApplied 是否为空"代替:初始化时它本来就是空的,
|
|
|
|
|
|
与"用户刚开始打字"无法区分。只有"第几次"这个信息能区分。 */
|
|
|
|
|
|
if (!this.primed) {
|
|
|
|
|
|
this.primed = true;
|
|
|
|
|
|
this.lastApplied = v;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 🔴 2026-11-03:**程序回写导致的重入**必须挡掉。
|
|
|
|
|
|
选中候选时 `pick()` 会 `onValue(t.name)` → 父组件改 `value`
|
|
|
|
|
|
→ TextInput 重渲染 → 又触发 onChange → 又搜一次 → 框重新弹出。
|
|
|
|
|
|
|
|
|
|
|
|
判据:值等于我刚提交的那个 ⇒ 是回写,不是用户打字 ⇒ 直接返回。
|
|
|
|
|
|
⚠️ 用完立刻清空 `lastApplied`:否则用户后续真的手动输入同一个字符串时
|
|
|
|
|
|
会被误判成"回写"而不搜索。 */
|
|
|
|
|
|
/* 🔴 #52:**trim 后比对**。
|
|
|
|
|
|
父组件回写时可能带首尾空格/全角差异,原串比对会失败
|
|
|
|
|
|
⇒ 判定成"用户输入" ⇒ 又搜一次 ⇒ 框重弹(用户:「这个并没有解决」)。 */
|
|
|
|
|
|
if (this.lastApplied !== '' && v.trim() === this.lastApplied.trim()) {
|
|
|
|
|
|
FbDiag.log('GEO', 'onChange 是程序回写,跳过搜索 v=[' + v + ']');
|
|
|
|
|
|
/* 🔴 #51(用户:「选中,又会弹出一次地址选择框,就是那个联想框,
|
|
|
|
|
|
这个问题在添加日程时就有」):
|
|
|
|
|
|
**不要在这里清 `lastApplied`**。
|
|
|
|
|
|
|
|
|
|
|
|
原来这里 `this.lastApplied = ''` —— 但选中候选时 ArkUI 可能触发
|
|
|
|
|
|
**不止一次** onChange(重渲染一次、光标定位一次),
|
|
|
|
|
|
第一次消费掉标记后,第二次就挡不住了 ⇒ 又搜一次 ⇒ 框重弹。
|
|
|
|
|
|
|
|
|
|
|
|
⇒ 标记留到"用户真的输入了别的值"时再清(见下面那条分支)。
|
|
|
|
|
|
这样无论回写触发几次 onChange,都会被同一个标记挡住。 */
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 走到这里说明 v 与 lastApplied 不同 ⇒ 是**用户真实输入** ⇒ 清掉标记 */
|
|
|
|
|
|
this.lastApplied = '';
|
|
|
|
|
|
|
2026-10-30 08:03:13 +08:00
|
|
|
|
this.onValue(v); // 值先给父组件(保存用的是它,不能等联想)
|
|
|
|
|
|
if (this.timer >= 0) {
|
|
|
|
|
|
clearTimeout(this.timer);
|
|
|
|
|
|
this.timer = -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
const kw: string = v.trim();
|
2026-11-05 12:41:04 +08:00
|
|
|
|
FbDiag.log('GEO', 'onChange v=[' + v + '] kw.len=' + kw.length + ' MIN=' + FB_GEO_MIN_LEN);
|
2026-10-30 08:03:13 +08:00
|
|
|
|
/* 字太少 ⇒ 不查,并把已有候选收起来。
|
|
|
|
|
|
⚠️ 一定要收:用户已经把字删了,留着旧候选会被误点。 */
|
|
|
|
|
|
if (kw.length < FB_GEO_MIN_LEN) {
|
|
|
|
|
|
this.close();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.timer = setTimeout((): void => {
|
|
|
|
|
|
this.timer = -1;
|
|
|
|
|
|
this.search(kw);
|
|
|
|
|
|
}, DEBOUNCE_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async search(kw: string): Promise<void> {
|
|
|
|
|
|
this.seq += 1;
|
|
|
|
|
|
const my: number = this.seq;
|
2026-10-30 08:12:15 +08:00
|
|
|
|
/* ⚠️ 不再传 token:改用鸿蒙自带 Map Kit 的 `site.queryAutoComplete()`,
|
|
|
|
|
|
鉴权走 AGC 应用认证,与我们的 API 令牌无关(见 FbGeoRepo 文件头)。 */
|
2026-11-05 12:41:04 +08:00
|
|
|
|
FbDiag.log('GEO', 'search 开始 kw=[' + kw + ']');
|
2026-10-30 08:12:15 +08:00
|
|
|
|
const list: FbGeoTip[] = await fbGeoTips(kw, this.limit);
|
2026-11-05 12:41:04 +08:00
|
|
|
|
FbDiag.log('GEO', 'search 返回 ' + list.length + ' 条 seq=' + my + '/' + this.seq);
|
2026-10-30 08:03:13 +08:00
|
|
|
|
if (my !== this.seq) {
|
2026-11-05 12:41:04 +08:00
|
|
|
|
FbDiag.log('GEO', '丢弃过期结果');
|
|
|
|
|
|
return;
|
2026-10-30 08:03:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
this.tips = list;
|
|
|
|
|
|
this.open = list.length > 0;
|
2026-11-05 12:41:04 +08:00
|
|
|
|
FbDiag.log('GEO', '面板 open=' + this.open);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把一条候选拼成**要存进输入框的值**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 2026-11-03 修(用户指出):「你放到输入框的地址,不是详细地址啊,
|
|
|
|
|
|
* 没有黄岛区珠江路这样的字样」。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 原来只写 `t.name`(如「招商银行(青岛经济技术开发区支行)」),
|
|
|
|
|
|
* **没有省市区和门牌**。而这一栏的值会原样存进服务端 `location`:
|
|
|
|
|
|
* · 以后导航做地理编码时又只搜「招商银行」→ 全国几百个 → 可能导到外省;
|
|
|
|
|
|
* · 等于把我们今晚修好的"详细地址"问题绕回了原点。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 照网页端 `addr_suggest.js` 第 566 行的格式:
|
|
|
|
|
|
* name + ' · ' + detail
|
|
|
|
|
|
* 「招商银行(青岛经济技术开发区支行) · 青岛市黄岛区珠江路1018号」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 分隔符必须与网页端**完全一致**(空格+中点+空格):
|
|
|
|
|
|
* 两端存进库的字符串要同形,否则服务端去重判断和用户观感都会不一致。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ `detail` 已含「省市区 + 门牌」(服务端 `Geo::suggest` 拼好的
|
|
|
|
|
|
* `district + address`),这里直接拼,不要二次组合。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 兜底:个别条目 detail 为空(纯 POI 无名址)⇒ 只写名字,
|
|
|
|
|
|
* 不留下「名字 · 」这种尾巴。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private valueOfTip(t: FbGeoTip): string {
|
|
|
|
|
|
const nm: string = t.name.trim();
|
|
|
|
|
|
const dt: string = t.detail.trim();
|
|
|
|
|
|
if (nm === '') {
|
|
|
|
|
|
return dt;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (dt === '' || dt === nm) {
|
|
|
|
|
|
return nm;
|
|
|
|
|
|
}
|
|
|
|
|
|
return nm + ' · ' + dt;
|
2026-10-30 08:03:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 选中一条候选 */
|
|
|
|
|
|
private pick(t: FbGeoTip): void {
|
2026-11-05 12:41:04 +08:00
|
|
|
|
const v: string = this.valueOfTip(t);
|
|
|
|
|
|
|
|
|
|
|
|
/* ⚠️ 顺序很重要:**先记下要写入的值,再 onValue**。
|
|
|
|
|
|
反过来的话父组件同步改 value 会立刻触发 onChange,
|
|
|
|
|
|
那一刻 lastApplied 还是空的 ⇒ 挡不住 ⇒ 又弹一次。 */
|
|
|
|
|
|
/* #52:存 trim 后的值,与 onType 里的 trim 比对保持同一口径 */
|
|
|
|
|
|
/* #53:开启抑制窗口(在 onValue **之前**设,
|
|
|
|
|
|
因为 onValue 会立刻触发父组件回写 → onChange,必须先挡住) */
|
|
|
|
|
|
this.suppressUntil = Date.now() + FbAddrInput.SUPPRESS_MS;
|
|
|
|
|
|
this.lastApplied = v.trim();
|
|
|
|
|
|
this.onValue(v);
|
|
|
|
|
|
/* #51:把结构化数据也交出去(地名/详细地址/经纬度分开)。
|
|
|
|
|
|
⚠️ 放在 onValue 之后:先用文字更新输入框(用户立刻看到),
|
|
|
|
|
|
再让父组件收坐标 —— 顺序反了会出现"输入框还空着但坐标已存"的瞬间。 */
|
|
|
|
|
|
this.onPick(t);
|
2026-10-30 08:03:13 +08:00
|
|
|
|
this.close();
|
2026-11-05 12:41:04 +08:00
|
|
|
|
FbDiag.log('GEO', '已选中并写入: [' + v + ']');
|
2026-10-30 08:03:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
build() {
|
|
|
|
|
|
Column({ space: 4 }) {
|
|
|
|
|
|
Text(this.label)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
|
|
|
|
|
|
|
|
|
|
|
TextInput({ text: this.value, placeholder: this.placeholder })
|
|
|
|
|
|
.placeholderColor(FbColors.faint)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.height(40)
|
|
|
|
|
|
.padding({ left: 12, right: 12 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
this.onType(v);
|
|
|
|
|
|
})
|
2026-11-05 12:41:04 +08:00
|
|
|
|
/* 🔴🔴 2026-11-03 改:候选面板从「布局流内联」改成 **bindPopup 浮层**。
|
|
|
|
|
|
用户原话:「下拉框在下面,完全被输入法遮挡」。
|
|
|
|
|
|
照 SyncCalendar 的 `EventEditPage.ets` 写法。
|
2026-10-30 08:03:13 +08:00
|
|
|
|
|
2026-11-05 12:41:04 +08:00
|
|
|
|
为什么 inline Column 不行:
|
|
|
|
|
|
· 它在布局流里 ⇒ 输入法弹起时被顶住/被盖住;
|
|
|
|
|
|
· 候选出现/消失会改变输入框位置 ⇒ 输入框上下跳(很难受)。
|
|
|
|
|
|
|
|
|
|
|
|
bindPopup 的三个关键参数:
|
|
|
|
|
|
· `placement: Placement.Top` —— 向**上**弹,躲开输入法;
|
|
|
|
|
|
· `focusable: false` —— **不抢焦点**,否则输入法会收起、体验断裂;
|
|
|
|
|
|
· `autoCancel: true` —— 点外部关闭(符合下拉的直觉)。 */
|
|
|
|
|
|
.bindPopup(this.open, {
|
|
|
|
|
|
builder: (): void => {
|
|
|
|
|
|
this.tipPanel()
|
|
|
|
|
|
},
|
|
|
|
|
|
/* 🔴 #52(用户:「有时候弹出的框会在输入框下面。我们之前定的是在输入框
|
|
|
|
|
|
上面弹出来,不然如果在下面弹出来,会被输入键盘挡住。」)
|
|
|
|
|
|
|
|
|
|
|
|
`Placement.Top` 只是**偏好**:上方空间不足时系统会自动翻到下方。
|
|
|
|
|
|
真正能控制的是**让它在上面放得下** ⇒ 候选列表限高 + 内部滚动
|
|
|
|
|
|
(见 tipPanel 的 constraintSize)。 */
|
|
|
|
|
|
placement: Placement.Top,
|
|
|
|
|
|
popupColor: FbColors.surface,
|
|
|
|
|
|
enableArrow: false,
|
|
|
|
|
|
showInSubWindow: false,
|
|
|
|
|
|
focusable: false,
|
|
|
|
|
|
autoCancel: true,
|
|
|
|
|
|
radius: FbMetric.radiusS,
|
|
|
|
|
|
onStateChange: (e: PopupStateChangeParam): void => {
|
|
|
|
|
|
/* 用户点浮层外部 / 系统收起时同步状态,
|
|
|
|
|
|
否则 `open` 还是 true,下次想弹会弹不出来。 */
|
|
|
|
|
|
if (!e.isVisible && this.open) {
|
|
|
|
|
|
this.open = false;
|
2026-10-30 08:03:13 +08:00
|
|
|
|
}
|
2026-11-05 12:41:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-30 08:03:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
2026-11-05 12:41:04 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 候选浮层的内容(bindPopup 的 builder)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 必须是 `@Builder` —— `bindPopup` 的 builder 签名就要求 void 返回。
|
|
|
|
|
|
* 这里读的是 `@Local tips`,V2 下状态变化会正常触发重建
|
|
|
|
|
|
* (与文件头说的"@Builder 值类型参数不参与观察"不冲突:
|
|
|
|
|
|
* 那个坑是**按值传参**,这里是直接读组件状态)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
tipPanel() {
|
|
|
|
|
|
Column() {
|
|
|
|
|
|
ForEach(this.tips, (t: FbGeoTip, idx: number): void => {
|
|
|
|
|
|
Column({ space: 2 }) {
|
|
|
|
|
|
/* 第一行:地名(用户认的是名字) */
|
|
|
|
|
|
Text(t.name)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
.maxLines(1)
|
|
|
|
|
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
|
|
|
/* #52:限高,让候选在上方也放得下,不被输入法挡住 */
|
|
|
|
|
|
.constraintSize({ maxHeight: 180 })
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Start)
|
|
|
|
|
|
/* 第二行:详细地址 —— **这就是用户要的"参考"**。
|
|
|
|
|
|
高德对纯行政区划条目不给 address,那时 detail 就是省市区本身;
|
|
|
|
|
|
万一都空就整行不画(不要留一条空灰线)。 */
|
|
|
|
|
|
if (t.detail !== '') {
|
|
|
|
|
|
Text(t.detail)
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
|
|
|
|
|
.maxLines(2)
|
|
|
|
|
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.pick(t);
|
|
|
|
|
|
})
|
|
|
|
|
|
}, (t: FbGeoTip, idx: number): string => t.name + '|' + t.detail + '|' + idx.toString())
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.backgroundColor(FbColors.surface)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
/* 最多约 5 条高度,多了内部滚动 —— 浮层不该占满整屏 */
|
|
|
|
|
|
.constraintSize({ maxHeight: 260 })
|
|
|
|
|
|
.clip(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|