/** * 家书 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'; import { FbDiag } from '../common/FbDiag'; 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 => { }; /** * **结构化选中回调**(#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 => { }; /** 候选列表 */ @Local tips: FbGeoTip[] = []; /** 是否展开候选面板 */ @Local open: boolean = false; /** 防抖计时器句柄(-1 = 没有待执行) */ @Local private timer: number = -1; /** * 请求序号 —— 用来丢弃过期响应。 * * 🔴 必须有:用户打字快时会有多个请求在飞, * 先发的可能后回(网络抖动),直接铺上去会出现 * "输到第 4 个字,候选却是第 2 个字的结果"。 * 每次发起自增,回来时不等于当前值就丢弃。 */ @Local private seq: number = 0; /** * 最后一次**程序写入**的值(选中候选后回写的那种)。 * * 🔴 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; /** 收起面板并清空候选 */ private close(): void { this.open = false; this.tips = []; } /** 输入变化 → 防抖 → 查候选 */ private onType(v: string): void { /* 🔴 #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 = ''; this.onValue(v); // 值先给父组件(保存用的是它,不能等联想) if (this.timer >= 0) { clearTimeout(this.timer); this.timer = -1; } const kw: string = v.trim(); FbDiag.log('GEO', 'onChange v=[' + v + '] kw.len=' + kw.length + ' MIN=' + FB_GEO_MIN_LEN); /* 字太少 ⇒ 不查,并把已有候选收起来。 ⚠️ 一定要收:用户已经把字删了,留着旧候选会被误点。 */ 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 { this.seq += 1; const my: number = this.seq; /* ⚠️ 不再传 token:改用鸿蒙自带 Map Kit 的 `site.queryAutoComplete()`, 鉴权走 AGC 应用认证,与我们的 API 令牌无关(见 FbGeoRepo 文件头)。 */ FbDiag.log('GEO', 'search 开始 kw=[' + kw + ']'); const list: FbGeoTip[] = await fbGeoTips(kw, this.limit); FbDiag.log('GEO', 'search 返回 ' + list.length + ' 条 seq=' + my + '/' + this.seq); if (my !== this.seq) { FbDiag.log('GEO', '丢弃过期结果'); return; } this.tips = list; this.open = list.length > 0; 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; } /** 选中一条候选 */ private pick(t: FbGeoTip): void { 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); this.close(); FbDiag.log('GEO', '已选中并写入: [' + v + ']'); } 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-03 改:候选面板从「布局流内联」改成 **bindPopup 浮层**。 用户原话:「下拉框在下面,完全被输入法遮挡」。 照 SyncCalendar 的 `EventEditPage.ets` 写法。 为什么 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; } } }) } .width('100%') .alignItems(HorizontalAlign.Start) } /** * 候选浮层的内容(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) } }