diff --git a/client/electron/src/components/AddressInput.tsx b/client/electron/src/components/AddressInput.tsx index e8985b1..70fe53d 100644 --- a/client/electron/src/components/AddressInput.tsx +++ b/client/electron/src/components/AddressInput.tsx @@ -1,7 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import * as api from '../api/client'; -import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes } from '../lib/addressSuggest'; -import type { SessionCandidate } from '../types'; +import { + parseParts, + mergeCandidate, + nextActiveIndex, + queryFor, + splitEditing, + normalizeCandidates, + filterSets +} from '../lib/addressSuggest'; /** * 三段式地址输入:name -> @path -> .session @@ -27,20 +34,40 @@ export default function AddressInput({ }) { const [open, setOpen] = useState(false); const [items, setItems] = useState([]); - // session 段的富候选,与 items 同序。其他段为空数组。 - const [meta, setMeta] = useState([]); + /* + * session 段的富候选,与 items 同序。其他段为空数组。 + * + * ★★ 2026-09-21 改:从 `SessionCandidate[]` 改成**三元组**。 + * + * 原因:原来这里存服务端对象、渲染层自己 `c?.title` / `c?.source` / + * `c?.unread` 地去 `omitempty` 守卫 —— 而这道守卫现在收进了 + * `normalizeCandidates`(两边共用)。渲染层只需要三个值, + * 不需要那个对象的其它字段(`alias` 从来没被读过 —— 字符串在 `items` 里)。 + * ⇒ 存归一化后的形状,守卫只有一处。 + */ + const [meta, setMeta] = useState<{ title: string; source: string; unread: number }[]>([]); const [kind, setKind] = useState<'name' | 'path' | 'session'>('name'); const [active, setActive] = useState(0); const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 }); const boxRef = useRef(null); // 当前正在编辑的那一段(多地址时取最后一段) - const { head, editing } = useMemo(() => { - if (!allowMultiple) return { head: '', editing: value }; - const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';')); - if (idx < 0) return { head: '', editing: value }; - return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() }; - }, [value, allowMultiple]); + /* + * ★★ 2026-09-21 改:改调 `splitEditing`(同一份规则抽到了 `lib/`)。 + * + * 这段原来是**内联在这里的 `useMemo`**,与 `lib/addressSuggest.ts` + * 里那份是**两份实现**;而鸿蒙侧第三份是我这轮新写的。 + * 我加了 `splitEditing` 到 lib、加了跨端判据,却**没把本组件改成用它** —— + * 等于只是把第二份实现搬了个地方,判据比的是 lib 那份, + * 线上跑的仍是这份内联的(这正是本仓`抽的时候行为一字不改`那段注释要防的事, + * 只不过方向反过来:这次是**抽了却没接上**)。 + * + * `useMemo` 保留(切分是纯计算,不必每渲染重算)。 + */ + const { head, editing } = useMemo( + () => splitEditing(value, allowMultiple), + [value, allowMultiple] + ); // 把编辑段拆成 name / path / session 三部分 const parts = useMemo(() => parseParts(editing), [editing]); @@ -51,23 +78,81 @@ export default function AddressInput({ try { // 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session const q = queryFor(parts); - const res = await api.suggestAddress(q.name, q.path); + /* + * ★★ 2026-09-21 修:**按层决定传几个参数**(这是我 `90631ca` 时弄坏的)。 + * + * 原来的写法是三个三目: + * const res = parts.hasDot + * ? await api.suggestAddress(parts.name, parts.path) + * : parts.hasAt ? await api.suggestAddress(parts.name) + * : await api.suggestAddress(); + * 我在 2in1 键盘那轮把它简化成了恒定的 + * await api.suggestAddress(q.name, q.path) + * —— 当时觉得"反正服务端把空串当没给"。那个说法**是对的** + * (`contacts.go:151` 的 `name := r.URL.Query().Get("name")` + * 对缺参与空串都得到 `""`,后续判据与线上行为完全一致), + * 但它把组件**自己文档化的契约**悄悄改掉了: + * `AddressInput.test.tsx:73` 「问 name 层时不带任何参数」, + * `:86` 「写了 @ 没写 . 时带 name 去问 path 层」。 + * 那两条从此一直红,而我先后两次把它们当成"预先存在的问题"绕过去。 + * **它是我自己引入的回归。** + * + * ── 为什么是"改回调用形状"而不是"改测试" ── + * 参数个数在这里是**层语义**的表达:问 name 层就不该传任何筛选参数。 + * 写成恒定两个参数虽然在服务端等价,但读代码的人得先确认 + * "空串会不会退到上一层"才能放心 —— 而那个确认正是我当初做错的地方。 + * 调用形状把意图摆在签名上,不需要读者去查服务端。 + * + * ── 与旧代码的差别 ── + * 判据用 `q.kind`(`queryFor` 的产物,**"问哪一层"的唯一来源**) + * 而不是再自己从 `parts.hasDot`/`hasAt` 推一遍。旧写法推了两遍, + * 两处将来会分叉(改了 `queryFor` 忘了改这里 ⇒ 问 A 层却按 B 层传参)。 + */ + const res = + q.kind === 'session' + ? await api.suggestAddress(q.name, q.path) + : q.kind === 'path' + ? await api.suggestAddress(q.name) + : await api.suggestAddress(); if (cancelled) return; const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name; - const lower = frag.toLowerCase(); const all = res.suggestions || []; const cands = res.candidates || []; /* - * 过滤下标(**保持 suggestions 与 candidates 同序**)—— - * 抽到 `lib/addressSuggest.ts`,与鸿蒙 `filterIndexes` 同一份规则。 + * ★★ 2026-09-21 改:归一化 + 过滤改调共享函数。 + * + * 原来这里是: + * const keep = filterIndexes(all, cands.map(c => c?.title ?? ''), lower); + * setItems(keep.map(i => all[i])); + * setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []); + * —— 即"服务端对象存着、渲染时再各自守 `omitempty`"。 + * + * 现在两个方向都只剩下**一份**实现: + * `normalizeCandidates` 守一次 `omitempty`,`filterSets` 保同序。 + * + * ★ 行为等价:`cands` 为空时旧代码给 `meta = []`; + * 新代码会给每项 `{title:'',source:'',unread:0}` —— + * 渲染层对这两者都不输出任何东西(三个值都不满足显示条件)。 */ - const keep: number[] = filterIndexes(all, cands.map(c => c?.title ?? ''), lower); + const sets = normalizeCandidates( + all, + cands.map(c => c?.title), + cands.map(c => c?.source), + cands.map(c => c?.unread) + ); + const kept = filterSets(sets, frag); setKind(res.kind); - setItems(keep.map(i => all[i])); - setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []); + setItems(kept.items); + setMeta( + kept.items.map((_, i) => ({ + title: kept.titles[i], + source: kept.sources[i], + unread: kept.unreads[i] + })) + ); setActive(0); } catch { if (!cancelled) { @@ -122,6 +207,11 @@ export default function AddressInput({ /** 选中一个候选后拼回完整地址 */ const apply = (choice: string) => { const next: string = mergeCandidate(parts, kind, choice); + /* + * 拼法逐字保留(`head` 尾上已带分隔符,非空时再补一个空格)—— + * 与 `lib/addressSuggest.ts` 里 `splitEditing` 的 `trimStart` 是一对: + * 写的时候多一个空格、读的时候去掉,往返稳定。 + */ onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next); // name/path 选完仍停留在补全态,继续下一段 setOpen(kind !== 'session'); @@ -214,7 +304,7 @@ export default function AddressInput({ )} {(c?.unread ?? 0) > 0 && ( - {c!.unread} + {c.unread} )} diff --git a/client/electron/src/lib/addressSuggest.ts b/client/electron/src/lib/addressSuggest.ts index 0ac578a..81f7ea2 100644 --- a/client/electron/src/lib/addressSuggest.ts +++ b/client/electron/src/lib/addressSuggest.ts @@ -21,6 +21,43 @@ export interface AddressParts { hasDot: boolean; } +/** + * 服务端候选的三个可选字段(都已归一成"一定有值"的形状)。 + * + * ★★ 2026-09-21 新增:抽出来的理由是**同一个坑要防多次**。 + * + * `SessionCandidate` 的三个字段(`title` / `source` / `unread`) + * 都带 `omitempty` ⇒ **缺键时裸 cast 拿到的是 `undefined`**。 + * 本仓已因此抛过 `TypeError`(`undefined.length`)。 + * + * 鸿蒙侧原先在 `ComposePage.fetchSuggestions` 里内联这套 `typeof` 守卫; + * 现在转发条、日历事件都要用 ⇒ 内联三份必然漂移。 + */ +export interface CandidateSets { + items: string[]; + titles: string[]; + sources: string[]; + unreads: number[]; +} + +export function normalizeCandidates( + suggestions: string[], + titles: (string | undefined)[], + sources: (string | undefined)[], + unreads: (number | undefined)[] +): CandidateSets { + const out: CandidateSets = { items: suggestions.slice(), titles: [], sources: [], unreads: [] }; + for (let i = 0; i < suggestions.length; i++) { + const t = i < titles.length ? titles[i] : undefined; + out.titles.push(typeof t === 'string' ? t : ''); + const s = i < sources.length ? sources[i] : undefined; + out.sources.push(typeof s === 'string' ? s : ''); + const u = i < unreads.length ? unreads[i] : undefined; + out.unreads.push(typeof u === 'number' ? u : 0); + } + return out; +} + /** * 把可能含多个地址的输入切成「已完成的前缀」+「正在编辑的最后一段」。 * @@ -152,3 +189,39 @@ export function filterIndexes( }); return keep; } + +/** + * 把"保留下来的下标"作用到任意同长数组上。 + * + * ★★ 2026-09-21 补:这个函数**只存在于鸿蒙侧**(`model/AddressSuggest.ts`), + * electron 从来没有 —— 而 electron 的 `AddressInput` 是在 `useMemo` 里 + * 直接 `.map()` 取的。两边的"取子集"因此没有任何可比基准。 + * + * 我是写 `filterSets`(想要两边共用)时被 `cross-client-logic` 当场拦住的: + * electron: THROW:pickBy is not defined + * 这条判据的报错里那句"以 electron 为准(用户定的方向:electron 是唯一真实源泉)" + * 正好说明为什么不能把函数只写在一侧。 + */ +export function pickBy(arr: T[], indexes: number[]): T[] { + const out: T[] = []; + for (const idx of indexes) { + if (idx >= 0 && idx < arr.length) out.push(arr[idx]); + } + return out; +} + +/** + * 按当前输入片段过滤一整套候选(保持四个数组同序)。 + * + * 把 `filterIndexes` + 四次 `pickBy` 的样板收成一件 —— 三个调用点 + * (写信页 / 转发条 / 日历事件)逐字相同,但手写三遍就会有一处忘同步。 + */ +export function filterSets(sets: CandidateSets, fragment: string): CandidateSets { + const keep = filterIndexes(sets.items, sets.titles, fragment); + return { + items: pickBy(sets.items, keep), + titles: pickBy(sets.titles, keep), + sources: pickBy(sets.sources, keep), + unreads: pickBy(sets.unreads, keep) + }; +} diff --git a/client/electron/test/cross-client-logic.test.mjs b/client/electron/test/cross-client-logic.test.mjs index 3a1e917..14df944 100644 --- a/client/electron/test/cross-client-logic.test.mjs +++ b/client/electron/test/cross-client-logic.test.mjs @@ -327,7 +327,36 @@ const PAIRS = [ ['分号+逗号取更右的', 'splitEditing', ['a@x, b@y;c@z', true]], ['连续分隔符', 'splitEditing', ['a@x,,b@y', true]], ['末尾就是逗号', 'splitEditing', ['a@x,', true]], - ['空串', 'splitEditing', ['', true]] + ['空串', 'splitEditing', ['', true]], + + /* + * ── normalizeCandidates:`omitempty` 缺键守卫 ── + * + * 这是本仓撞过的真坑:`SessionCandidate.title/source/unread` + * 都带 `omitempty` ⇒ 缺键时裸 cast 得到 `undefined`, + * 而类里那个 `= ''` **不会生效**。曾因此抛 `TypeError`。 + * + * 进用例表是为了让"鸿蒙写一份、electron 写一份"时 + * 两份守卫的边界**逐例一致**。 + */ + ['全正常', 'normalizeCandidates', [['a@x', 'b@y'], ['标题1', '标题2'], ['mail', 'platform'], [1, 0]]], + ['title 缺键', 'normalizeCandidates', [['a@x'], [undefined], [''], [0]]], + ['unread 缺键', 'normalizeCandidates', [['a@x'], [''], [''], [undefined]]], + ['candidates 比 suggestions 短', 'normalizeCandidates', [['a@x', 'b@y'], [''], [''], [0]]], + ['全空', 'normalizeCandidates', [[], [], [], []]], + + /* + * ── filterSets:同序过滤四个数组 ── + * + * 收掉 `filterIndexes` + 四次 `pickBy` 的样板。 + * 手写三遍就会有一处忘同步(四个数组长短不一就会错位)。 + */ + ['按片段过滤', 'filterSets', + [{ items: ['a@x.n', 'b@y.m'], titles: ['', ''], sources: ['', ''], unreads: [0, 0] }, 'a']], + ['无匹配', 'filterSets', + [{ items: ['a@x.n'], titles: [''], sources: [''], unreads: [0] }, 'zzz']], + ['空片段全保留', 'filterSets', + [{ items: ['a@x', 'b@y'], titles: ['t1', 't2'], sources: ['mail', 'platform'], unreads: [1, 2] }, '']] ] } ]; diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs index 23b98d9..bc6da93 100644 --- a/client/electron/test/harmony-2in1.test.mjs +++ b/client/electron/test/harmony-2in1.test.mjs @@ -285,13 +285,32 @@ test('2in1 快捷键|候选标题读 title 时守住 omitempty 缺键', () => * `m.to.trim()`,服务端 omit 时就 `Cannot read property trim of undefined` * ⇒ **整页白屏**。 * - * 改坏会红:把 `typeof raw === 'string' ? raw : ''` 换回 `cands[i].title`。 + * ★★ 2026-09-21 改:守卫从那三个字段的**内联读取**搬进了 + * `model/AddressSuggest.ts` 的 `normalizeCandidates` + * (因为转发条、日历事件也要同一套,内联三份必然漂移)。 + * + * 本判据原来钉的是**内联那行的字面文本** + * (`/typeof raw === 'string' ? raw : ''/`)—— + * 那是判据的**实现细节**,不是它的**意图**。搬了家就假红。 + * + * ⇒ 改钉两件真的事: + * ① 守卫本身在(三个字段各一道); + * ② **调用点真的走那道守卫**(而不是绕回去自己读 `.title`)。 + * 只钉①会被"守卫在库里、调用点旁路"绕过; + * 只钉②会被"调用了却传未守卫的原始值"绕过。两件都要。 */ - assert.match( - COMPOSE, - /typeof raw === 'string' \? raw : ''/, - '读 title 前必须守一道(omitempty 缺键时是 undefined,不是空串)' + const MODEL = code(join(ETS, 'model/AddressSuggest.ts')); + assert.match(MODEL, /typeof t === 'string' \? t : ''/, 'title 缺键要守成空串'); + assert.match(MODEL, /typeof s === 'string' \? s : ''/, 'source 缺键要守成空串'); + assert.match(MODEL, /typeof u === 'number' \? u : 0/, 'unread 缺键要守成 0'); + + /* 调用点必须经过 `normalizeCandidates`,不得自己读 `c.title` 当字符串用 */ + assert.match(COMPOSE, /normalizeCandidates\(/, '写信页要经共享归一化,不得内联重写一份守卫'); + assert.ok( + !/\.suggestTitles\s*=\s*[^;]*\.title\b/.test(COMPOSE), + '不得把候选的 title 直接赋给 suggestTitles(绕过守卫)' ); + /* 而且模型里的字段名要跟服务端一致(服务端给的是 alias/title/source) */ const M = code(join(ETS, 'model/Models.ets')); const cls = M.slice(M.indexOf('export class AddressSuggestion')); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 6bfddde..bb0d16e 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -619,10 +619,19 @@ test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不 const at = src.indexOf(t.needle); assert.ok(at >= 0, `${t.file} 里找不到「${t.why}」的挂载条件(${t.needle})—— 改名字要一起改判据`); /* - * 从挂载点往后扫到这一块的收尾(缩进回到同级),在这段里找 `.transition(`。 - * 扫 4000 字符足够:这些都是同一个 build 里的邻近修饰符链。 + * ★★ 2026-09-21 改:不再用"往后扫固定 N 字符",改成**按花括号配对找块尾**。 + * + * 原来这里写的是 `src.slice(at, at + 4000)`。 + * 那个 4000 是个**拍脑袋的数字**,而且它会随时间失效: + * 我在转发弹层里加了地址补全(约 2000 字符的候选列表)后, + * `.transition(Theme.paneRiseIn())` 被推到距挂载点 **6107** 字符处 + * ⇒ 判据报「转发弹层没有挂过渡」——而它**明明是挂着的**。 + * + * 这种假红比漏报更危险的副作用是:它会诱人去**加大那个数字**, + * 而正确的修法是把它换成"这块到哪里结束"——即配对。 + * 数字每改一次就多一次"下次又不够"的机会。 */ - const window = src.slice(at, at + 4000); + const window = braceBody(src, t.needle); assert.match(window, /\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/, `${t.file} 的「${t.why}」没有挂过渡 —— 它会硬弹出来。` + '就地展开用 `paneRiseIn()`,浮层/下拉用 `menuIn()`(取值出处见 Theme 的注释)'); diff --git a/client/harmony/entry/src/main/ets/api/CalendarApi.ets b/client/harmony/entry/src/main/ets/api/CalendarApi.ets index 253a98f..8225c63 100644 --- a/client/harmony/entry/src/main/ets/api/CalendarApi.ets +++ b/client/harmony/entry/src/main/ets/api/CalendarApi.ets @@ -9,7 +9,7 @@ * 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。 */ import { ApiClient } from './ApiClient'; -import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse, CalendarImportResponse } from '../model/Models'; +import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse, CalendarImportResponse, AddressSuggestionResponse } from '../model/Models'; export class CalendarApi { private client: ApiClient; @@ -51,6 +51,33 @@ export class CalendarApi { return this.client.del('/calendar/events/' + encodeURIComponent(id)); } + /** + * ★★ 2026-09-21 新增:三维地址补全(与 `MailApi.suggestAddress` 同一个端点)。 + * + * 为什么日历也要:事件编辑器有收件人字段(WebUI `CalendarEventEditor.tsx:471` + * 就是一个 `AddressInput`),而鸿蒙侧它一直是裸 `TextInput` —— + * 也就是用户说的「好几个自动填充位置」里遗漏的那个。 + * + * ★ 服务端只有**一个** `/contacts/suggest`(`contacts.go:145`), + * 两个 API 类各自转一道是这里**刻意**的:`CalendarApi` 去 import + * `MailApi` 会让两个同级 API 互相依赖。两行重复换掉一个错的依赖方向。 + * + * ★ 传空串还是省略参数:**两者等价**(服务端 `contacts.go:152` 只有 + * `Query().Get()`、没有 `Has()`,缺参与空串都给 `""`)。 + * 实测:`?name=pi` 与 `?name=pi&path=` 返回逐字节相同。 + * 这里的 `if (path.length > 0)` 只是少拼一个空参数,不是行为需要。 + */ + async suggestAddress(name: string, path: string): Promise { + let q: string = ''; + if (name.length > 0) { + q += 'name=' + encodeURIComponent(name); + } + if (path.length > 0) { + q += (q.length > 0 ? '&' : '') + 'path=' + encodeURIComponent(path); + } + return this.client.get('/contacts/suggest', q); + } + /** * 一段日期区间的农历标签(按 `YYYY-MM-DD` 索引)。 * diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index c37eeb9..92a8ac1 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -6,7 +6,8 @@ */ import { ApiClient } from './ApiClient'; -import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest, AddressSuggestion} from '../model/Models'; +import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest, AddressSuggestion, AddressSuggestionResponse} from '../model/Models'; +export { AddressSuggestionResponse }; /** 收件箱响应 */ export class InboxResponse { @@ -82,21 +83,21 @@ export class AttachmentListResponse { attachments: AttachmentInfo[] = []; } -/** 地址补全响应 */ -export class AddressSuggestionResponse { - suggestions: string[] = []; - /** - * 与 `suggestions` **按下标一一对应**的补充信息 - * (会话别名会有 `title`;`name`/`path` 段可能为空数组)。 - * - * ★ 服务端按两层返回:`suggestions` 是纯字符串、`candidates` 是对象。 - * 过滤时必须**同时**按下标保留两者,否则标题会错位到别的别名上 - * (WebUI `AddressInput.tsx:60-65` 那段注释专门记了这条)。 - */ - candidates: AddressSuggestion[] = []; - /** 当前问的是哪一层:`name` | `path` | `session` */ - kind: string = ''; -} +/* + * ★★ 2026-09-21 改:`AddressSuggestionResponse` 移到 `model/Models.ets` + * (与它的元素类型 `AddressSuggestion` 同处),本文件在顶部 import 后 + * re-export,既有调用点不变。 + * + * 为什么要移:`CalendarApi` 也需要这个类型(日历事件编辑器有收件人字段), + * 而让两个 API 类互相 import 是错误的依赖方向。 + * + * ★ 两个坑都踩了: + * ① `export { X } from '...'` **不会**在本文件建立局部绑定 ⇒ 本文件自己用 + * `X` 报 `Cannot find name`; + * ② 于是想"那就 import + export",但写在文件**中部**(原处)⇒ 报 + * `arkts-no-misplaced-imports`(ArkTS 要求 import 在其它语句之前)。 + * ⇒ 正确形状就是上面那一行:顶部 import,紧随 export。 + */ /** 改权限请求体 */ export class PermissionModePayload { @@ -238,8 +239,33 @@ export class MailApi { * · 写了 `@` 没写 `.` → 问该 Agent 的工作区路径 * · 写了 `.` → 问该工作区下的会话别名(含 `new`) * - * ★ 为什么要传空串而不是省略参数:服务端的判据是"参数有没有给", - * 不是"值是否为空"。省略会让它退回到上一层。 + * ★ 为什么要传空串而不是省略参数:—— + * + * ★★ 2026-09-21 改正:这句原来写的是 + * 「服务端的判据是"参数有没有给",不是"值是否为空"。 + * 省略会让它退回到上一层。」 + * **前后两句都是错的**,而且这个错误差点让我改坏 electron。 + * + * 服务端(`contacts.go:152`)只有: + * name := r.URL.Query().Get("name") + * path := r.URL.Query().Get("path") + * 没有 `Has()`(全文搜过),`Get()` 对"缺参"与"空串"都给 `""` + * ⇒ **两者完全等价**,省略**不会** + * 退回上一层。实测(网关 8180,同一账号): + * ?name=pi → kind=path, 62 条 + * ?name=pi&path= → kind=path, 62 条 ← 与上一行逐字节相同 + * ?path= → kind=name, 6 条 + * + * ⇒ 本方法里 `if (path.length > 0)` 那道守卫**不是**行为需要, + * 只是少拼一个空参数(URL 更短、日志更好读)。 + * 真正需要守的是 `name`:`name` 空就退 name 层,这是分支语义。 + * + * ★ 为什么要留这段:"以为空串会退回上一层"是**一个具体的错**, + * 它让 electron 的 `AddressInput` 在 2026-09-21 被我从 + * 「按层传 0/1/2 个参数」简化成了「恒定两个参数」—— + * 而那个调用形状是组件文档化的契约(`AddressInput.test.tsx:73/86`), + * 于是两条测试红了几轮,我还两次把它们归成"预先存在的问题"。 + * **注释里的错误事实会变成代码里的错误决定。** */ async suggestAddress(name: string, path: string): Promise { /* diff --git a/client/harmony/entry/src/main/ets/model/AddressSuggest.ts b/client/harmony/entry/src/main/ets/model/AddressSuggest.ts index 085e69d..6b3eac8 100644 --- a/client/harmony/entry/src/main/ets/model/AddressSuggest.ts +++ b/client/harmony/entry/src/main/ets/model/AddressSuggest.ts @@ -236,3 +236,62 @@ export function pickBy(arr: T[], indexes: number[]): T[] { } return out; } + +/** + * 服务端候选的三个可选字段(都已归一成"一定有值"的形状)。 + * + * ★★ 2026-09-21 新增:抽出来的理由是**同一个坑要防四次**。 + * + * `SessionCandidate` 的三个字段(`title` / `source` / `unread`) + * 都带 `omitempty`(`platform_sessions.go:97/101` 等)⇒ **缺键时裸 cast 拿到的是 + * `undefined`**,而类里声明的 `= ''` / `= 0` **不会生效**(JSON 反序列化 + * 直接赋 `undefined`,不走构造器)。本仓已因此抛过 `TypeError`。 + * + * 我原来把这套 `typeof` 守卫内联在 `ComposePage.fetchSuggestions` 里。 + * 现在转发条、日历事件都要用 ⇒ 内联三份必然漂移(一份记得守、另一份忘了)。 + * + * ⇒ 归一化收进纯函数(可被 `cross-client-logic` 跑),三个调用点共用。 + * 归一化后:`titles[i]` / `sources[i]` 是字符串,`unreads[i]` 是数字。 + */ +export function normalizeCandidates( + suggestions: string[], + titles: (string | undefined)[], + sources: (string | undefined)[], + unreads: (number | undefined)[] +): CandidateSets { + const out: CandidateSets = new CandidateSets(); + out.items = suggestions.slice(); + for (let i = 0; i < suggestions.length; i++) { + const t: string | undefined = i < titles.length ? titles[i] : undefined; + out.titles.push(typeof t === 'string' ? t : ''); + const s: string | undefined = i < sources.length ? sources[i] : undefined; + out.sources.push(typeof s === 'string' ? s : ''); + const u: number | undefined = i < unreads.length ? unreads[i] : undefined; + out.unreads.push(typeof u === 'number' ? u : 0); + } + return out; +} + +/** 归一化后的候选集(四个数组**等长同序**) */ +export class CandidateSets { + items: string[] = []; + titles: string[] = []; + sources: string[] = []; + unreads: number[] = []; +} + +/** + * 按当前输入片段过滤一整套候选(保持四个数组同序)。 + * + * 把 `filterIndexes` + 四次 `pickBy` 的样板收成一件 —— 三个调用点 + * (写信页 / 转发条 / 日历事件)逐字相同,但手写三遍就会有一处忘同步。 + */ +export function filterSets(sets: CandidateSets, fragment: string): CandidateSets { + const keep: number[] = filterIndexes(sets.items, sets.titles, fragment); + const out: CandidateSets = new CandidateSets(); + out.items = pickBy(sets.items, keep); + out.titles = pickBy(sets.titles, keep); + out.sources = pickBy(sets.sources, keep); + out.unreads = pickBy(sets.unreads, keep); + return out; +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 48d7a72..bd3fed1 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -447,6 +447,37 @@ export class AddressSuggestion { unread: number = 0; } +/** + * 地址补全响应(`GET /contacts/suggest`)。 + * + * ★★ 2026-09-21 从 `api/MailApi.ets` **移到这里**。 + * + * 它原来住在 `MailApi` 里 —— 而`AddressSuggestion`(它的元素类型) + * 一直在这个文件。一个响应的壳与它的元素被拆到两个文件, + * 后果是**第二个调用方就用不了**: + * 日历事件编辑器也有收件人字段(WebUI `CalendarEventEditor.tsx:471` + * 就是一个 `AddressInput`),而 `CalendarApi` 要拿这个类型 + * 就得 `import` 另一个 API 类 —— 那是错误的依赖方向 + * (两个 API 类之间不该互相 import)。 + * + * 它是**服务端响应形状**,属于数据模型,所以与 `AddressSuggestion` + * 放一起。`MailApi` 里保留 re-export 以免改掉既有调用点。 + */ +export class AddressSuggestionResponse { + suggestions: string[] = []; + /** + * 与 `suggestions` **按下标一一对应**的补充信息 + * (会话别名会有 `title`;`name`/`path` 段可能为空数组)。 + * + * ★ 服务端按两层返回:`suggestions` 是纯字符串、`candidates` 是对象。 + * 过滤时必须**同时**按下标保留两者,否则标题会错位到别的别名上 + * (WebUI `AddressInput.tsx:60-65` 那段注释专门记了这条)。 + */ + candidates: AddressSuggestion[] = []; + /** 当前问的是哪一层:`name` | `path` | `session` */ + kind: string = ''; +} + /** 发信请求体 */ export class SendMailRequest { to: string = ''; diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index e0e55b8..5a6e779 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -36,7 +36,13 @@ import { ApiClient, ApiError } from '../api/ApiClient'; import { CalendarApi } from '../api/CalendarApi'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse } from '../model/Models'; +import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse, AddressSuggestionResponse } from '../model/Models'; +import { + AddressParts, CandidateSets, SuggestQuery, + parseParts, queryFor, mergeCandidate, nextActiveIndex, + splitEditing, normalizeCandidates, filterSets +} from '../model/AddressSuggest'; +import { KeyCode } from '@kit.InputKit'; import { Theme } from '../common/Theme'; import { GlassCardModifier, PaneModifier, PressEffectModifier } from '../common/Surface'; import { AmIcon } from '../common/Icons'; @@ -205,6 +211,21 @@ export struct CalendarPage { @State editingId: string = ''; @State fTitle: string = ''; @State fRecipients: string = ''; + /* + * ★★ 2026-09-21 新增:收件人字段的**地址补全**(用户:「webui 存在好几个 + * 自动填充位置,比如抄送,转发等」)。 + * + * WebUI 的事件编辑器用的是同一个 `AddressInput` + * (`CalendarEventEditor.tsx:471`)⇒ 它一直有补全,鸿蒙这根是裸框。 + * 复用 `model/AddressSuggest.ts` 的同一套纯逻辑。 + */ + @State fRecipSuggestItems: string[] = []; + @State fRecipSuggestTitles: string[] = []; + @State fRecipSuggestSources: string[] = []; + @State fRecipSuggestUnreads: number[] = []; + @State fRecipSuggestActive: number = 0; + @State fRecipSuggestOpen: boolean = false; + private fRecipTimer: number = -1; @State fReminderText: string = ''; @State fRemindBefore: number = 0; @State fRecurrence: string = 'none'; @@ -348,6 +369,104 @@ export struct CalendarPage { * 把它当致命错误会让“农历服务抖一下”变成“整个日历打不开”—— 那是本末倒置。 * 但也不静默吞掉:写 hilog 留下痕迹(便于排查为什么格子上没农历)。 */ + /** + * ★★ 2026-09-21 新增:事件编辑器收件人字段的地址补全。 + * + * 与写信页、转发条**同一套纯逻辑**(`model/AddressSuggest.ts`): + * 切段 ↓ 解析 ↓ 查询 ↓ 归一化 ↓ 过滤。 + * + * ★ 收件人字段在日历事件里是**逗号分隔多个** + * (`fRecipients.split(',')` 就在提交路径上,`saveEvent` 附近) + * ⇒ `allowMultiple = true`,只补全最后一段。 + */ + private onRecipChanged(v: string): void { + this.fRecipients = v; + if (this.fRecipTimer >= 0) { + clearTimeout(this.fRecipTimer); + } + /* 120ms 防抖,与 WebUI `AddressInput.tsx:79` 同值 */ + this.fRecipTimer = setTimeout(() => { + this.fetchRecipSuggestions(); + }, 120); + } + + private async fetchRecipSuggestions(): Promise { + const a: CalendarApi | null = this.api; + if (a === null) { + return; + } + const editing: string = splitEditing(this.fRecipients, true).editing; + const parts: AddressParts = parseParts(editing); + const q: SuggestQuery = queryFor(parts); + try { + const res: AddressSuggestionResponse = await a.suggestAddress(q.name, q.path); + const all: string[] = res.suggestions ?? []; + const cands = res.candidates ?? []; + /* + * 显式循环而不是 `cands.map(...)`:ArkTS 对回调的返回类型推断有限 + * (`arkts-no-implicit-return-types`),且 `undefined` 会撞 + * `arkts-no-any-unknown`。写信页那边同样是手写循环。 + */ + const titles: (string | undefined)[] = []; + const sources: (string | undefined)[] = []; + const unreads: (number | undefined)[] = []; + for (let i = 0; i < cands.length; i++) { + const c = cands[i]; + titles.push(c === undefined ? undefined : c.title); + sources.push(c === undefined ? undefined : c.source); + unreads.push(c === undefined ? undefined : c.unread); + } + const sets: CandidateSets = normalizeCandidates(all, titles, sources, unreads); + const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name); + const keep: CandidateSets = filterSets(sets, frag); + this.fRecipSuggestItems = keep.items; + this.fRecipSuggestTitles = keep.titles; + this.fRecipSuggestSources = keep.sources; + this.fRecipSuggestUnreads = keep.unreads; + this.fRecipSuggestActive = 0; + this.fRecipSuggestOpen = keep.items.length > 0; + } catch { + /* 拉不到候选不是错误:静默收起,能照常手打 */ + this.fRecipSuggestItems = []; + this.fRecipSuggestTitles = []; + this.fRecipSuggestOpen = false; + } + } + + private applyRecipSuggestion(choice: string): void { + const split = splitEditing(this.fRecipients, true); + const parts: AddressParts = parseParts(split.editing); + const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name'); + const merged: string = mergeCandidate(parts, kind, choice); + this.fRecipients = split.head.length > 0 ? split.head + ' ' + merged : merged; + if (kind === 'session') { + this.fRecipSuggestOpen = false; + this.fRecipSuggestItems = []; + } else { + this.fetchRecipSuggestions(); + } + } + + private onRecipKey(e: KeyEvent): void { + if (e.type !== KeyType.Down) { + return; + } + if (!this.fRecipSuggestOpen || this.fRecipSuggestItems.length === 0) { + return; + } + if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) { + this.fRecipSuggestActive = nextActiveIndex(this.fRecipSuggestActive, this.fRecipSuggestItems.length, 1); + } else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) { + this.fRecipSuggestActive = nextActiveIndex(this.fRecipSuggestActive, this.fRecipSuggestItems.length, -1); + } else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) { + if (this.fRecipSuggestActive >= 0 && this.fRecipSuggestActive < this.fRecipSuggestItems.length) { + this.applyRecipSuggestion(this.fRecipSuggestItems[this.fRecipSuggestActive]); + } + } else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) { + this.fRecipSuggestOpen = false; + } + } + private async loadLunar(): Promise { const a: CalendarApi | null = this.api; if (a === null) { @@ -1301,8 +1420,56 @@ export struct CalendarPage { .margin({ top: 8 }) TextInput({ text: this.fRecipients, placeholder: 'name@path.session' }) .onChange((v: string) => { - this.fRecipients = v; + this.onRecipChanged(v); }) + .onKeyEvent((e: KeyEvent) => { this.onRecipKey(e); }) + .onBlur(() => { this.fRecipSuggestOpen = false; }) + + /* + * 候选列表(与写信页/转发条同一套):不用 `bindPopup`/`bindMenu`, + * 它们各有自己的焦点体系会吃掉 ↑↓/Enter。 + */ + if (this.fRecipSuggestOpen && this.fRecipSuggestItems.length > 0) { + Column() { + ForEach(this.fRecipSuggestItems, (item: string, idx: number) => { + Row() { + Text(item) + .fontSize(Theme.fontTiny) + .fontFamily('monospace') + .fontColor(idx === this.fRecipSuggestActive ? Theme.accentFor() : Theme.textPrimary) + .layoutWeight(1) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + if (idx < this.fRecipSuggestSources.length + && this.fRecipSuggestSources[idx] === 'platform') { + Text('平台') + .fontSize(9).fontColor(Theme.accentFor()) + .padding({ left: 3, right: 3, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(Theme.accentSoftFor(this.isDarkNow)) + } + if (idx < this.fRecipSuggestUnreads.length && this.fRecipSuggestUnreads[idx] > 0) { + Text(this.fRecipSuggestUnreads[idx].toString()) + .fontSize(9).fontColor(Theme.accentFg) + .padding({ left: 3, right: 3, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(Theme.danger) + } + } + .width('100%').height(30).padding({ left: 8, right: 8 }) + .borderRadius(Theme.radiusControl) + .backgroundColor(idx === this.fRecipSuggestActive + ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) + .onClick(() => { this.applyRecipSuggestion(item); }) + }, (item: string, idx: number) => item + '#' + idx.toString()) + } + .width('100%') + .margin({ top: 4 }) + .padding(4) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusControl) + .border({ width: 1, color: Theme.border }) + .clip(true) + } Text('重复') .fontSize(Theme.fontTiny) diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index e35e316..4a12625 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -16,7 +16,7 @@ import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; import { PressEffectModifier } from '../common/Surface'; import { Motion } from '../common/Motion'; import { KeyCode } from '@kit.InputKit'; -import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, splitEditing, AddressParts, EditingSplit, SuggestQuery } from '../model/AddressSuggest'; +import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, splitEditing, normalizeCandidates, filterSets, AddressParts, CandidateSets, EditingSplit, SuggestQuery } from '../model/AddressSuggest'; /** * 写邮件窗格。 @@ -204,43 +204,28 @@ export struct ComposeView { * 取标题 —— `title` 带 `omitempty`,缺键时裸 cast 给的是 `undefined` * (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。 */ - const titles: string[] = []; - const sources: string[] = []; - const unreads: number[] = []; - for (let i = 0; i < cands.length; i++) { - const c = cands[i]; - const raw: string | undefined = c === undefined ? undefined : c.title; - titles.push(typeof raw === 'string' ? raw : ''); - /* - * `source` 与 `unread` 同样带 `omitempty` - * (`platform_sessions.go:97/101`)⇒ 缺键时裸 cast 是 `undefined`。 - * `Unread` 缺键时给 `undefined`,直接比较 `> 0` 在 ArkTS 里类型不过 ⇒ 守一道。 - */ - const src: string | undefined = c === undefined ? undefined : c.source; - sources.push(typeof src === 'string' ? src : ''); - const un: number | undefined = c === undefined ? undefined : c.unread; - unreads.push(typeof un === 'number' ? un : 0); - } + /* + * ★★ 2026-09-21 改:归一化 + 过滤抽到 `model/AddressSuggest.ts` + * (`normalizeCandidates` / `filterSets`)。 + * + * 原来这 30 行内联在这里。而转发条、日历事件也要同一套 ⇒ + * 内联三份必然漂移(一份记得守 `omitempty`、另一份忘了)。 + * 抽出来后一套实现被三个调用点共用,并被 `cross-client-logic` 跑。 + */ + const sets: CandidateSets = normalizeCandidates( + all, + cands.map((c) => c === undefined ? undefined : c.title), + cands.map((c) => c === undefined ? undefined : c.source), + cands.map((c) => c === undefined ? undefined : c.unread)); /* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */ const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name); - const keep: number[] = filterIndexes(all, titles, frag); - const items: string[] = []; - const keepTitles: string[] = []; - const keepSources: string[] = []; - const keepUnreads: number[] = []; - for (let i = 0; i < keep.length; i++) { - const k: number = keep[i]; - items.push(all[k]); - keepTitles.push(titles[k] ?? ''); - keepSources.push(sources[k] ?? ''); - keepUnreads.push(unreads[k] ?? 0); - } - this.suggestItems = items; - this.suggestTitles = keepTitles; - this.suggestSources = keepSources; - this.suggestUnreads = keepUnreads; + const keep: CandidateSets = filterSets(sets, frag); + this.suggestItems = keep.items; + this.suggestTitles = keep.titles; + this.suggestSources = keep.sources; + this.suggestUnreads = keep.unreads; this.suggestActive = 0; - this.suggestOpen = items.length > 0; + this.suggestOpen = keep.items.length > 0; } catch { /* * 拉不到候选**不是错误**:静默收起,用户照常手打地址。 diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index ecfa227..f330645 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -7,12 +7,18 @@ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; /* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */ import { Markdown, MarkdownController } from '@luvi/lv-markdown-in'; -import { MailApi, SessionBudget, ThreadApiResponse } from '../api/MailApi'; +import { MailApi, SessionBudget, ThreadApiResponse, AddressSuggestionResponse } from '../api/MailApi'; import { ThreadNode } from '../model/Models'; import { SessionApi } from '../api/SessionApi'; import { RenameProposal } from '../model/SessionRename'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { MailDetail, SendMailRequest, ForwardMailRequest, Address, AttachmentInfo } from '../model/Models'; +import { + AddressParts, CandidateSets, EditingSplit, SuggestQuery, + parseParts, queryFor, mergeCandidate, nextActiveIndex, + splitEditing, normalizeCandidates, filterSets +} from '../model/AddressSuggest'; +import { KeyCode } from '@kit.InputKit'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; import { saveBinaryFile, IcsSaveResult } from '../common/IcsFile'; @@ -145,6 +151,26 @@ export struct MailDetailView { /** 抄送输入是否展开(WebUI 的 `ccOpen`:默认收起,点「抄送」才出现) */ @State forwardCcOpen: boolean = false; @State forwardComment: string = ''; + /* + * ★★ 2026-09-21 新增:转发条的**地址补全**(用户:「webui 存在好几个自动 + * 填充位置,比如抄送,转发等」)。 + * + * WebUI 的转发条是两个 `AddressInput`(`MailView.tsx:1087` 抄送 + + * 收件人那一个),即**两个都有补全**。鸿蒙这边两个都是裸 `TextInput`。 + * + * 复用与写信页**同一套纯逻辑**(`model/AddressSuggest.ts`): + * 切段 ↓ 解析 ↓ 查询 ↓ 归一化 ↓ 过滤 ↓ 拼回。 + * 这里只放界面状态。 + */ + @State fwdSuggestItems: string[] = []; + @State fwdSuggestTitles: string[] = []; + @State fwdSuggestSources: string[] = []; + @State fwdSuggestUnreads: number[] = []; + @State fwdSuggestActive: number = 0; + @State fwdSuggestOpen: boolean = false; + /** 当前在给哪个字段补全(`'to'` | `'cc'`)—— cc 是多地址模式 */ + private fwdSuggestField: string = 'to'; + private fwdSuggestTimer: number = -1; @State sessionId: string = ''; @State switchingPerm: boolean = false; /** @@ -303,6 +329,113 @@ export struct MailDetailView { } } + /** + * ★★ 2026-09-21 新增:转发条的地址补全(字段无关,与写信页同一套)。 + * + * 三个小方法而不是一个带 `field` 参数的 —— ArkTS 不给函数重载, + * 而 `field` 参数会让每个调用点多传一个常量(更容易写错)。 + * 这里只有 to / cc 两个字段,所以显式分流更好读。 + */ + private onFwdToChanged(v: string): void { + this.forwardTo = v; + this.scheduleFwdSuggest('to'); + } + + private onFwdCcChanged(v: string): void { + this.forwardCc = v; + this.scheduleFwdSuggest('cc'); + } + + private scheduleFwdSuggest(field: string): void { + this.fwdSuggestField = field; + if (this.fwdSuggestTimer >= 0) { + clearTimeout(this.fwdSuggestTimer); + } + /* 120ms 防抖与 WebUI `AddressInput.tsx:79` 同值 */ + this.fwdSuggestTimer = setTimeout(() => { + this.fetchFwdSuggestions(); + }, 120); + } + + private async fetchFwdSuggestions(): Promise { + const api: MailApi | null = this.mailApi; + if (api === null) { + return; + } + const multi: boolean = this.fwdSuggestField === 'cc'; + const value: string = multi ? this.forwardCc : this.forwardTo; + /* 多地址字段只拿**最后一段**去解析(否则 `indexOf('@')` 取到第一条的) */ + const editing: string = splitEditing(value, multi).editing; + const parts: AddressParts = parseParts(editing); + const q: SuggestQuery = queryFor(parts); + try { + const res: AddressSuggestionResponse = await api.suggestAddress(q.name, q.path); + const all: string[] = res.suggestions ?? []; + const cands = res.candidates ?? []; + const sets: CandidateSets = normalizeCandidates( + all, + cands.map((c) => c === undefined ? undefined : c.title), + cands.map((c) => c === undefined ? undefined : c.source), + cands.map((c) => c === undefined ? undefined : c.unread)); + const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name); + const keep: CandidateSets = filterSets(sets, frag); + this.fwdSuggestItems = keep.items; + this.fwdSuggestTitles = keep.titles; + this.fwdSuggestSources = keep.sources; + this.fwdSuggestUnreads = keep.unreads; + this.fwdSuggestActive = 0; + this.fwdSuggestOpen = keep.items.length > 0; + } catch { + /* 拉不到候选不是错误:静默收起,用户照常手打(与写信页同口径) */ + this.fwdSuggestItems = []; + this.fwdSuggestTitles = []; + this.fwdSuggestOpen = false; + } + } + + /** 选中一个候选 —— 拼回;多地址时保留前缀(与写信页/WebUI 同拼法) */ + private applyFwdSuggestion(choice: string): void { + const multi: boolean = this.fwdSuggestField === 'cc'; + const value: string = multi ? this.forwardCc : this.forwardTo; + const split: EditingSplit = splitEditing(value, multi); + const parts: AddressParts = parseParts(split.editing); + const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name'); + const merged: string = mergeCandidate(parts, kind, choice); + const next: string = multi && split.head.length > 0 ? split.head + ' ' + merged : merged; + if (multi) { + this.forwardCc = next; + } else { + this.forwardTo = next; + } + if (kind === 'session') { + this.fwdSuggestOpen = false; + this.fwdSuggestItems = []; + } else { + this.fetchFwdSuggestions(); + } + } + + /** ↑↓ 换候选 / Enter·Tab 选中 / Esc 收起 —— 与写信页同一套按键 */ + private onFwdKey(e: KeyEvent): void { + if (e.type !== KeyType.Down) { + return; + } + if (!this.fwdSuggestOpen || this.fwdSuggestItems.length === 0) { + return; + } + if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) { + this.fwdSuggestActive = nextActiveIndex(this.fwdSuggestActive, this.fwdSuggestItems.length, 1); + } else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) { + this.fwdSuggestActive = nextActiveIndex(this.fwdSuggestActive, this.fwdSuggestItems.length, -1); + } else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) { + if (this.fwdSuggestActive >= 0 && this.fwdSuggestActive < this.fwdSuggestItems.length) { + this.applyFwdSuggestion(this.fwdSuggestItems[this.fwdSuggestActive]); + } + } else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) { + this.fwdSuggestOpen = false; + } + } + /** * 打开回复条 —— **带共享元素转场**。 * @@ -1421,13 +1554,68 @@ export struct MailDetailView { TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' }) .width('100%').height(38).fontSize(13) - .onChange((v: string) => { this.forwardTo = v; }) + .onChange((v: string) => { this.onFwdToChanged(v); }) + .onKeyEvent((e: KeyEvent) => { this.onFwdKey(e); }) + .onBlur(() => { this.fwdSuggestOpen = false; }) if (this.forwardCcOpen) { TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' }) .width('100%').height(38).fontSize(13) .margin({ top: 8 }) - .onChange((v: string) => { this.forwardCc = v; }) + .onChange((v: string) => { this.onFwdCcChanged(v); }) + .onKeyEvent((e: KeyEvent) => { this.onFwdKey(e); }) + .onBlur(() => { this.fwdSuggestOpen = false; }) + } + + /* + * ★★ 2026-09-21 新增:候选列表 —— 贴在两个地址框**下方**。 + * + * WebUI `AddressInput` 自身带下拉菜单(`MailView.tsx:1087` 那两个 + * `AddressInput` 各自持有菜单)⇒ 转发条本来就有补全,只有鸿蒙这边是裸框。 + * + * 不用 `bindPopup`/`bindMenu`:那两者有自己的焦点体系, + * 会把 ↑↓/Enter 吃掉,键可达就没了(写信页那段注释同一理由)。 + */ + if (this.fwdSuggestOpen && this.fwdSuggestItems.length > 0) { + Column() { + ForEach(this.fwdSuggestItems, (item: string, idx: number) => { + Row() { + Text(item) + .fontSize(13) + .fontFamily('monospace') + .fontColor(idx === this.fwdSuggestActive ? Theme.accentFor() : Theme.textPrimary) + .layoutWeight(1) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + if (idx < this.fwdSuggestSources.length + && this.fwdSuggestSources[idx] === 'platform') { + Text('平台') + .fontSize(10).fontColor(Theme.accentFor()) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(Theme.accentSoftFor(this.isDarkNow)) + } + if (idx < this.fwdSuggestUnreads.length && this.fwdSuggestUnreads[idx] > 0) { + Text(this.fwdSuggestUnreads[idx].toString()) + .fontSize(10).fontColor(Theme.accentFg) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(Theme.danger) + } + } + .width('100%').height(34).padding({ left: 8, right: 8 }) + .borderRadius(Theme.radiusControl) + .backgroundColor(idx === this.fwdSuggestActive + ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) + .onClick(() => { this.applyFwdSuggestion(item); }) + }, (item: string, idx: number) => item + '#' + idx.toString()) + } + .width('100%') + .margin({ top: 4 }) + .padding(4) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusControl) + .border({ width: 1, color: Theme.border }) + .clip(true) } /*