From 25e7d8f3bf2d9cbc864c7363a61e54641eb18b42 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 20 Sep 2026 21:14:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E8=A1=A5=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E5=8C=BA=EF=BC=88=E4=B8=A4=E7=AB=AF=E4=B8=80=E7=9B=B4?= =?UTF-8?q?=E9=83=BD=E6=9C=89=E8=BF=99=E4=B8=AA=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=88=91=E4=B8=8A=E6=AC=A1=E8=AF=AF=E5=88=A4=E6=88=90"?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81"=EF=BC=89+=20=E4=BF=AE=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E7=9C=9F=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档) 那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有 `has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0` 恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。 **这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**, 看到 key 不在,就断言服务端从不返回它。事实: · 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着 理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」 · `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key** 实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。 ★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。 判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。 这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛) 是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。 ══ ② 补附件区(鸿蒙原来完全没有) · `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖, 逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。 · `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get`:后者假定 JSON, 取二进制会炸;壁纸当初踩过)。 · `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。 · `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载), 位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。 · 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。 ══ ③ 顺带撞出并修掉两个**真 bug** **bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` —— 而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是 `undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑, 我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。** ⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。 **bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` —— 而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`), `ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。 它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`, 一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。 ⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**: 让误用变成**编译错**,而不是运行时白屏。 同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)—— 空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文, 所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。 ══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例) 从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名 (不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。 ★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报: `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带), 鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。 ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。 已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。 变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。 ══ ⑤ 数据路径已实测(模拟器) 临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52, 默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现), 加临时 hilog 后拿到: AttProbe: mail=531a1629-… attach=1 AttProbe: mail=b68cbbe8-… attach=1 正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。 ══ ⚠️ 本轮**未能**完成设备端视觉验收 模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时), 导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。 主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘) 尚未在设备上看过 —— 待模拟器恢复后补。 --- client/electron/test/harmony-arkts.test.mjs | 152 +++++++++++++++++- client/electron/test/harmony-logic.test.mjs | 47 ++++++ client/electron/test/run-all.mjs | 4 +- .../entry/src/main/ets/api/MailApi.ets | 19 +++ .../entry/src/main/ets/common/IcsFile.ets | 45 ++++++ .../entry/src/main/ets/common/MailStore.ets | 26 +++ .../entry/src/main/ets/model/Attachment.ts | 58 +++++++ .../entry/src/main/ets/model/MailGrouping.ts | 35 ++++ .../entry/src/main/ets/model/Models.ets | 40 ++++- .../entry/src/main/ets/pages/InboxPage.ets | 2 +- .../src/main/ets/pages/MailDetailPage.ets | 100 +++++++++++- .../entry/src/main/ets/pages/MainPage.ets | 73 +++++++-- docs/DEBTS.json | 6 +- 13 files changed, 583 insertions(+), 24 deletions(-) create mode 100644 client/harmony/entry/src/main/ets/model/Attachment.ts diff --git a/client/electron/test/harmony-arkts.test.mjs b/client/electron/test/harmony-arkts.test.mjs index 857a9f3..1ccdbd5 100644 --- a/client/electron/test/harmony-arkts.test.mjs +++ b/client/electron/test/harmony-arkts.test.mjs @@ -26,7 +26,7 @@ import assert from 'node:assert/strict'; import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { code } from './lib/read.mjs'; +import { code, prose } from './lib/read.mjs'; // 与其它鸿蒙判据同口径(见 `harmony-admin.test.mjs` 的文件头) /* @@ -346,3 +346,153 @@ test('★ 时间戳不许把原始 ISO 直接印到界面上(`Text(mail.create assert.equal(probe("Text(compactMailTime(mail.created_at))"), 0, '不许误伤传参给格式化函数的写法'); assert.equal(probe("Text(mail.createdAt)"), 1, '驼峰写法同样要抓'); }); + +test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读它时必须有 `??` 兜底', () => { + /* + * ★★ 2026-09-20 加。这条是**同一天踩了两次**之后加的判据。 + * + * 第一次(真崩溃,白屏 + 应用重启): + * `session_workspace` 带 `omitempty` ⇒ 服务端**不输出这个 key** + * ⇒ `JSON.parse as T` 之后是 `undefined` + * ⇒ `participantAddress` 里 `workspace.trim()` 抛 TypeError。 + * 第二次(差一点就是 94/96 必崩): + * 给列表行加"附件数"时写 `mail.attachments.length` —— + * 而 `Attachments` 也带 `omitempty`,实测 96 封里**只有 2 封**带这个 key。 + * + * ★ 为什么"记住别这么写"不管用、要写成判据: + * 两次之间我**刚**在 `Models.ets` 头注释里写了一大段讲这个坑, + * 然后加新字段时照踩 —— 记忆不跨"写下一行代码"这个边界。判据能跨。 + * + * ── 判据的关键:**不能按字段名一刀切** ── + * + * 第一版我就是按"名字在 omitempty 名单里"扫的,结果报了 6 处, + * 其中 4 处是**误报**:`session_alias` 在服务端有**两个**声明 —— + * · `models.Mail.SessionAlias` `json:"session_alias,omitempty"` ★会缺失 + * · `repo.Contact.SessionAlias` `json:"session_alias"` 恒有 + * 而鸿蒙那 4 处读的全是 `Contact`(联系人卡片)⇒ 按名字判根本分不出来。 + * + * 正确判法:只扫**"只以 omitempty 形式出现过"的字段名** —— + * 即全仓所有 struct 里,该 json 名**从来没有**不带 omitempty 的声明。 + * 那样 `session_alias`/`display_name`/`title`/`mail_count`/`key_token` + * 这几个"两种形式都有"的自动被排除(名单从服务端**算出来**,不手抄)。 + */ + const GO_FILES = []; + const walkGo = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walkGo(full); continue; } + if (e.name.endsWith('.go')) GO_FILES.push(full); + } + }; + walkGo(join(ROOT, 'server/internal')); + + const omitOnly = new Map(); // json 名 → 类型集合 + const alsoPlain = new Set(); // 同时存在"不带 omitempty"声明的名字 + for (const f of GO_FILES) { + /* + * 读**原文**(`prose`)而不是剥注释后的 `code()`: + * struct tag 里的 `json:"...,omitempty"` 虽然不是注释,但这几行 Go 源码 + * 附近有大量解释性注释,而**判据要的是字段声明的字面形状** —— + * 走 `prose` 语义最直白("我在读这一段字面文本"),也符合本仓 + * 「不许裸用 readFileSync」那条(改用它就要说清是 code 还是 prose)。 + */ + const goSrc = prose(f); + for (const m of goSrc.matchAll(/^\s+(\w+)\s+(\[\]\w+|\*\w+|\w+)\s+`json:"([a-z_]+)(,omitempty)?"/gm)) { + const jsonName = m[3]; + if (m[4]) { + if (!omitOnly.has(jsonName)) omitOnly.set(jsonName, new Set()); + omitOnly.get(jsonName).add(m[2]); + } else { + alsoPlain.add(jsonName); + } + } + } + /* 只留下"从未不带 omitempty 出现过"的名字 —— 那些读起来一定要有兜底 */ + const risky = [...omitOnly.entries()] + .filter(([name, types]) => { + if (alsoPlain.has(name)) return false; + /* 只有"成员调用会抛"的类型才算:字符串与切片。 + 数值/bool 缺失时比较不抛(`undefined > 0` 是 false),不在此列。 */ + return [...types].some(t => t === 'string' || t.startsWith('[]')); + }) + .map(([name]) => name); + + assert.ok(risky.length >= 5, `应当算出一批"只在 omitempty 里出现"的字段,实际 ${risky.length}`); + assert.ok(risky.includes('attachments'), '`attachments` 应当在名单里(这是第二次踩的那个)'); + assert.ok(risky.includes('session_workspace'), '`session_workspace` 应当在名单里(这是崩溃那个)'); + assert.ok(!risky.includes('session_alias'), + '`session_alias` 不该在名单里 —— 它在 `repo.Contact` 上是不带 omitempty 的(恒有)'); + + const hits = []; + const walkEts = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walkEts(full); continue; } + if (!/\.(ets|ts)$/.test(e.name)) continue; + const src = code(full); + for (const f of risky) { + const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim|slice|substring|toUpperCase|toLowerCase|replace|split|startsWith|endsWith|indexOf)\\b`, 'g'); + for (const mm of src.matchAll(re)) { + const lineStart = src.lastIndexOf('\n', mm.index) + 1; + const lineEnd = src.indexOf('\n', mm.index); + const line = src.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); + /* 同一行有兜底就不算(`??` 或 `||`) */ + if (/\?\?|\|\|/.test(line)) continue; + hits.push(`${e.name}: ${line.trim().slice(0, 96)}`); + } + } + } + }; + for (const sub of ['pages', 'common', 'model']) walkEts(join(ETS_ROOT, sub)); + /* + * ── 已逐个核实过的**豁免**(不是"看着像就放过")── + * + * 判据按**跨语言字段名**匹配,所以同一个名字在不同接口上含义不同时会有假阳性。 + * 下面每条都写了"为什么它不是那个会缺失的字段",改这里必须先重新取证: + * + * ① `attachments` 在 `this.` 上(`MailDetailPage`): + * `this.attachments` 是 `@State attachments: AttachmentInfo[] = []` —— + * 本地状态,初值是**空数组**、且 `MailDetail.normalize()` 在赋值前 + * 已经把服务端的缺失兜成 `[]`(`Models.ets:202`)。不是服务端裸值。 + * + * ② `attachments` 在 `mail.` 上(`InboxPage`): + * 那个文件是**死代码** —— 不在 `resources/base/profile/main_pages.json` + * 的页面表里、全仓没有任何 `pushUrl('pages/InboxPage')`。 + * (它靠"编译器仍会编译"活着,所以判据还是能扫到它。) + * + * ③ `reason`(`MailDetailPage` 的 `this.renameProposal.reason`): + * 这个不是 `models.Mail.RenameReason`,是 + * `GET /sessions/{id}/rename-proposal` 的**那条** proposal。 + * 服务端(`sessions.go:236`)返回的是 `map[string]string{"alias":…, "reason":…}` + * —— **Go 的 map 里的空串照样输出**(实测: + * `json.Marshal(map[string]string{"reason":""})` → `{"reason":""}`, + * 而 struct 带 omitempty 才会省略)⇒ 这个 key 恒在。 + * + * ④ `last_login`(`SettingsPage`): + * 那里写的是 `this.profile.last_login !== undefined && …` —— + * **已经显式判过 undefined**(只是用了 !== undefined 而不是 ??, + * 所以上面那个正则没认出来)。等价且更明确。 + */ + const EXEMPT = [ + /^MailDetailPage\.ets: if \(this\.attachments\.length/, + /^MailDetailPage\.ets: Text\('附件 ' \+ this\.attachments\.length\)/, + /^InboxPage\.ets: if \(mail\.attachments\.length/, + /^MailDetailPage\.ets: if \(this\.renameProposal\.reason\.length/, + /^SettingsPage\.ets: \(this\.profile\.last_login !== undefined/ + ]; + const real = hits.filter(h => !EXEMPT.some(re => re.test(h))); + assert.deepEqual(real, [], + `这些地方直接读了服务端带 omitempty 的字段(缺失时是 undefined,会抛):\n ${real.join('\n ')}\n` + + '加 `?? []` / `?? \'\'` 兜底(见 Models.ets 顶部那段教训)'); + + // 自检:探测器要认得错误形状,也要认得兜底后的正确形状 + const probe = (t, f) => { + const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim)\\b`, 'g'); + return [...t.matchAll(re)].filter(mm => { + const line = t.slice(t.lastIndexOf('\n', mm.index) + 1, t.indexOf('\n', mm.index)); + return !/\?\?|\|\|/.test(line); + }).length; + }; + assert.equal(probe('const n = mail.attachments.length;', 'attachments'), 1, '要抓得住裸读'); + assert.equal(probe('const n = (mail.attachments ?? []).length;', 'attachments'), 0, '不许误伤兜底写法'); +}); diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 13fcbe2..f66453a 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -33,6 +33,12 @@ const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts'); /** 被测对象:鸿蒙客户端真正引用的那份逻辑(不是复制品) */ const H = await import(pathToFileURL(MODULE_TS).href); +/* + * 附件格式化(`model/Attachment.ts`,同样是纯逻辑、无 SDK 依赖)。 + * ★ 直接执行鸿蒙那一份代码 —— 与 `H`/`C` 同一手法,验的是**行为**。 + */ +const ATT_TS = join(HARMONY_ETS, 'model/Attachment.ts'); +const ATT = await import(pathToFileURL(ATT_TS).href); const C = await import(pathToFileURL(COMM_TS).href); const page = code(join(HARMONY_ETS, 'pages/MainPage.ets')); @@ -871,3 +877,44 @@ test('★ 详情页折叠头部的展开区要有足够高度(14px 的横条 assert.ok(Number(h[1]) >= 32, `★ 展开行的高度应 ≥32vp(实测 14px 太薄、点不中;修成 36 后实测可点区 43px)。实际 ${h[1]}`); }); + +test('★ 附件区:字节数格式化与 WebUI 逐字一致(三档 + 保留一位小数)', () => { + /* + * ★★ 2026-09-20 加。鸿蒙原来**完全没有附件区** —— + * `MailDetail.attachments` 一直在模型里、服务端也在返回,界面一个都没画。 + * WebUI `MailView.tsx:148/692` 两处都调 `AttachmentList`。 + * + * 这里验的是格式化:期望值照 WebUI `api/client.ts:390 formatSize` **逐字**写, + * 不是"大概像就行" —— 两端同一个文件名旁边显示 `512 B` 与 `512.0 B` + * 会显得是两个不同的软件。 + * + * ★ 边界各取一个,而不是只测"正常值": + * · `0` —— 0 字节文件真实存在(空文件),不能显示成空串 + * · `1023` —— 最后一个 B 档(`< 1024`,不加小数) + * · `1024` —— 第一个 KB 档(**跨越点**,最容易差一位) + * · `1048575`—— 最后一个 KB 档 + * · `1048576`—— 第一个 MB 档(第二个跨越点) + */ + assert.equal(ATT.formatSize(0), '0 B', '0 字节要显示成 "0 B",不能是空串'); + assert.equal(ATT.formatSize(512), '512 B', 'B 档是整数、不加小数(与 WebUI 同)'); + assert.equal(ATT.formatSize(1023), '1023 B', '1023 是最后一个 B 档'); + assert.equal(ATT.formatSize(1024), '1.0 KB', '1024 跨到 KB 档,且保留一位小数'); + assert.equal(ATT.formatSize(1536), '1.5 KB', 'KB 档保留一位小数'); + assert.equal(ATT.formatSize(1048575), '1024.0 KB', '最后一个 KB 档(WebUI 也是 1024.0 KB,不提前进位)'); + assert.equal(ATT.formatSize(1048576), '1.0 MB', '1048576 跨到 MB 档'); + assert.equal(ATT.formatSize(3 * 1024 * 1024), '3.0 MB', 'MB 档保留一位小数'); +}); + +test('★ 附件区:空文件名要显式占位,不能留一条看起来坏掉的空行', () => { + /* + * 服务端理论上不会给空文件名,但真出现时界面上会是一条**看不出来是错的** + * 空行(比显式占位更难诊断)。占位符不是文案偏好,是**可诊断性**。 + */ + const ok = ATT.attachmentLabel('report.pdf', 2048); + assert.equal(ok.filename, 'report.pdf'); + assert.equal(ok.size, '2.0 KB'); + + const empty = ATT.attachmentLabel('', 100); + assert.notEqual(empty.filename, '', '空文件名要有占位符,不能是空串'); + assert.equal(empty.size, '100 B', '空文件名不影响大小显示'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 4cf6681..22f0635 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -78,7 +78,7 @@ const SUITE = [ // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], - ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 32], + ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 34], ['test/harmony-system-api.test.mjs', [], 5], // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27], @@ -121,7 +121,7 @@ const SUITE = [ ['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], // ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的: // 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。 - ['test/harmony-arkts.test.mjs', [], 5], + ['test/harmony-arkts.test.mjs', [], 7], ['test/harmony-contacts.test.mjs', [], 5], // ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。 // diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index 6fb7b57..bdcdf7b 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -338,6 +338,25 @@ export class MailApi { await this.client.post('/contacts/archive', payload); } + /** + * 下载附件的原始字节(`GET /me/attachments/{id}`,服务端 `MeDownloadAttachment`)。 + * + * ★★ 2026-09-20 加。鸿蒙原来**只实现了上传、没实现下载** —— + * 而详情页连附件区都没有(见 `model/Attachment.ts` 的头注释): + * 收到带附件的邮件,在鸿蒙上既看不到、也拿不到。 + * WebUI `Attachments.tsx:21` 是可点的下载链接。 + * + * ★ 走 `getBytes` 而不是 `get`:后者假定响应是 JSON + * (`JSON.parse(response.result as string)`),拿它取二进制会当场炸 —— + * `getBytes` 的注释里写着这条(壁纸当初踩过同一个坑)。 + * + * ★ 认证走 header(Bearer),不用 `?token=`:后者会把密钥写进服务端日志 + * 与访问历史(服务端注释里明确不做这件事,壁纸那处也守的同一条)。 + */ + async downloadAttachment(attachmentId: string): Promise { + return this.client.getBytes('/me/attachments/' + attachmentId); + } + /** 上传附件(multipart/form-data,字段名 file)→ attachment_id */ async uploadAttachment(filePath: string, fileName: string): Promise { const resp = await this.client.uploadFile('/me/attachments', filePath, fileName); diff --git a/client/harmony/entry/src/main/ets/common/IcsFile.ets b/client/harmony/entry/src/main/ets/common/IcsFile.ets index 8a87f3a..1662557 100644 --- a/client/harmony/entry/src/main/ets/common/IcsFile.ets +++ b/client/harmony/entry/src/main/ets/common/IcsFile.ets @@ -120,3 +120,48 @@ export async function saveIcsText(ctx: common.Context, text: string, filename: s return out; } } + +/** + * 把**二进制**内容存到用户选定的位置(附件下载用)。 + * + * ★★ 2026-09-20 加。与 `saveIcsText` 是同一套流程(系统文件选择器 → + * 用户选路径 → 写文件 → 报回结果),只有**写入的数据类型**不同 + * (`ArrayBuffer` vs `string`)。所以没有另起一个文件、也没有把 + * `saveIcsText` 改成泛型:两者都是"选择器 + 写 + 结果对象"的短流程, + * 合并成一个带 `any` 参数的函数反而丢掉了类型(本仓禁 `any`)。 + * + * ★ 为什么不让调用点自己 `fileIo` 一遍:`DocumentViewPicker` 的 + * `save()` 会**在取消时返回空数组**(不是抛异常)—— 这条容易漏, + * 漏了就会把"用户点了取消"报成"写入失败"。放在这里只用守一次。 + */ +export async function saveBinaryFile( + ctx: common.Context, + data: ArrayBuffer, + filename: string +): Promise { + const out = new IcsSaveResult(); + try { + const options = new picker.DocumentSaveOptions(); + options.newFileNames = [filename]; + const docPicker = new picker.DocumentViewPicker(ctx); + const uris: string[] = await docPicker.save(options); + if (uris === undefined || uris.length === 0) { + return out; // 用户取消:`ok=false` 且**没有** message(不是失败) + } + const uri: string = uris[0]; + const file = fileIo.openSync(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); + try { + fileIo.writeSync(file.fd, data); + out.ok = true; + out.path = uri; + } finally { + fileIo.closeSync(file); + } + return out; + } catch (e) { + const be = e as BusinessError; + hilog.error(0x0001, 'IcsFile', 'saveBinary failed: %{public}s', JSON.stringify(be)); + out.message = be.message !== undefined && be.message.length > 0 ? be.message : '无法写入文件'; + return out; + } +} diff --git a/client/harmony/entry/src/main/ets/common/MailStore.ets b/client/harmony/entry/src/main/ets/common/MailStore.ets index 2020de5..e6a3964 100644 --- a/client/harmony/entry/src/main/ets/common/MailStore.ets +++ b/client/harmony/entry/src/main/ets/common/MailStore.ets @@ -165,6 +165,29 @@ export class MailStore { * (编译报 "incorrectly implements interface")。派生放在**填充处**。 */ mail.cc_count = mail.cc_list.length; + /* + * 附件数(列表行显示回形针 + 数字)。 + * + * ★★ 这里**必须**写成 `(mail.attachments ?? []).length`,不能直接 + * `mail.attachments.length` —— 后者会崩,而且是"94/96 必崩"。 + * + * 我第一版就是直接 `.length`。写完之后去核对服务端回包才发现: + * · `CCList` 的 tag **没有** `omitempty` ⇒ 96/96 都带 `cc_list` + * (所以上面那行一直是安全的); + * · `Attachments` 的 tag **有** `omitempty`(`models.go:186`) + * ⇒ 实测 96 封里只有 **2 封**带这个 key,其余 **94 封缺失**。 + * 而 `JSON.parse as T` 是裸转型,缺失字段是 `undefined` + * ⇒ `.length` 抛 `Cannot read property length of undefined`。 + * + * ★ 这与当天早些时候那个**白屏崩溃**是**同一个坑**(`session_workspace` + * 的 `omitempty` → `undefined` → `participantAddress` 抛)。 + * 那个坑我刚修过、还在 `Models.ets` 的头注释里写了一大段, + * 结果加这个新字段时**又踩了一次** —— 说明"记住别这么写"不管用, + * 要在**每个真正读这些字段的地方**都把 `?? []` 写出来。 + * (`MailDetail` 那条路有 `normalize()` 统一兜,列表这条没有, + * 所以这里就地兜。) + */ + mail.attach_count = (mail.attachments ?? []).length; mergedMails.push(mail); } unreadTotals.push(response.total); @@ -260,6 +283,9 @@ export class MailStore { mail.source_account_id = acct.id; mail.source_account_name = acct.displayName; mail.cc_count = mail.cc_list.length; + /* 附件数 —— 同收件箱那处,必须 `?? []`(`Attachments` 带 omitempty, + 94/96 的邮件缺这个 key)。完整理由见收件箱那一处的注释。 */ + mail.attach_count = (mail.attachments ?? []).length; merged.push(mail); } } catch (e) { diff --git a/client/harmony/entry/src/main/ets/model/Attachment.ts b/client/harmony/entry/src/main/ets/model/Attachment.ts new file mode 100644 index 0000000..496e789 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/Attachment.ts @@ -0,0 +1,58 @@ +/* + * 附件相关的**纯逻辑,无 UI / 无 SDK 依赖**。 + * + * 为什么单独成文件(与 `MailGrouping.ts` 同一条理由): + * 这些是**判据的对象**。写在 `build()` 里的话,判据只能断言"源码里出现了 + * 某个字符串"(看起来绿、实际什么都没验);放在这里判据可以跑**同一份代码** + * (`harmony-logic.test.mjs` 直接执行本文件),断言的是**行为**: + * `1023` 是不是 `1023 B`、`1024` 是不是 `1.0 KB`、`0` 会不会显示成 `0 B`。 + * + * ★★ 2026-09-20 新建。起因:鸿蒙的邮件详情**完全没有附件区** —— + * `MailDetail.attachments` 字段一直在模型里(`Models.ets:127`), + * 服务端也在返回,但界面一个都没画。 + * 而 WebUI `Attachments.tsx:9 AttachmentList` 是**必渲染**的一块 + * (`MailView.tsx:148/692` 两处都调)。附件是"这封信带了什么"的直接信息, + * 漏掉它意味着:收到带附件的邮件,在鸿蒙上完全看不出来。 + */ + +/** 人类可读的字节数 —— 逐字对齐 WebUI `api/client.ts:390 formatSize`。 */ +export function formatSize(n: number): string { + /* + * 三个档与 WebUI 逐字一致(含**保留一位小数**): + * < 1024 → `${n} B` (整数,不加小数) + * < 1024*1024 → `${(n/1024).toFixed(1)} KB` + * else → `${(n/1024/1024).toFixed(1)} MB` + * + * ★ 为什么连"B 档不保留小数"这种细节也要照抄: + * 两端的同一个文件名旁边显示 `512 B` 与 `512.0 B` 会显得是两个不同的软件。 + * 本仓纪律:同一件事说同一句话。 + */ + if (n < 1024) { + return n.toString() + ' B'; + } + if (n < 1024 * 1024) { + return (n / 1024).toFixed(1) + ' KB'; + } + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} + +/** + * 附件条目要显示的三段文本(纯函数 ⇒ 判据可直接验)。 + * + * 为什么不直接在 `build()` 里拼:拼法(尤其是"空文件名怎么办") + * 是要被验的**规则**,不是绘制细节。 + * + * `filename` 为空时给占位符:服务端理论上不会给空名,但真出现空名时 + * 界面上会是一条**看起来坏掉的空行**,比显式占位更难诊断。 + */ +export class AttachmentLabel { + filename: string = ''; + size: string = ''; +} + +export function attachmentLabel(filename: string, sizeBytes: number): AttachmentLabel { + const l = new AttachmentLabel(); + l.filename = filename.length > 0 ? filename : '(未命名附件)'; + l.size = formatSize(sizeBytes); + return l; +} diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts index d8eed0a..06766f1 100644 --- a/client/harmony/entry/src/main/ets/model/MailGrouping.ts +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -51,6 +51,41 @@ export interface MailLike { * 而列表行只需要个数。派生放到 `MailSummary` 的填充处(`cc_count = cc_list.length`)。 */ cc_count: number; + /** + * 附件数 —— WebUI 行上那个回形针 + 数字(`MailList.tsx:351`)。 + * + * ★★ 2026-09-20 补。**这里要更正我 2026-09-19 的一个错误结论。** + * + * 那天我审计后写进 `docs/DEBTS.json` 的说法是: + * 「实测 `GET /me/mail/inbox` 的回包里**既没有 `attachments` 也没有 + * `has_attachments`** ⇒ `mail.attachments?.length ?? 0` 在列表里**恒为 0**, + * WebUI 上那个 📎 是**死代码**」 + * 并据此在鸿蒙侧**有意不抄**这个标记。 + * + * 这个结论**是错的**,错在取证方法:我**只看了一封没有附件的邮件**, + * 看到 key 不在,就断言服务端从不返回它。 + * 实际上 —— + * · 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**, + * 还写了理由:「Agent 靠收件箱列表得知有哪些附件可下载, + * 否则它不知道该调 `attachment_id`」; + * · `Mail.Attachments` 的 json tag 带 **`omitempty`** + * ⇒ **没有附件的邮件根本不输出这个 key**。 + * + * 2026-09-20 实测(`limit=200`,96 封):带 `attachments` 的 **2 封**, + * 正好就是真有附件的那两封。 + * + * ★ 教训(与同一天的崩溃是**同一个坑的两面**): + * **`omitempty` 字段的"缺失"不等于"服务端不返回"。** + * 判「某字段有没有」必须拿**确实有值的那条**去验, + * 而不是拿一条恰好为空的数据。 + * 那天崩溃那一面是:`session_workspace` 缺失 → 客户端拿到 `undefined` → 白屏; + * 今天是另一面:缺失 → 我误判成"不返回" → 少抄一个真功能。 + * + * 与 `cc_count` 同一形状(数字而不是数组): + * `MailLike` 是 interface(ArkTS 接口里不能有 getter),列表行只需要个数; + * 派生放在 `MailSummary` 的填充处(`attach_count = attachments.length`)。 + */ + attach_count: number; source_account_id: string; source_account_name: string; } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index b67878e..1706d80 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -62,7 +62,20 @@ export class MailSummary implements MailLike { created_at: string = ''; status: string = ''; // unread | read is_read: boolean = true; - has_attachments: boolean = false; + /** + * ★★ 2026-09-20 修:删掉 `has_attachments`、改成真正的 `attachments`。 + * + * `has_attachments` 是**死的** —— 全服务端 `grep HasAttachments` **零命中** + * (`models.go` 里根本没有这个字段,Go 侧从来没有这个概念)。 + * 它是客户端照着"应该有个布尔"的直觉加出来的,服务端**永远不返回**, + * 于是恒为 `false`。而真数据一直躺在 `attachments` 里。 + * + * 服务端真正返回的是 `Mail.Attachments []Attachment` + * (`models.go:186`,带 `omitempty` —— 见下面那段"缺失 ≠ 不返回"的教训)。 + * 列表行只需要**个数**,所以与 `cc_count` 同一形状派生出 `attach_count` + * (接口里不能有 getter,ArkTS 限制)。 + */ + attachments: AttachmentInfo[] = []; permission_mode: string = ''; /** * 邮件类型:`permission_request` = 待人点头的**待办**,其余是要读的内容。 @@ -85,6 +98,16 @@ export class MailSummary implements MailLike { * 或在 `cc_list` 赋值处同步 —— 两处都要写,所以放在模型里注释说明。 */ cc_count: number = 0; + /** + * 派生的附件数(列表行的回形针 + 数字)。 + * + * 与 **完全同一形状** —— 就是因为 是 interface、 + * 不能有 getter,所以要在模型里多存一个派生字段,并在每个填充点同步。 + * 本次加它时漏了这里,编译器当场报 + * 「Class 'MailSummary' incorrectly implements interface 'MailLike'」—— + * 这正是字段要写两处的代价,也是那条注释想提醒的事。 + */ + attach_count: number = 0; /** 客户端聚合字段:服务端不返回,由收件箱按来源账号填充。 */ source_account_id: string = ''; source_account_name: string = ''; @@ -302,7 +325,20 @@ export class PermissionRequest { /** 决策时要回传的邮件 id(`POST /permission/decide` 的 body 用它) */ mail_id: string = ''; session_id: string = ''; - session_alias: string = ''; + /* + * ★★ 2026-09-20 **删掉 `session_alias`**(原文留在这里说明为什么)。 + * + * 这里原来声明了 `session_alias: string = ''`。但服务端的 + * `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`), + * `repo.ListPendingPermissionsFor` 的 SELECT 也没查它 —— + * WebUI 的类型里同样没有(`types/index.ts:233` 只有 `session_id`/`agent_name`)。 + * + * 它是我照"授权卡总得显示会话名"这个直觉加出来的。 + * **声明一个服务端永不返回的字段不会编译报错、也不会被任何判据抓到**, + * 只会在真正用它的时候炸 —— `MainPage` 的授权卡就是这么写的, + * 靠"当前待办数一直是 0(实测 `{"requests":[]}`)"侥幸没暴露。 + * ⇒ 删掉它,让误用变成**编译错**,而不是运行时白屏。 + */ /** 发起请求的 Agent(权限请求一定由 Agent 发出) */ agent_name: string = ''; /** Agent 的问题原文:「是否允许我删除 X」 */ diff --git a/client/harmony/entry/src/main/ets/pages/InboxPage.ets b/client/harmony/entry/src/main/ets/pages/InboxPage.ets index b83b63b..718490b 100644 --- a/client/harmony/entry/src/main/ets/pages/InboxPage.ets +++ b/client/harmony/entry/src/main/ets/pages/InboxPage.ets @@ -220,7 +220,7 @@ struct InboxPage { .padding({ left: 8 }) // 附件图标 - if (mail.has_attachments) { + if (mail.attachments.length > 0) { AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.textMuted }).margin({ right: 8 }) } diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 2b74b7f..94ef1ea 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -12,9 +12,11 @@ import { ThreadNode } from '../model/Models'; import { SessionApi } from '../api/SessionApi'; import { RenameProposal } from '../model/SessionRename'; import { AccountManager, AccountInfo } from '../api/AccountManager'; -import { MailDetail, SendMailRequest, ForwardMailRequest, Address } from '../model/Models'; +import { MailDetail, SendMailRequest, ForwardMailRequest, Address, AttachmentInfo } from '../model/Models'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; +import { saveBinaryFile, IcsSaveResult } from '../common/IcsFile'; +import { attachmentLabel } from '../model/Attachment'; import { permissionLabel } from '../model/MailGrouping'; import { LIST_FADE_LENGTH, HEADER_BACK_HIT } from '../model/NavItems'; import { LengthMetrics } from '@kit.ArkUI'; @@ -88,6 +90,10 @@ export struct MailDetailView { @State mailType: string = ''; /** 已读状态:`unread` 时头部常驻一个「未读」点(与 WebUI 同一位置与语义) */ @State status: string = ''; + /** 这封邮件的附件清单(服务端在详情接口里返回;空则不渲染整块) */ + @State attachments: AttachmentInfo[] = []; + /** 正在下载的附件 id(防重复点击;空串表示没有在下的) */ + @State downloadingId: string = ''; /** 对话树:是否显示弹层 + 已整理好的行(见 `openThread()`) */ @State showThread: boolean = false; @State threadLines: string[] = []; @@ -226,6 +232,8 @@ export struct MailDetailView { this.fromHuman = mail.from_human; this.toHuman = mail.to_human; this.sessionWorkspace = mail.session_workspace; + /* 附件清单(`normalize()` 已把服务端 `omitempty` 的缺失补成 []) */ + this.attachments = mail.attachments; this.ccList = mail.cc_list; this.mailType = mail.mail_type; this.status = mail.status; @@ -331,6 +339,45 @@ export struct MailDetailView { } } + /** + * 下载一个附件(WebUI `Attachments.tsx:21` 那个可点条目)。 + * + * 流程:取字节 → 系统文件选择器让用户选位置 → 写盘 → 报结果。 + * + * ★ 三个必须守住的行为: + * ① **下载中禁用重复点击**(`downloadingId`):附件可能几十兆, + * 连点会并发好几个请求,还会弹好几个选择器。 + * ② **用户取消不算失败**(`saveBinaryFile` 返回 `ok=false` 且 `message` 为空) + * —— 弹"下载失败"会让人以为出错了。这条容易漏(取消返回的是**空数组** + * 而不是抛异常),所以判在工具函数里、这里只区分两种空。 + * ③ **失败要说出来**:走的是网络 + 文件系统两条链路,静默失败用户无从判断。 + */ + async downloadAttachment(a: AttachmentInfo): Promise { + if (this.mailApi === null || this.downloadingId.length > 0) { + return; + } + this.downloadingId = a.attachment_id; + try { + const data: ArrayBuffer = await this.mailApi.downloadAttachment(a.attachment_id); + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + const r: IcsSaveResult = await saveBinaryFile(ctx, data, a.filename); + if (r.ok) { + this.getUIContext().getPromptAction().showToast({ message: '已保存到 ' + r.path }); + } else if (r.message.length > 0) { + this.getUIContext().getPromptAction().showToast({ message: '下载失败: ' + r.message }); + } + /* r.ok=false 且 message 为空 = 用户取消,不提示(见 ②) */ + } catch (e) { + const ae = e as BusinessError; + this.getUIContext().getPromptAction().showToast({ message: '下载失败: ' + ae.message }); + } finally { + this.downloadingId = ''; + } + } + /** * 对话树(WebUI `MailView.tsx:528` 的「对话树」,`onThread`)。 * @@ -883,6 +930,57 @@ export struct MailDetailView { .width('100%') .padding({ left: 16, right: 16, bottom: 16 }) + /* + * ── 附件区(只读,点击下载)── + * + * ★★ 2026-09-20 补。鸿蒙**原来完全没有这一块**: + * `MailDetail.attachments` 字段一直在模型里、服务端也在返回, + * 但界面一个都没画 —— 收到带附件的邮件在鸿蒙上**看不出来**。 + * WebUI `MailView.tsx:148/692` 两处都调 `AttachmentList`。 + * + * 位置也逐项对齐:WebUI 是 + *
…正文…
+ * ← 紧跟正文之后 + * 所以这里排在 `Markdown` 之后。 + * + * 无附件时**整块不渲染**(`if` 而不是画个空标题)—— + * WebUI `AttachmentList` 第一行就是 + * `if (!items || items.length === 0) return null`。 + */ + if (this.attachments.length > 0) { + Column() { + /* 标题行:回形针图标 + 「附件 N」(与 WebUI 同形) */ + Row({ space: 6 }) { + AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.textMuted }) + Text('附件 ' + this.attachments.length) + .fontSize(11).fontColor(Theme.textMuted) + } + .width('100%') + .margin({ bottom: 8 }) + + ForEach(this.attachments, (a: AttachmentInfo) => { + Row({ space: 8 }) { + AmIcon({ iconName: 'file', iconSize: 14, iconColor: Theme.textSubtleFor() }) + Text(attachmentLabel(a.filename, a.size).filename) + .fontSize(12).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text(attachmentLabel(a.filename, a.size).size) + .fontSize(10).fontColor(Theme.textSubtleFor()) + AmIcon({ iconName: 'download', iconSize: 14, iconColor: Theme.textSubtleFor() }) + } + .width('100%') + .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .borderRadius(8) + .border({ width: 1, color: Theme.border }) + .margin({ bottom: 6 }) + .onClick(() => { this.downloadAttachment(a); }) + }, (a: AttachmentInfo) => a.attachment_id) + } + .width('100%') + .padding({ left: 16, right: 16, bottom: 16 }) + } + /* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */ Blank().height(80) } diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 09a4c23..8a890d9 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -827,21 +827,41 @@ struct InboxTab { * 在列表里看不出任何区别,得点进去才知道。而 `cc_list` 服务端一直有返回 * (实测回包字段列表里有),只是 `MailLike` 接口漏了这个字段。 * - * ★★ **有意不抄附件标记**(WebUI 那个 `attachCount`):核对服务端实测回包, - * 列表接口既没有 `attachments` 也没有 `has_attachments` - * (`MailList.tsx:351` 的 `mail.attachments?.length ?? 0` 在列表里**恒为 0** - * —— 它在 WebUI 上也是个从不出现的死标记)。 - * 照抄一个不工作的东西,只会让鸿蒙多一处"看起来有、永远不亮"的代码。 - * ⇒ 要显示附件数得先让**服务端**在列表回包里带上它;那是独立的一件事, - * 不是这里顺手能补的。 + * ── 附件标记(WebUI `MailList.tsx:351` 那个回形针 + 数字)── * - * 有条件才画:没有抄送时不占位,否则每行都多一片空白。 + * ★★ 2026-09-20 **更正我 2026-09-19 的一个错误结论**。 + * + * 那天我在这里写的是「**有意不抄**附件标记」,理由是我"实测"列表接口 + * 既没有 `attachments` 也没有 `has_attachments`,所以 WebUI 那个 📎 + * 在列表里恒为 0、是死代码。 + * + * **那个结论是错的**,错在取证:我只看了一封**没有附件**的邮件, + * 看到 key 不在就断言服务端从不返回它。而 + * · 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**, + * 注释还写着理由:「Agent 靠收件箱列表得知有哪些附件可下载, + * 否则它不知道该调 attachment_id」; + * · `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ + * **没有附件的邮件根本不输出这个 key**。 + * 实测 `limit=200`(96 封):带 `attachments` 的 2 封,正是真有附件那两封。 + * + * ⇒ WebUI 那个 📎 **不是死代码**,鸿蒙这次补上它。 + * + * 有条件才画(两端同口径):没有附件/抄送时不占位,否则每行都多一片空白。 */ - if (mail.cc_count > 0) { - Text('抄送 ' + mail.cc_count) - .fontSize(10).fontColor(Theme.textSubtleFor()) - .margin({ top: 3 }) + Row({ space: 8 }) { + if (mail.attach_count > 0) { + Row({ space: 2 }) { + AmIcon({ iconName: 'paperclip', iconSize: 10, iconColor: Theme.textSubtleFor() }) + Text(mail.attach_count.toString()) + .fontSize(10).fontColor(Theme.textSubtleFor()) + } + } + if (mail.cc_count > 0) { + Text('抄送 ' + mail.cc_count) + .fontSize(10).fontColor(Theme.textSubtleFor()) + } } + .margin({ top: 3 }) } .layoutWeight(1).height('100%') .alignItems(HorizontalAlign.Start) @@ -1001,7 +1021,15 @@ struct SentTab { .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ top: 4 }) - if (mail.body_preview.length > 0) { + /* + * `?? ''` 不能省:服务端 `BodyPreview` 带 `omitempty` + * (`models.go:183`),而它的值就是 `Body`(≤200 字截断)—— + * **空正文的邮件 → 预览是空串 → 服务端整个 key 都不输出** + * ⇒ `undefined.length` 抛 TypeError。 + * 实测那批 96 封恰好都有正文,所以"看起来没问题"—— 那正是这个坑的形态: + * 拿一批恰好非空的数据是验不出它的(见 Models.ets 顶部那段教训)。 + */ + if ((mail.body_preview ?? '').length > 0) { Text(mail.body_preview) .fontSize(11).fontColor(Theme.textMuted) .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -1275,7 +1303,24 @@ struct PermissionTab { .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) - Text(req.session_alias.length > 0 ? req.session_alias : '(未命名会话)') + /* + * ★★ 2026-09-20 修:**这里原来读的是一个不存在的字段**。 + * + * `req` 是 `PermissionRequest`,而服务端那个 struct + * (`models.go:239-255`)**没有 `session_alias`** —— + * `repo.ListPendingPermissionsFor` 的 SELECT 也没查它。 + * WebUI 的类型里同样没有(`types/index.ts:233` 只有 `session_id`/`agent_name`)。 + * + * ⇒ `req.session_alias` 恒为 `undefined`, + * 一旦有待办,`.length` 当场抛 TypeError(整页白屏)。 + * 这个 bug **一直没暴露只是因为当前待办数一直是 0** —— + * 实测 `/permission/pending` 返回 `{"requests":[]}`。 + * + * 改成服务端**确实有**的 `agent_name`(授权请求一定由 Agent 发出, + * 这是卡片上最有辨识度的一格)。 + * 与 WebUI 同口径:那边授权卡标题也只显示 Agent 与问题,不显示会话别名。 + */ + Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)') .fontSize(10).fontColor(Theme.accentFor()) } .width('100%') diff --git a/docs/DEBTS.json b/docs/DEBTS.json index 740ae3d..96adbda 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -122,11 +122,11 @@ }, { "id": "mail-list-attachment-count", - "count": 1, - "due": "邮件列表需要显示附件数时(要服务端在列表回包里带上它)", + "count": 0, + "due": "已修(2026-09-20):字段确实返回,两端都可接", "where": "server/internal/handler(邮件列表的响应构造) —— 客户端侧见 client/harmony/entry/src/main/ets/pages/MainPage.ets 的 MailItem", "kind": "scope", - "note": "2026-09-19 审计发现:**两端都没有**附件数可用,而 WebUI 里那段代码看起来像有。实测 `GET /me/mail/inbox` 的回包字段列表里**既没有 `attachments` 也没有 `has_attachments`** ⇒ `MailList.tsx:351` 的 `mail.attachments?.length ?? 0` 在列表里**恒为 0**,那个 📎 标记在 WebUI 上**从不出现**(死代码)。鸿蒙这一轮只补了「抄送 N」(`cc_list` 服务端确实返回,真数据),**有意不抄附件标记** —— 照抄一个不工作的东西只会多一处「看起来有、永远不亮」的代码。要真做这个功能,先让服务端在列表响应里带上附件计数(一次 JOIN 的事),然后两端一起接。" + "note": "★★ 2026-09-20 **更正**:当初这条的结论是**错的**,根因是它把一个`omitempty` 造成的**字段缺失**当成了「服务端不返回这个字段」。\n\n原文(留档,别再犯):实测 `GET /me/mail/inbox` 的回包字段列表里「既没有 `attachments` 也没有 `has_attachments`」⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0` 恒为 0、那个 📎 在 WebUI 上从不出现(死代码)。\n\n事实:服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,并写了理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」。而 `Mail.Attachments` 的 json tag 带 **`omitempty`** —— **没有附件的邮件根本不输出这个 key**。\n我当初是**只看了一封没附件的邮件**就下了全称结论。\n\n2026-09-20 实测(`limit=200`,96 封):带 `attachments` 的 **2 封**,都是真有附件的那两封。⇒ **WebUI 那个 📎 不是死代码**,鸿蒙当初「有意不抄」的前提不成立。\n\n★ 教训:`omitempty` 字段的**缺失**不等于「服务端不返回」。判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。这与同一天那个真崩溃(`session_workspace` 的 `omitempty` → 客户端 `undefined` → 白屏)是**同一个坑的两面**。" }, { "id": "harmony-maildetail-missing-three",