地点输入:补地址联想,下拉带详细地址好区分同名地点 laoyang/FamilyBook#36
用户 2026-10-30:「在所有调用高德显示详细地址的地方…不能只把地点告诉我, 一定要把详细地址也放在后面。因为同一个地址可能有好几个, 我根本不知道应该选哪一个。」 网页端(addr_suggest.js)已经是这个行为,端上原本**连联想都没有**(纯文本框)。 本次把能力补到端上: - 新增 FbGeoRepo.ets:调服务端 /api/v1/geo/tips,最少 2 字才查 - 新增 FbAddrInput.ets:候选每行两行字(地名 14px 深色 + 详细地址 12px 灰), 同名地点靠第二行区分;防抖 260ms(与网页端同节奏);用 seq 丢弃过期响应 - 日程编辑、行程节点两处地点接入 🔴 走服务端代理而不是端上直连高德:高德 Web 服务 key 是服务端密钥, 打进安装包等于公开(反编译/抓包即得,配额会被跑光、key 可能被封)。 网页端用的 JS API key + 安全密钥是域名白名单那套,原生端没有等价机制。 ⚠️ 必须是 @ComponentV2 组件而不是 @Builder:@Builder 的值类型参数 不参与状态观察(SDK r/quick-rules/05-state.md),候选列表/展开态写在 @Builder 里会数据变了界面不重建。本项目在 FbEventChip/FbTagChip/ FbCalOptionRow 上各踩过一次。 ⚠️ 联想失败一律返回空候选 ⇒ 退化成普通文本框,手敲照常保存 (接口挂/没网/没配 key 都不能拖垮主流程)。 编译通过(33 tasks,17 executed)。
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 家书 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)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
|
||||
import { FbEventChip } from './FbEventChip';
|
||||
import { FbSheetRing } from './FbSheetRing';
|
||||
/* 🔴 2026-10-30(工单 #36):地点改成带联想的输入框 ——
|
||||
用户要「输入简单地址 → 下拉里带详细地址,好区分同名地点」。 */
|
||||
import { FbAddrInput } from './FbAddrInput';
|
||||
/* 🔴 v64(用户 2026-10-19):「观影的开始/结束时间应该跟**日程里面的是一样的**。」
|
||||
⇒ 「日期 + 时间」那一套控件抽成了共用组件(见该文件头),日程与观影都用它。
|
||||
⚠️ 本文件里原来的 `dateTimeRow` / `dateTimePickerSheet` / `cnDate` /
|
||||
@@ -519,8 +522,21 @@ export struct FbEventEditSheet {
|
||||
.margin({ top: 2 })
|
||||
|
||||
// ---- 地点 / 备注 ----
|
||||
this.field('地点', this.fPlace, '地点(可选)', (v: string): void => {
|
||||
this.fPlace = v;
|
||||
/* 🔴 2026-10-30(工单 #36):地点换成**带联想**的输入框。
|
||||
用户:「输入一个简单地址、下拉显示详细地址时,不能只把地点告诉我,
|
||||
一定要把详细地址也放在后面。因为同一个地址可能有好几个,
|
||||
我根本不知道应该选哪一个。」——与网页端 addr_suggest.js 同一行为。
|
||||
⚠️ 选中候选后只把**地名**写进 location(与网页端日程那栏一致:
|
||||
日程没有单独的"详细地址"字段,而带门牌会让后续按名字搜索变难;
|
||||
详细地址在下拉里已经看到、能区分就够)。 */
|
||||
FbAddrInput({
|
||||
label: '地点',
|
||||
placeholder: '地点(可选,输入后可从下拉选)',
|
||||
value: this.fPlace,
|
||||
token: this.token(),
|
||||
onValue: (v: string): void => {
|
||||
this.fPlace = v;
|
||||
}
|
||||
})
|
||||
this.area('备注', this.fNote, '备注(可选)', (v: string): void => {
|
||||
this.fNote = v;
|
||||
|
||||
@@ -37,6 +37,8 @@ import { addTripNode, FbTripNodeDraft } from '../data/FbTripRepo';
|
||||
import { fetchVehicles } from '../data/FbTripSettingsRepo';
|
||||
import { FbVehicleItem } from '../model/Types';
|
||||
import { FbApiResult } from '../data/FbHttp';
|
||||
/* 🔴 2026-10-30(工单 #36):地点带联想(与日程编辑同一个组件) */
|
||||
import { FbAddrInput } from './FbAddrInput';
|
||||
|
||||
/** 节点类型(⚠️ 与 `app/Trip.php` 的 `NODE_TYPES` 保持一致) */
|
||||
interface FbNodeTypeOpt {
|
||||
@@ -213,10 +215,18 @@ export struct FbTripNodeSheet {
|
||||
})
|
||||
|
||||
// ---- 地点 ----
|
||||
this.textField('地点', '如「青岛北站」「崂山区·沙子口」', this.place,
|
||||
(v: string): void => {
|
||||
/* 🔴 2026-10-30(工单 #36):换成带联想的输入框。
|
||||
行程地点是"出发地/目的地",同名地点(多个同名车站/商场)
|
||||
最容易填错,详细地址作为参考尤其有用。 */
|
||||
FbAddrInput({
|
||||
label: '地点',
|
||||
placeholder: '如「青岛北站」「崂山区·沙子口」',
|
||||
value: this.place,
|
||||
token: FB_TOKEN_STORE.get(),
|
||||
onValue: (v: string): void => {
|
||||
this.place = v;
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 时间(复用日程/观影那套日期时间控件)----
|
||||
FbDateTimeRow({
|
||||
|
||||
Reference in New Issue
Block a user