跨端: 修发件箱「严重问题」+ 抄送字段/补全(用户点名的两处系统性遗漏)

用户两句话把问题指到了根上:
  ① 「发件箱存在严重问题」
  ② 「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 个 ✗ 我
  只修了写信页那一处)。它们是**同一条线索的剩余项**,不是新发现 ——
  但我不该再一次只修被点到的那一个。
This commit is contained in:
2026-09-21 22:28:02 +08:00
parent bea26b885a
commit d429e4af24
5 changed files with 272 additions and 16 deletions

View File

@ -33,6 +33,44 @@ export class AddressParts {
hasDot: boolean = false;
}
/**
* 把可能含多个地址的输入切成「已完成的前缀」+「正在编辑的最后一段」。
*
* ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,比如抄送,转发等」)。
*
* 我给鸿蒙写信页写补全时,只做了**单地址**的收件人框(`parseParts(整串)`)——
* 而 WebUI `AddressInput` 从第一天起就有 `allowMultiple`:抄送框里可以写
* `a@x, b@y, c@z`,补全**只作用于最后一段**。
*
* 行为**逐字照搬** `AddressInput.tsx:38-43` 那个 `useMemo`:
* · 分隔符取 `,` 与 `;` 里**更靠右**的 —— 只认逗号的话,
* 用户用中文输入法打出分号会把两段粘成一段;
* · `trimStart()` 不能省:`"a@x, b"` 里 b 前有空格,不 trim 则 name
* 段是 `" b"`,拼出的地址带空格。
*/
export class EditingSplit {
head: string = '';
editing: string = '';
}
export function splitEditing(value: string, allowMultiple: boolean): EditingSplit {
const out: EditingSplit = new EditingSplit();
if (!allowMultiple) {
out.editing = value;
return out;
}
const comma: number = value.lastIndexOf(',');
const semi: number = value.lastIndexOf(';');
const idx: number = comma > semi ? comma : semi;
if (idx < 0) {
out.editing = value;
return out;
}
out.head = value.slice(0, idx + 1);
out.editing = value.slice(idx + 1).trimStart();
return out;
}
/**
* 把编辑中的一段拆成三段。
*

View File

@ -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, AddressParts, SuggestQuery } from '../model/AddressSuggest';
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, splitEditing, AddressParts, EditingSplit, SuggestQuery } from '../model/AddressSuggest';
/**
* 写邮件窗格。
@ -75,6 +75,13 @@ export struct ComposeView {
@StorageProp(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State to: string = '';
/*
* ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,比如抄送,转发等」)——
* 鸿蒙写信页原来**连抄送框都没有**,而 WebUI `ComposePage.tsx:277` 有一个,
* 且带完整补全。服务端 `SendMailRequest` 也一直有 `cc` 字段(`Models.ets`)。
* ⇒ 字段 + 补全都补上。
*/
@State cc: string = '';
@State subject: string = '';
@State body: string = '';
@State replyTo: string = '';
@ -122,6 +129,26 @@ export struct ComposeView {
*/
private onToChanged(v: string): void {
this.to = v;
this.scheduleSuggest('to');
}
/*
* ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,比如抄送,转发等」)——
*
* 原来补全只服务**收件人一个字段**(`fetchSuggestions()` 硬编码读 `this.to`)。
* WebUI 的 `AddressInput` 是个**可复用组件**:同一个补全逻辑往六个地方
* 一挂就行(收件人 / 抄送 ×写信 / 回复 / 转发 / 日历事件)。
*
* 这里用 `suggestField` 记住"当前在给哪个字段补全",
* 把那一套拉取/过滤/应用逻辑变成**字段无关**的。
*
* ★ 一个 `@State` 就够,不需要真做成可复用子组件:ArkUI 的
* `@Builder` 参数是**值传递**,把 `onChange` 回调穿进子组件时 `this`
* 会丢(本仓已撞过,见 `@BuilderParam` 那段纪律)。
* 字段标记法没有这个问题,代价是每个新字段多两行。
*/
private scheduleSuggest(field: string): void {
this.suggestField = field;
if (this.suggestTimer >= 0) {
clearTimeout(this.suggestTimer);
}
@ -130,6 +157,28 @@ export struct ComposeView {
}, 120);
}
/** 当前正在补全的字段名(`'to'` | `'cc'`),以及它是否允许多个地址 */
private suggestField: string = 'to';
/** 该字段是否允许多个地址 —— 抄送类为 true(`allowMultiple`) */
private suggestMulti(): boolean {
return this.suggestField === 'cc';
}
/** 读当前字段的整串值 */
private suggestValue(): string {
return this.suggestField === 'cc' ? this.cc : this.to;
}
/** 写回当前字段(多地址时拼在 `head` 之后,与 WebUI `apply` 同形) */
private writeSuggestValue(v: string): void {
if (this.suggestField === 'cc') {
this.cc = v;
} else {
this.to = v;
}
}
private async fetchSuggestions(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
@ -139,7 +188,13 @@ export struct ComposeView {
if (api === null) {
return;
}
const parts: AddressParts = parseParts(this.to);
/*
* ★ 多地址字段先切段、只拿**最后一段**去解析。
* 不切的话,`a@x, b@y` 的 `indexOf('@')` 会取到第一条的 `@`,
* 整串被当成一条地址 ⇒ 候选全是错的。
*/
const editing: string = splitEditing(this.suggestValue(), this.suggestMulti()).editing;
const parts: AddressParts = parseParts(editing);
const q: SuggestQuery = queryFor(parts);
try {
const res: AddressSuggestionResponse = await api.suggestAddress(q.name, q.path);
@ -200,9 +255,19 @@ export struct ComposeView {
/** 选中一个候选 —— 拼回地址;name/path 段选完**仍停在补全态**(继续下一段) */
private applySuggestion(choice: string): void {
const parts: AddressParts = parseParts(this.to);
const full: string = this.suggestValue();
const multi: boolean = this.suggestMulti();
const split: EditingSplit = splitEditing(full, multi);
const parts: AddressParts = parseParts(split.editing);
const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name');
this.to = mergeCandidate(parts, kind, choice);
const merged: string = mergeCandidate(parts, kind, choice);
/*
* 多地址:前缀(含分隔符)原样保留,只换最后一段 ——
* 与 WebUI `AddressInput.tsx:125` 的
* `onChange(allowMultiple ? head + (head ? ' ' : '') + next : next)`
* 同一个拼法(`head` 自己尾上已经带了逗号,再加一个空格)。
*/
this.writeSuggestValue(multi && split.head.length > 0 ? split.head + ' ' + merged : merged);
if (kind === 'session') {
this.suggestOpen = false;
this.suggestItems = [];
@ -419,6 +484,12 @@ export struct ComposeView {
* (`pi@root.new ` 与 `pi@root.new` 是两条不同地址)。
*/
request.to = this.to.trim();
/*
* ★★ 2026-09-21 新增:抄送也要 trim。
* 服务端 `SendMailRequest.CC` 一直是存在的,只是鸿蒙从来没传过 ——
* 不加这句,用户填了抄送也发不出去(静默丢字段)。
*/
request.cc = this.cc.trim();
request.subject = this.subject.trim();
request.body = this.body;
if (this.replyTo.length > 0) {
@ -591,6 +662,36 @@ export struct ComposeView {
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
/*
* ★★ 2026-09-21 新增:**抄送**字段(用户:「webui 存在好几个自动填充位置,
* 比如抄送,转发等」)。
*
* 原来鸿蒙写信页**根本没有抄送框**,而:
* · WebUI 有(`ComposePage.tsx:277`,且带完整 `AddressInput` 补全);
* · 服务端 `SendMailRequest` 一直有 `cc` 字段(`Models.ets`);
* · 转发条里的抄送我们**反而做了**(`MailDetailPage.ets:1427`)。
* ⇒ 写信页缺这一项是单点遗漏,不是设计选择。
*
* 提示文案与 WebUI 逐字一致:`多个地址用逗号分隔`。
* 补全走同一个菜单(`suggestField='cc'` ⇒ 多地址模式)。
*/
Divider().color(Theme.border)
Row() {
Text('抄送').fontSize(14).fontColor(Theme.textSubtleFor()).width(60)
TextInput({ placeholder: '多个地址用逗号分隔', text: this.cc })
.layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent)
.onChange((v: string) => {
this.cc = v;
this.scheduleSuggest('cc');
})
.onKeyEvent((e: KeyEvent) => { this.onToKey(e); })
.onBlur(() => { this.suggestOpen = false; })
}
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
Divider().color(Theme.border)
/*
* 候选列表 —— 贴在收件人行**下方**(WebUI `AddressInput` 的绝对定位菜单)。
*

View File

@ -1270,6 +1270,54 @@ struct SentTab {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
/*
* ★★ 2026-09-21 修(真 bug,用户:「发件箱存在严重问题」)——
*
* `SentRow` **一个修饰符都没挂**。而同文件里所有别的列表行都挂着:
* `MailRow` L898/902 CompositeModifier / GlassCardModifier
* `GroupHeader` L996 GlassCardModifier
* `PermissionTab` L1373/1726 …
*
* 后果**不是"少一层好看",是读不出来**。设备实测(宽屏 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**
* 三行都远低于 WCAG AA(4.5:1)。
*
* ── 为什么"少个修饰符"会变成"字看不见" ──
* 没有卡底 ⇒ 行底就是**壁纸自己**。而这个壁纸是用户头像图(浅色、
* 大面积近似白)⇒ `Theme.textPrimary`(浅色下是深字)本该正常,
* 但实测拿到的是**浅灰字**:因为行里那几行用的是 `textPrimary`/
* `textMuted`/`textSubtle`,而**壁纸本身**在那些位置恰好是浅灰
* (`241,242,244`)—— 字色与底色**同向**,就掉到 1.3:1。
* 对齐 WebUI:邮件列表的每一行都是 `.glass-card`
* (`MailList.tsx:280` 收件箱 / WebUI 发件箱同一件),
* **玻璃卡的作用正是把"壁纸不可预知"变成"基材恒为白"** ——
* 没有这层,文字就得跟用户的壁纸赌运气。
*
* ── 为什么不只补一句 `backgroundColor` ──
* 收件箱那条路径(上面那段长注释)已经踩过同一个坑:
* 手写实心色 ⇒ 壁纸透不出来 ⇒ 一行里"单出一张不透明的"。
* 所以这里走**同一件基础件**,不手写颜色。
*
* ── 选中态 ──
* WebUI 发件箱的行也能被选中(`MailList.tsx:279` 的 `active`),
* 而本页 `currentMailId` 就是"正在读的那一封"。用与 `MailRow`
* **同一个三目**(选中 > 未读 > 普通)—— 发件箱没有未读概念,
* 所以只有两档。
*/
.attributeModifier(this.currentMailId === mail.mail_id
? CompositeModifier.of([
GlassCardModifier.of(this.bgActive, false),
TintModifier.of(Theme.accentSoftFor(this.isDarkNow))
])
: GlassCardModifier.of(this.bgActive))
.border({
width: 1,
color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border
})
.borderRadius(Theme.radiusCard)
.clip(true)
}
@Builder
@ -1296,8 +1344,22 @@ struct SentTab {
.width('100%').height(60)
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
.backgroundColor(Theme.surfaceMuted)
/*
* ★★ 2026-09-21 修(真 bug,用户:「发件箱存在严重问题」)——
* 会话头原来铺的是 `Theme.surfaceMuted`
* (`$r('sys.color.ohos_id_color_sub_background')`,**不透明**)。
*
* 与收件箱 `GroupHeader` 是**同一个错误的两个副本**:
* 收件箱那个已经改成 `GlassCardModifier`(L996),发件箱这个漏了。
*
* 不透明底的代价:壁纸开着时每一条组头都是一块**实心灰板**,
* 与本仓反复强调的「玻璃 = 白 + alpha、**不是**不透明材质」正好相反。
*
* ⇒ 与 `GroupHeader` 同一件基础件(不手写颜色)。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.margin({ bottom: 6 })
.clip(true)
.attributeModifier(PressEffectModifier.of())
@ -1366,21 +1428,20 @@ struct SentTab {
if (!isFlatGroup(g) && this.isExpanded(g.key)) {
ForEach(g.mails, (m: MailLike) => {
ListItem() {
/*
* ★★ 2026-09-21 修:这里原本再包一层
* `.attributeModifier(GlassCardModifier.of(this.bgActive))`
* —— 而 `SentRow` 自己也挂了一份(刚补的)。
* 两层玻璃卡叠加会让 `backgroundEffect` 走两次,
* 且外层的 `border`/`borderRadius` 与内层重复。
* 收件箱那边(`MailRow`)就是"卡在行自己身上、
* 调用点只给缩进和间距",这里改成同一形状。
*/
Column() {
this.SentRow(m)
}
.width('100%')
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
/* 与 `MailRow` 同一口径:选中(正在读的那一封)上品牌边。
WebUI 组内条目也是这么标的(`MailList.tsx:229`
把 `currentMailID` 传进 `MailItem` 的 `active`)。 */
.border({
width: 1,
color: this.currentMailId === m.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border
})
.margin({ bottom: 6 })
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.openMail(m); })
}
.width('100%')