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';
|
|
|
|
|
|
|
|
|
|
|
|
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 => {
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 候选列表 */
|
|
|
|
|
|
@Local tips: FbGeoTip[] = [];
|
|
|
|
|
|
/** 是否展开候选面板 */
|
|
|
|
|
|
@Local open: boolean = false;
|
|
|
|
|
|
/** 防抖计时器句柄(-1 = 没有待执行) */
|
|
|
|
|
|
@Local private timer: number = -1;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 请求序号 —— 用来丢弃过期响应。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 必须有:用户打字快时会有多个请求在飞,
|
|
|
|
|
|
* 先发的可能后回(网络抖动),直接铺上去会出现
|
|
|
|
|
|
* "输到第 4 个字,候选却是第 2 个字的结果"。
|
|
|
|
|
|
* 每次发起自增,回来时不等于当前值就丢弃。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local private seq: number = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** 收起面板并清空候选 */
|
|
|
|
|
|
private close(): void {
|
|
|
|
|
|
this.open = false;
|
|
|
|
|
|
this.tips = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 输入变化 → 防抖 → 查候选 */
|
|
|
|
|
|
private onType(v: string): void {
|
|
|
|
|
|
this.onValue(v); // 值先给父组件(保存用的是它,不能等联想)
|
|
|
|
|
|
if (this.timer >= 0) {
|
|
|
|
|
|
clearTimeout(this.timer);
|
|
|
|
|
|
this.timer = -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
const kw: string = v.trim();
|
|
|
|
|
|
/* 字太少 ⇒ 不查,并把已有候选收起来。
|
|
|
|
|
|
⚠️ 一定要收:用户已经把字删了,留着旧候选会被误点。 */
|
|
|
|
|
|
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 文件头)。 */
|
|
|
|
|
|
const list: FbGeoTip[] = await fbGeoTips(kw, this.limit);
|
2026-10-30 08:03:13 +08:00
|
|
|
|
if (my !== this.seq) {
|
|
|
|
|
|
return; // 已经有一次更新的输入了,这份丢弃
|
|
|
|
|
|
}
|
|
|
|
|
|
this.tips = list;
|
|
|
|
|
|
this.open = list.length > 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 选中一条候选 */
|
|
|
|
|
|
private pick(t: FbGeoTip): void {
|
|
|
|
|
|
this.onValue(t.name);
|
|
|
|
|
|
this.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/* 候选面板:只在有候选时占位(用 if 而不是 visibility,
|
|
|
|
|
|
否则空列表也会把下面的字段撑开一块空白)。 */
|
|
|
|
|
|
if (this.open && this.tips.length > 0) {
|
|
|
|
|
|
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 })
|
|
|
|
|
|
.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)
|
|
|
|
|
|
.border({ width: 1, color: FbColors.line })
|
|
|
|
|
|
/* 最多约 5 条高度,多了内部滚动 —— 不把整张表单顶下去 */
|
|
|
|
|
|
.constraintSize({ maxHeight: 260 })
|
|
|
|
|
|
.clip(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|