Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbAddrInput.ets
T

179 lines
6.9 KiB
Plaintext
Raw Normal View History

/**
* 家书 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 = '';
/** API 令牌(空串 = 不联想,纯文本框) */
@Param token: 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;
const list: FbGeoTip[] = await fbGeoTips(kw, this.token, this.limit);
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)
}
}