══ ① 更正我 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。附件区的**渲染**(清单外观、下载落盘) 尚未在设备上看过 —— 待模拟器恢复后补。
168 lines
6.7 KiB
Plaintext
168 lines
6.7 KiB
Plaintext
/*
|
||
* 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;
|
||
}
|
||
}
|