用户两句话把问题指到了根上:
① 「发件箱存在严重问题」
② 「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 个 ✗ 我
只修了写信页那一处)。它们是**同一条线索的剩余项**,不是新发现 ——
但我不该再一次只修被点到的那一个。
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
/**
|
||
* 三维地址补全的**纯逻辑** —— 与鸿蒙 `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;
|
||
}
|