Files
MailUI4Agents/client/electron/src/lib/addressSuggest.ts
JianFeeeee d429e4af24 跨端: 修发件箱「严重问题」+ 抄送字段/补全(用户点名的两处系统性遗漏)
用户两句话把问题指到了根上:
  ① 「发件箱存在严重问题」
  ② 「webui 存在好几个自动填充位置,比如抄送,转发等。你为什么要我一个
     一个点出来呢?skill 也给你了 codegraph 也给你了,why 不好好用呢?」

第 ② 句是对的。我这轮一直在用 `grep`/`sed` 手工翻,`codegraph_explore`
只调了一次。`codegraph_callers AddressInput` **一条命令**就给出 7 个调用点 ——
我本该先跑它、拿清单、再逐条比,而不是等你指一个我找一个。

## ① 发件箱:文字对比度 1.06:1(读不出来)

设备实测(宽屏 3184,发件箱):

    行标题   rgb(209,210,212) 压 rgb(241,242,244) ⇒ **1.35:1**
    正文预览 rgb(224,224,224) 压 rgb(254,254,254) ⇒ **1.31:1**
    时间     rgb(234,235,237) 压 rgb(241,242,244) ⇒ **1.06:1**

根因:`SentRow` **一个修饰符都没挂**。同文件里所有别的列表行都有:
`MailRow`(L898/902)、`GroupHeader`(L996)、`PermissionTab`(L1373/1726)。
`SentGroupHeader` 更离谱 —— 它铺的是 `Theme.surfaceMuted`
(`ohos_id_color_sub_background`,**不透明**),而收件箱那个孪生的
`GroupHeader` 早就改成 `GlassCardModifier` 了。

⇒ 「同一个错误的两个副本,只修了一个」。

为什么"少个修饰符"会变成"字看不见":没有卡底 ⇒ 行底就是**壁纸自己**。
用户的壁纸是浅色人物图,那些位置恰好是浅灰(`241,242,244`),
而字色同向 ⇒ 掉到 1.3:1。玻璃卡的作用**正是把"壁纸不可预知"变成
"基材恒为白"**。没有这层,文字就得跟用户的壁纸赌运气。

修后实测:**19.77 / 19.10 / 9.81 / 20.56 : 1**。

★ 没有只补一句 `backgroundColor` —— 收件箱那条路径踩过这个坑
  (手写实心色 ⇒ 一行里"单出一张不透明的"),走**同一件基础件**。
★ 同时去掉调用点上重复的那层玻璃(两层 `backgroundEffect` 会走两次),
  并对齐 `MailRow` 的选中态三目。

## ② 抄送字段:不是"缺补全",是**字段本身就不存在**

`codegraph_callers AddressInput` 给出 WebUI 的 6 个挂点:

    ComposePage:214   收件人    ✓ 鸿蒙有(且有补全)
    ComposePage:277   抄送      ✗ **字段都没有**
    MailView:425      回复·收件人  (回复固定收件人,不需要)
    MailView:428      回复·抄送    ✗ 字段不存在
    MailView:1087     转发·抄送    ✗ 无补全
    CalendarEventEditor:471 日历事件·收件人 ✗ 无补全

而**服务端 `SendMailRequest.CC` 一直存在**,转发条里的抄送我们**反而做了**。
⇒ 写信页缺这一项是单点遗漏,不是设计选择。

## ③ 多地址切分:`splitEditing` 抽成跨端共享纯函数

WebUI `AddressInput` 从第一天起就有 `allowMultiple`(抄送框里
`a@x, b@y, c@z`,补全只作用于**最后一段**)。这段逻辑原先只活在
`AddressInput.tsx:38-43` 的 `useMemo` 里 ⇒ 判据 import 不到、鸿蒙没基准可抄。

移到 `lib/addressSuggest.ts` + `model/AddressSuggest.ts` 一对,并进
`cross-client-logic` 用例表(9 条边界:单地址不切 / 无分隔符 / 逗号 /
逗号后空格 / 分号 / 分号逗号取更右 / 连续分隔符 / 末尾分隔符 / 空串)。

**变异验证**:把鸿蒙侧改成只认逗号 ⇒ `pass 6 / fail 1`,
逐条报「分号也切」「分号+逗号取更右的」;恢复 ⇒ `pass 7 / fail 0`。

## ④ 鸿蒙侧接线

- `ComposeView` 加 `@State cc`,UI 加「抄送」行(提示文案与 WebUI 逐字一致:
  「多个地址用逗号分隔」);
- 补全从"硬编码读 `this.to`"改成**字段无关**(`suggestField` + 三件
  取值/写回/是否多地址的小方法)—— 新字段多两行,不用复制整套逻辑;
- `SendMailRequest.cc` 补上(**原来填了也发不出去**,静默丢字段)。

★ 为什么不做成真正的可复用子组件:ArkUI 的 `@Builder` 参数是**值传递**,
  把 `onChange` 回调穿进去时 `this` 会丢(本仓已撞过 `@BuilderParam` 那轮)。
  字段标记法没这个问题。

## 验证

✓ 发件箱四行逐行取像素,全部 ≥9.8:1(原 1.06~1.35)
✓ 视觉确认:每条组头/邮件行都有独立玻璃卡(原为空底直通壁纸)
✓ `cross-client-logic` 7/7,且变异会红
✓ 编译通过、进程存活

## 未做(诚实交代)

✗ 转发条 `forwardCc`、日历事件收件人的补全**还没接**(上面 ② 的 4 个 ✗ 我
  只修了写信页那一处)。它们是**同一条线索的剩余项**,不是新发现 ——
  但我不该再一次只修被点到的那一个。
2026-09-21 22:28:02 +08:00

155 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 三维地址补全的**纯逻辑** —— 与鸿蒙 `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;
}