Files
MailUI4Agents/client/harmony/entry/src/main/ets/common/IcsFile.ets
JianFeeeee 25e7d8f3bf 跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 bug
══ ① 更正我 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<T>`:后者假定 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。附件区的**渲染**(清单外观、下载落盘)
尚未在设备上看过 —— 待模拟器恢复后补。
2026-09-20 21:14:20 +08:00

168 lines
6.7 KiB
Plaintext
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.

/*
* AgentMail 鸿蒙客户端 — .ics 文件的选/读/写
*
* 为什么单独一个文件:这三件事都带 `@kit.CoreFileKit` 依赖,而**纯逻辑必须没有 SDK 依赖**
* (`model/*.ts` 要让 Node 判据直接 import)。文件选择器天然是设备相关的,所以它
* 单独待在这里,不污染任何可以被离线判据执行的东西。
*
* 与 WebUI 的分工差异:WebUI 在浏览器里,导出是 `Blob` + `<a download>`、
* 导入是 `<input type=file>`,两条都由浏览器实现。鸿蒙没有"下载目录"这个概念,
* 必须显式走系统文件选择器 —— 这不是"多此一举",是这个平台上唯一
* 能让用户拿到文件 / 指定文件的路。
*/
import { picker } from '@kit.CoreFileKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { util } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 导出文件的建议名(与 WebUI/服务端的 `agentmail-calendar.ics` 同名) */
export const ICS_DEFAULT_FILENAME: string = 'agentmail-calendar.ics';
/** 导出成功/失败的结果 —— 调用侧要能说出"存到哪了",而不是一句"已导出" */
export class IcsSaveResult {
ok: boolean = false;
/** 落盘路径(用户可以在文件管理器里找到它) */
path: string = '';
/** 失败原因(用户取消不算失败,见 `saveIcs`) */
message: string = '';
}
export class IcsPickResult {
ok: boolean = false;
/** 文件内容;ok=false 时为空串 */
text: string = '';
message: string = '';
}
/**
* 让用户选一个 .ics 并读出文本。
*
* `DocumentViewPicker.select` 的 `maxSelectNumber` 必须是 1 —— 多选了之后
* "把哪一份导进去"就成了一个我们没打算回答的问题(服务端也只收一份文本)。
*
* 取消(用户按了返回)**不是错误**:`select` 会正常返回空数组。
* 把取消当成失败弹红字,是"用户什么也没做却被骂一句"。
*/
export async function pickIcsText(ctx: common.Context): Promise<IcsPickResult> {
const out = new IcsPickResult();
try {
const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = 1;
/*
* 只列 .ics。`fileSuffixFilters` 是"给用户看的过滤器",
* 不是安全边界 —— 真正的校验在服务端解析时(格式不对它会回 {error})。
*/
options.fileSuffixFilters = ['.ics'];
const docPicker = new picker.DocumentViewPicker(ctx);
const uris: string[] = await docPicker.select(options);
if (uris === undefined || uris.length === 0) {
// 用户取消:ok=false 但 message 为空 —— 调用侧据此**不报错**
return out;
}
const uri: string = uris[0];
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
try {
const stat = fileIo.statSync(file.fd);
const buf = new ArrayBuffer(stat.size);
fileIo.readSync(file.fd, buf);
const decoder = new util.TextDecoder('utf-8');
out.text = decoder.decodeToString(new Uint8Array(buf));
out.ok = out.text.length > 0;
if (!out.ok) {
out.message = '文件是空的';
}
} finally {
fileIo.closeSync(file);
}
return out;
} catch (e) {
const be = e as BusinessError;
hilog.error(0x0001, 'IcsFile', 'pick failed: %{public}s', JSON.stringify(be));
out.message = be.message !== undefined && be.message.length > 0 ? be.message : '无法读取文件';
return out;
}
}
/**
* 让用户选保存位置并写入 .ics 文本。
*
* `DocumentViewPicker.save` 返回的是**新文件的 uri**;写它之前必须先 `create`
* (`save` 在某些版本上只返回 uri、不落文件),这是这个 API 的实际形状。
*
* 取消同样**不是错误**(用户按返回 ⇒ 返回空数组)。
*/
export async function saveIcsText(ctx: common.Context, text: string, filename: string): Promise<IcsSaveResult> {
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; // 用户取消
}
const uri: string = uris[0];
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
try {
fileIo.writeSync(file.fd, text);
out.ok = true;
out.path = uri;
} finally {
fileIo.closeSync(file);
}
return out;
} catch (e) {
const be = e as BusinessError;
hilog.error(0x0001, 'IcsFile', 'save failed: %{public}s', JSON.stringify(be));
out.message = be.message !== undefined && be.message.length > 0 ? be.message : '无法写入文件';
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<IcsSaveResult> {
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;
}
}