diff --git a/entry/src/main/ets/components/FbAddrInput.ets b/entry/src/main/ets/components/FbAddrInput.ets new file mode 100644 index 0000000..1f61d68 --- /dev/null +++ b/entry/src/main/ets/components/FbAddrInput.ets @@ -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 { + 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) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index 0028865..b4a150e 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -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; diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets index 5e8f78f..044d727 100644 --- a/entry/src/main/ets/components/FbTripNodeSheet.ets +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -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({ diff --git a/entry/src/main/ets/data/FbGeoRepo.ets b/entry/src/main/ets/data/FbGeoRepo.ets new file mode 100644 index 0000000..c714cd0 --- /dev/null +++ b/entry/src/main/ets/data/FbGeoRepo.ets @@ -0,0 +1,102 @@ +/** + * 家书 FamilyBook · 地址联想仓库(鸿蒙端) + * + * 对应服务端 `GET /api/v1/geo/tips?kw=<关键词>&limit=<条数>`。 + * + * 🔴 2026-10-30(工单 #36)用户原话: + * 「在所有调用高德显示详细地址的地方,也就是当我输入一个简单地址、 + * 下拉显示详细地址时,不能只把地点告诉我,一定要把详细地址也放在后面。 + * 因为如果只是把地址给我,同一个地址可能有好几个, + * 我根本不知道应该选哪一个。 + * 所以应该把详细地址也放在后面,作为参考,让用户可以更好地选择。」 + * + * 网页端(`assets/js/addr_suggest.js`)早已是这个行为,本次同步到端上。 + * + * ⚠️ 为什么端上不直连高德、而要走服务端代理: + * 高德 Web 服务 key 是**服务端密钥**,打进安装包等于公开 + * (反编译/抓包即可拿到,配额被跑光、key 还可能被封)。 + * 网页端用的是 JS API key + 安全密钥(域名白名单那套),原生端没有等价机制。 + * ⇒ 走代理。这也顺带把高德 tips 三套不齐的字段在服务端归一好了, + * 端上只管画「名字 + detail」两行。 + * + * ⚠️ 这是**可选能力**:接口挂了/没配 key/没网,一律返回空数组, + * 输入框仍是普通文本框,手敲照常保存(不能让联想拖垮主流程)。 + */ + +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { FbApiOptions, FbApiResult, fbApi } from './FbHttp'; +import { FbGeoTip } from '../model/Types'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbGeoRepo'; + +/** 关键词最短长度:少于 2 个字不查(与网页端、服务端同一个阈值) */ +export const FB_GEO_MIN_LEN: number = 2; + +/** + * 地址联想。 + * + * @param kw 用户已输入的关键词 + * @param token API 令牌(服务端要求登录:这是要花配额的接口) + * @param limit 最多要几条(默认 10;端上视口有限,别要太多) + * @returns 候选列表;**任何失败都返回空数组**,不抛错 + */ +export async function fbGeoTips(kw: string, token: string, limit: number = 10): Promise { + const q: string = kw.trim(); + /* 本地先挡一道:省一次网络往返,也让"字太少"的抖动更小 */ + if (q.length < FB_GEO_MIN_LEN || token === '') { + return []; + } + /* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:对象字面量必须有**显式类型**, + 否则编译报 10605038。这里显式标 `FbApiOptions`, + 内层 query 也标 `Record`。 */ + const query: Record = { + 'kw': q, + 'limit': limit.toString() + }; + const opts: FbApiOptions = { + token: token, + query: query + }; + const res: FbApiResult = await fbApi('/api/v1/geo/tips', opts); + if (!res.ok) { + /* ⚠️ 只记日志、不打扰用户:联想失败时用户正对着输入框, + 弹一个"地址联想失败"只会挡住他自己打字。 */ + hilog.warn(DOMAIN, TAG, '地址联想失败:%{public}s', res.error); + return []; + } + const out: FbGeoTip[] = []; + try { + const rows: Record[] = JSON.parse(res.data) as Record[]; + for (const r of rows) { + const name: string = str(r, 'name'); + if (name === '') { + continue; + } + out.push({ + name: name, + detail: str(r, 'detail'), + district: str(r, 'district'), + address: str(r, 'address'), + lng: str(r, 'lng'), + lat: str(r, 'lat') + }); + } + } catch (err) { + hilog.error(DOMAIN, TAG, '地址联想解析失败:%{public}s', JSON.stringify(err)); + return []; + } + return out; +} + +/** 从「任意 JSON 对象」里安全取字符串(高德/服务端都可能给 null 或数字) */ +function str(o: Record, k: string): string { + const v: Object | undefined = o[k]; + if (typeof v === 'string') { + return (v as string).trim(); + } + if (typeof v === 'number') { + return (v as number).toString(); + } + return ''; +} \ No newline at end of file diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 8f2e71f..7a11ba1 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -690,6 +690,37 @@ export interface FbCalBook { // 任务(tasks) // ============================================================ +/* ═══════════════════════════════════════════════════════════════ + 地址联想(高德输入提示,走服务端代理) + ═══════════════════════════════════════════════════════════════ */ + +/** + * 一条地址联想候选(`GET /api/v1/geo/tips`)。 + * + * 🔴 2026-10-30(工单 #36)用户原话: + * 「在所有调用高德显示详细地址的地方…不能只把地点告诉我, + * 一定要把详细地址也放在后面。因为同一个地址可能有好几个, + * 我根本不知道应该选哪一个。」 + * ⇒ `detail` 就是那个"作为参考"的详细地址,**必须画在名字下面**。 + */ +export interface FbGeoTip { + /** 地点名(如「星巴克(万象城店)」)—— 用户认的是这个名字 */ + name: string; + /** + * 详细地址(省市区 + 门牌),**用来区分同名地点**。 + * ⚠️ 纯行政区划条目高德不给 address,这时它就是省市区本身。 + */ + detail: string; + /** 省市区 */ + district: string; + /** 门牌 */ + address: string; + /** 经度(字符串;没有时为空串) */ + lng: string; + /** 纬度 */ + lat: string; +} + /** 任务卡片(tasks/index.php 看板) */ export interface FbTaskItem { id: number;