/** * 三维地址补全的**纯逻辑** —— 与鸿蒙 `model/AddressSuggest.ts` 一对。 * * 从 `components/AddressInput.tsx` 里抽出来的。抽的原因有两个: * ① 逻辑散在组件闭包里时**没法单测**(`apply`/`onKeyDown` 都改 React state); * ② 鸿蒙侧要写同一套规则 —— 两份手抄必然漂移(本仓已有判据 * `cross-client-logic.test.mjs` 专门抓这个,它的首次运行就抓到过两处真分叉)。 * * ⇒ 抽成纯函数后,同一张用例表喂给两边、逐条比结果。 * * ★ 抽的时候**行为一字不改**:`parseParts` 是逐字搬过来的(含"取最后一个点")。 * 抽出来顺手"改进"一下是最危险的 —— 那会让判据比的是新行为, * 而线上跑的仍是旧行为,两边都"通过"了却都错。 */ export interface AddressParts { name: string; path: string; session: string; hasAt: boolean; hasDot: boolean; } /** * 把可能含多个地址的输入切成「已完成的前缀」+「正在编辑的最后一段」。 * * ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,比如抄送,转发等」)。 * * 2026-09-21 我给鸿蒙写信页写补全时,只做了**单地址**的收件人框 * (`parseParts(整串)`)—— 而 WebUI 的 `AddressInput` 从第一天起就有 * `allowMultiple`:抄送框里可以写 `a@x, b@y, c@z`,补全**只作用于最后一段**。 * * 原实现写在组件闭包里(`AddressInput.tsx:38-43` 的 `useMemo`), * 和 `parseParts` 当初一样的病:判据 import 不到,鸿蒙只能手抄一份。 * ⇒ 移到 `lib/`,两边共用同一份。 * * 行为**逐字照搬**组件里那段(含 `trimStart()`、含逗号/分号都算分隔符): * · 分隔符取 `,` 与 `;` 里**更靠右**的那个 —— 只认逗号的话, * 用户写分号(中文输入法下很容易打出)会把两段粘成一段。 * · `trimStart()` 不能省:`"a@x, b"` 里 b 前面有空格, * 不 trim 则 name 段是 `" b"`,拼出来是 `"a@x, b@..."`(空格带进地址)。 * * @param value 输入框全文 * @param allowMultiple 单地址字段(收件人/主题等)传 false,抄送类传 true */ export function splitEditing(value: string, allowMultiple: boolean): { head: string; editing: string } { 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() }; } /** * 把编辑中的一段拆成三段。 * * 没有 `@` ⇒ 整串都是 name;有 `@` 没 `.` ⇒ `@` 之后是 path; * 都有 ⇒ 以**最后一个** `.` 为界。 */ export function parseParts(s: string): AddressParts { const at = s.indexOf('@'); if (at < 0) { return { name: s, path: '', session: '', hasAt: false, hasDot: false }; } const name = s.slice(0, at); const rest = s.slice(at + 1); const dot = rest.lastIndexOf('.'); if (dot < 0) { return { name, path: rest, session: '', hasAt: true, hasDot: false }; } return { name, path: rest.slice(0, dot), session: rest.slice(dot + 1), hasAt: true, hasDot: true }; } /** * 把选中的候选拼回一段完整地址。 * * `name`/`path` 选完要**补上分隔符**(`@` / `.`),这样用户接着打字就自然 * 进入下一段,不必自己敲分隔符。 */ export function mergeCandidate(parts: AddressParts, kind: string, choice: string): string { if (kind === 'name') { return `${choice}@`; } if (kind === 'path') { return `${parts.name}@${choice}.`; } return `${parts.name}@${parts.path}.${choice}`; } /** * 上下键移动时的下一个下标(**循环**)。 * * ★ JS 的 `%` 对负数返回负数(`-1 % 5 === -1`),所以向上移动要写成 * `((i + d) % n + n) % n`。直接写 `(i-1) % n` 会得到负下标 —— * 而负下标在数组访问里**不报错**(返回 `undefined`),只会表现为 * "按上键之后菜单里没有任何一项高亮"。这类静默失败正是判据要钉的。 */ export function nextActiveIndex(current: number, count: number, delta: number): number { if (count <= 0) { return 0; } return (((current + delta) % count) + count) % count; } /** 候选菜单要不要向上翻转(下方空间不够且上方更多时翻) */ export function shouldFlipUp(spaceAbove: number, spaceBelow: number, need: number): boolean { const want = Math.min(need, spaceAbove); return spaceBelow < want && spaceAbove > spaceBelow; } export interface SuggestQuery { name: string; path: string; kind: 'name' | 'path' | 'session'; } /** 按当前输入决定"问哪一层" */ export function queryFor(parts: AddressParts): SuggestQuery { if (parts.hasDot) { return { name: parts.name, path: parts.path, kind: 'session' }; } if (parts.hasAt) { return { name: parts.name, path: '', kind: 'path' }; } return { name: '', path: '', kind: 'name' }; } /** * 过滤候选的下标 —— **保持 suggestions 与 candidates 同序**。 * * 分别过滤两个数组会让标题错位到别的别名上(`AddressInput.tsx:60` 的原注释)。 * 标题也参与匹配:人记得的是会话标题而不是随机短名。 */ export function filterIndexes( suggestions: string[], titles: string[], fragment: string ): number[] { const keep: number[] = []; const lower = fragment.toLowerCase(); suggestions.forEach((s, i) => { const title = titles[i] ?? ''; const hay = title ? `${s} ${title}`.toLowerCase() : s.toLowerCase(); if (hay.includes(lower)) { keep.push(i); } }); return keep; }