跨端: 发件箱接上 MailStore(A 的剩余)+ 修 3 处把原始 ISO 印到界面上的时间

══ ① A 的剩余:发件箱走了 store(顺带撞出 store 里的一个真 bug)

`SentTab.load()` 原来把收件箱那 107 行**抄了一遍**(注释里还写着"同收件箱"
—— 抄的时候就知道是重复)。现在改成 3 行调用 `MailStore.loadSent`。

★ 接上之后**立刻炸出一个真 bug**:`MailStore.loadSent` 里写的是
      snap.groups = [];
  而发件箱的列表**就是按会话分组渲染的**(`ForEach(this.groups, …)`)
  ⇒ 接上 store 之后发件箱会**一片空白**,而"接口有返回、loaded > 0"
  会让症状看起来像"数据没到"。

  为什么会写成空数组:它是照收件箱那半抄的,而收件箱的 `groups` 来自
  `splitByPermission` **筛完之后**的 `inboxMails`(授权邮件要挑出去单独成栏)。
  抄的时候只看到"要赋值",没注意发件箱没有那一步筛选 ⇒ 把 `[]` 抄了过来。
  发件箱也**不能**照搬那个筛选:那是为"授权待办栏"服务的,发件箱不显示那栏。

★ 这段经历本身值得记:**死代码不会自己暴露错误**。
  `loadSent` 写完到今天我接上它之前,那行 `groups = []` 一直没人执行过。
  "写完就搁着"和"接上一个调用点"是两件事 —— 后者才算验过。

══ ② 3 处把原始 ISO 直接印到界面上(看到屏幕才发现的)

发件箱那张卡的时间格显示的是
      2026-09-19T02:55:33.10099Z
(23 个字符,把「致 homeagent」那行挤到换行)。

WebUI **三种卡片全部格式化**、一个没漏:`MailList.tsx:151/262`、
`PermissionList.tsx:143/251`,全是 `toLocaleString('zh-CN', {month,day,hour,minute})`
⇒ `MM/DD HH:mm`。鸿蒙的 `compactMailTime` 就是那个实现,收件箱一直在用 ——
所以这不是"要不要格式化"的分歧,是**三处漏调**(收件箱 / 发件箱 / 授权栏)。

══ ③ 补判据(形状而不是实例)

新判据:`Text(<x>.created_at)` 这个**形状**不许出现(`Text` 只负责画,
不做格式化)。为什么枚举形状而不是列举调用点:漏的三处分布在**三个不同 struct**,
按名字枚举一定会再漏第四个。
自检 + 变异验证都做了(把 SentRow 那处改回裸字段 → 判据转红;还原 → 绿)。

设备验证:发件箱正常渲染(会话组 + 条目,非空白),
时间显示 `09/19 10:55` 而非 ISO。
This commit is contained in:
2026-09-20 18:43:35 +08:00
parent 99a7bbccf7
commit f1db99ef41
3 changed files with 99 additions and 37 deletions

View File

@ -305,3 +305,44 @@ test('★ 尺寸/底色不许直接链在 `AmIcon` 上(内层容器固定 icon
assert.equal(probe(bad), 1, '探测器要认得出错误写法');
assert.equal(probe(good), 0, '探测器不许误伤套了 Stack 的正确写法');
});
test('★ 时间戳不许把原始 ISO 直接印到界面上(`Text(mail.created_at)`)', () => {
/*
* ★★ 2026-09-20 加。这条是**先看到屏幕上印着原始 ISO** 才回来补的判据:
*
* 发件箱那张卡的时间那一格显示的是
* 2026-09-19T02:55:33.10099Z
* (23 个字符,把「致 homeagent」那一行挤到换行)。
*
* WebUI **三种卡片全部格式化**,一个都没漏:
* · `MailList.tsx:151`(会话组头)与 `:262`(组内条目)
* · `PermissionList.tsx:143`(授权组头)与 `:251`(授权条目)
* 全部是 `toLocaleString('zh-CN', { month:'2-digit', day:'2-digit',
* hour:'2-digit', minute:'2-digit' })` ⇒ `MM/DD HH:mm`。
*
* 鸿蒙的 `compactMailTime` 就是那个格式的实现,收件箱一直在用 ——
* 所以这不是"要不要格式化"的分歧,是**三处漏调**。
*
* ★ 判据形状为什么是"不许出现 `Text(<x>.created_at)`"而不是列举调用点:
* 漏的那三处分布在三个不同的 struct(收件箱/发件箱/授权),
* 按"名字"枚举一定会再漏第四个。而"把 ISO 交给 Text"这个**形状**
* 本身就是错的(`Text` 只负责画,不做格式化)——
* 枚举形状才挡得住漂移。
*
* ★ 边界:`*For()` 那种"取值函数"不算,这里只管**直接**把字段喂给 Text 的。
* 允许 `created_at` 作为**参数**传给格式化函数(那正是正确写法)。
*/
const src = code(join(ETS_ROOT, 'pages/MainPage.ets'));
// Text( 里直接出现 .created_at / .createdAt,且中间没有函数调用
const bad = [...src.matchAll(/Text\(\s*[\w.]*\.(created_at|createdAt)\s*\)/g)]
.map(m => m[0]);
assert.deepEqual(bad, [],
`这些地方把原始 ISO 直接交给 Text 了(屏上会印出 23 个字符的时间戳):\n ${bad.join('\n ')}\n` +
'改用 compactMailTime(...)(收件箱行一直是这么写的)');
// 自检:探测器要认得错误形状,且不误伤正确写法
const probe = (t) => [...t.matchAll(/Text\(\s*[\w.]*\.(created_at|createdAt)\s*\)/g)].length;
assert.equal(probe("Text(mail.created_at)"), 1, '探测器要认得出裸字段');
assert.equal(probe("Text(compactMailTime(mail.created_at))"), 0, '不许误伤传参给格式化函数的写法');
assert.equal(probe("Text(mail.createdAt)"), 1, '驼峰写法同样要抓');
});

View File

@ -273,7 +273,24 @@ export class MailStore {
}
snap.mails = merged;
snap.groups = [];
/*
* ★★ 2026-09-20 修:这里原来写的是 `snap.groups = []`。
*
* 那是**错的**,而且错得很隐蔽:发件箱的列表**就是按会话分组渲染的**
* (`SentTab` 里 `ForEach(this.groups, …)`),`groups` 恒为空
* ⇒ 接上 store 之后发件箱会变成一片空白,而"接口有返回、loaded > 0"
* 会让错误看起来像"数据没到"。
*
* 为什么会写成空数组:这个方法是照收件箱那半抄的,而收件箱的
* `groups` 来自 `splitByPermission` **筛完之后**的 `inboxMails`
* (授权邮件要挑出去单独成栏)。我抄的时候只看到"要赋值",
* 没注意发件箱没有那一步筛选 ⇒ 直接把 `[]` 抄了过来。
*
* 发件箱也**不能**照搬 `splitByPermission`:那一筛是为"授权待办栏"服务的,
* 发件箱不显示那个栏位。所以这里直接对全部 merged 分组 ——
* 与 WebUI `mailStore.fetchSent` 同口径(那边也是直接 set 全量)。
*/
snap.groups = groupMailsBySession(merged);
snap.loaded = merged.length;
snap.unread = 0;
snap.notice = '';

View File

@ -915,45 +915,38 @@ struct SentTab {
this.load();
}
/**
* 取数**交给 `MailStore`**(与收件箱同一份实现)。
*
* ★★ 2026-09-20 改。这一段原来是**抄了一遍**多账号聚合:
* 遍历账号 → 每个建 `ApiClient` → `sent()` → 补 `source_account_*` 与
* `cc_count` → 最后 `groupMailsBySession`。与收件箱里那 107 行是同源复制品
* (注释里还写着"同收件箱"—— 说明抄的时候就知道是重复)。
*
* 用户要做的 **A** 就是这件事:把散在各处的取数收到 store。
* 收件箱那半已经收了(`InboxTab.loadData` 现在 3 行),发件箱这片是剩下的。
*
* ★ 顺带修掉 `MailStore.loadSent` 里一个真 bug(`snap.groups = []`)——
* 见那个方法里的注释:接上 store 之前它是死代码,接上之后
* 发件箱会**一片空白**。这就是"写完没收口"的代价,也是为什么要
* 先接一个调用点再算完 —— 死代码不会自己暴露错误。
*/
async load(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.loading = true;
this.error = '';
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
const all: MailSummary[] = [];
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
try {
const c: ApiClient = new ApiClient(ctx);
c.setBase(acct.server);
c.setToken(acct.token);
const resp: SentResponse = await new MailApi(c).sent();
for (let j = 0; j < resp.mails.length; j++) {
const mail: MailSummary = resp.mails[j];
mail.source_account_id = acct.id;
mail.source_account_name = acct.displayName;
/* 同收件箱:派生行上的「抄送 N」(两处都要写 —— 理由见收件箱那处) */
mail.cc_count = mail.cc_list.length;
all.push(mail);
}
} catch (e) {
// 单个账号拉不到不该让整栏空白(与收件箱同口径)
}
}
this.groups = groupMailsBySession(all);
this.loaded = all.length;
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
const store: MailStore = MailStore.getInstance();
await store.loadSent(ctx, 'all');
this.applyStoreSnapshot(store.snapshot);
}
/** 把 store 快照搬到本组件的 `@State`(`InboxTab` 里同名同形) */
private applyStoreSnapshot(snap: MailSnapshot): void {
this.groups = snap.groups;
this.loaded = snap.loaded;
this.loading = snap.loading;
this.error = snap.error;
}
isExpanded(key: string): boolean {
@ -989,7 +982,17 @@ struct SentTab {
.fontSize(13).fontWeight(FontWeight.Medium).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(mail.created_at).fontSize(10).fontColor(Theme.textSubtleFor())
/*
* ★★ 2026-09-20 修:这里原来直接渲染 `mail.created_at` ——
* 屏上印的是原始 ISO,实测:`2026-09-19T02:55:33.10099Z`
* (23 个字符,把「致 homeagent」那一行挤到换行)。
*
* WebUI 三种卡片**全部都格式化**(`MailList.tsx:262/151`、
* `PermissionList.tsx:251/143` 都是 `toLocaleString('zh-CN', {month,day,hour,minute})`
* ⇒ `MM/DD HH:mm`)。鸿蒙的 `compactMailTime` 就是那个格式的实现,
* 收件箱行(L813)一直在用,这三处漏了。
*/
Text(compactMailTime(mail.created_at)).fontSize(10).fontColor(Theme.textSubtleFor())
}
.width('100%')
@ -1289,7 +1292,8 @@ struct PermissionTab {
.margin({ top: 4 })
}
Text(req.created_at).fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
/* 同 ①:WebUI `PermissionList.tsx:251` 也格式化,不印 ISO */
Text(compactMailTime(req.created_at)).fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
if (this.noteFor === req.mail_id) {
TextInput({ placeholder: '备注(会随决策一起送给 Agent)', text: this.noteText })