端上:#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),待真机确认后移除
This commit is contained in:
@@ -29,6 +29,7 @@ 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';
|
||||
@@ -50,6 +51,22 @@ export struct FbAddrInput {
|
||||
@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[] = [];
|
||||
/** 是否展开候选面板 */
|
||||
@@ -66,6 +83,46 @@ export struct FbAddrInput {
|
||||
*/
|
||||
@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;
|
||||
@@ -74,12 +131,59 @@ export struct FbAddrInput {
|
||||
|
||||
/** 输入变化 → 防抖 → 查候选 */
|
||||
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) {
|
||||
@@ -97,18 +201,73 @@ export struct FbAddrInput {
|
||||
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) {
|
||||
return; // 已经有一次更新的输入了,这份丢弃
|
||||
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 {
|
||||
this.onValue(t.name);
|
||||
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() {
|
||||
@@ -128,52 +287,97 @@ export struct FbAddrInput {
|
||||
.onChange((v: string): void => {
|
||||
this.onType(v);
|
||||
})
|
||||
/* 🔴🔴 2026-11-03 改:候选面板从「布局流内联」改成 **bindPopup 浮层**。
|
||||
用户原话:「下拉框在下面,完全被输入法遮挡」。
|
||||
照 SyncCalendar 的 `EventEditPage.ets` 写法。
|
||||
|
||||
/* 候选面板:只在有候选时占位(用 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)
|
||||
}
|
||||
为什么 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)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选浮层的内容(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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user