跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 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。附件区的**渲染**(清单外观、下载落盘) 尚未在设备上看过 —— 待模拟器恢复后补。
This commit is contained in:
@ -338,6 +338,25 @@ export class MailApi {
|
||||
await this.client.post<Object>('/contacts/archive', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载附件的原始字节(`GET /me/attachments/{id}`,服务端 `MeDownloadAttachment`)。
|
||||
*
|
||||
* ★★ 2026-09-20 加。鸿蒙原来**只实现了上传、没实现下载** ——
|
||||
* 而详情页连附件区都没有(见 `model/Attachment.ts` 的头注释):
|
||||
* 收到带附件的邮件,在鸿蒙上既看不到、也拿不到。
|
||||
* WebUI `Attachments.tsx:21` 是可点的下载链接。
|
||||
*
|
||||
* ★ 走 `getBytes` 而不是 `get<T>`:后者假定响应是 JSON
|
||||
* (`JSON.parse(response.result as string)`),拿它取二进制会当场炸 ——
|
||||
* `getBytes` 的注释里写着这条(壁纸当初踩过同一个坑)。
|
||||
*
|
||||
* ★ 认证走 header(Bearer),不用 `?token=`:后者会把密钥写进服务端日志
|
||||
* 与访问历史(服务端注释里明确不做这件事,壁纸那处也守的同一条)。
|
||||
*/
|
||||
async downloadAttachment(attachmentId: string): Promise<ArrayBuffer> {
|
||||
return this.client.getBytes('/me/attachments/' + attachmentId);
|
||||
}
|
||||
|
||||
/** 上传附件(multipart/form-data,字段名 file)→ attachment_id */
|
||||
async uploadAttachment(filePath: string, fileName: string): Promise<string> {
|
||||
const resp = await this.client.uploadFile('/me/attachments', filePath, fileName);
|
||||
|
||||
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
58
client/harmony/entry/src/main/ets/model/Attachment.ts
Normal file
58
client/harmony/entry/src/main/ets/model/Attachment.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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」 */
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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<void> {
|
||||
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 是
|
||||
* <div className="markdown">…正文…</div>
|
||||
* <AttachmentList items={…} /> ← 紧跟正文之后
|
||||
* 所以这里排在 `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)
|
||||
}
|
||||
|
||||
@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user