Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbAddrInput.ets
T
laoyang 86de59616c 端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)
#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
2026-11-05 12:41:04 +08:00

384 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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<void> {
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)
}
}