diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index 95f5850..6fb7b57 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -6,7 +6,7 @@ */ import { ApiClient } from './ApiClient'; -import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest} from '../model/Models'; +import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest} from '../model/Models'; /** 收件箱响应 */ export class InboxResponse { @@ -32,14 +32,44 @@ export class ContactListResponse { total: number = 0; } +/** 无请求体的 POST 用(ArkTS 要求对象字面量有类型,不能直接写 `{}`) */ +export class EmptyRequest { +} + /** 邮件详情响应 */ export class MailDetailResponse { mail: MailDetail = new MailDetail(); } -/** 对话树响应 */ +/** + * 对话树响应 —— **就是 `ThreadPage` 本身,没有外层包装**。 + * + * ★★ 2026-09-20 修。改之前这里写的是: + * export class ThreadApiResponse { + * thread: ThreadResponse = new ThreadResponse(); // ← 服务端没有这层 + * } + * 服务端 `/mail/{id}/thread` 是**直接把 `ThreadPage` 铺在顶层**返回的 + * (实测 key:`anchor_depth / anchor_mail_id / has_more / hidden / + * next_offset / nodes / root_mail_id / total`)—— + * 没有 `thread` 这一层。 + * + * 于是调用方读 `resp.thread.nodes` 永远拿到**空数组**(`thread` 是 undefined + * ⇒ 第一版直接在 `.length` 上崩过)。表现是:接口 200、请求发出去了、 + * 点「对话树」什么都不显示。 + * + * 保留这个别名(而不是让 `thread()` 直接返回 `ThreadResponse`)是为了 + * 调用点读起来还有"这是一个接口响应"的语义;但**类型上就是 ThreadPage**。 + * 这与 electron 的 `api.getMailThread()` 返回 `ThreadPage` 是同一形状。 + */ export class ThreadApiResponse { - thread: ThreadResponse = new ThreadResponse(); + anchor_mail_id: string = ''; + root_mail_id: string = ''; + anchor_depth: number = 0; + nodes: ThreadNode[] = []; + total: number = 0; + hidden: number = 0; + has_more: boolean = false; + next_offset: number = 0; } /** 发信响应 */ @@ -188,7 +218,34 @@ export class MailApi { /** 邮件详情(API 返回裸对象) */ async mailDetail(mailId: string): Promise { - return this.client.get('/mail/' + mailId); + const m: MailDetail = await this.client.get('/mail/' + mailId); + /* + * ★★ 归一化(详见 `model/Models.ets` 顶部那段注释): + * 服务端 12 个字段带 `omitempty`,零值时**整个 key 都不出现**; + * 而这里是裸 `JSON.parse as T`,缺失字段会变成 `undefined`, + * 下游任何 `.trim()` / `.length` 都会当场崩。 + * 在**解析边界**补一次,下游就能按"字段一定存在"写。 + */ + return MailDetail.normalize(m); + } + + /** + * 把一封邮件标为已读(`POST /mail/{id}/read`,服务端 `MarkMailRead`)。 + * + * ★★ 2026-09-20 补(用户:「还有其他行为都要一一对齐,例如邮件展示页面」)。 + * + * 之前鸿蒙**没有这个调用** —— 详情页头部只有"未读"两个字告诉你是未读, + * 但没有办法把它变成已读(只能在列表上等它自己变?其实不会变)。 + * WebUI 有「标记已读」按钮(`MailView.tsx:522-526`,只在 `status === 'unread'` + * 时出现),我们的详情页完全没有这个动作。 + * + * 契约与服务端一致:**无请求体**,路径带邮件 id;回包我们只需要成功与否, + * 状态由调用方就地更新(与 WebUI 的乐观更新同一做法)。 + */ + async markRead(mailId: string): Promise { + /* 空请求体也要是个**已声明的类型**(ArkTS 禁无类型对象字面量)。 + 复用一个现成的空类即可,不为"传个空"再造一个类型。 */ + await this.client.post('/mail/' + mailId + '/read', new EmptyRequest()); } /** 对话树 */ diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index c713a79..b67878e 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -98,6 +98,51 @@ export class AttachmentInfo { content_type: string = ''; } +/** + * ── 服务端 `omitempty` 的防御层 ── + * + * ★★ 2026-09-20 加(**真崩溃换来的**,不是预防性代码)。 + * + * 服务端 `Mail` 结构里有 12 个字段带 `json:"...,omitempty"` + * (`server/internal/models/models.go:140-238`)。Go 的 `omitempty` 对 + * **零值不输出该 key** —— 所以回包里这些字段是**缺失**的,不是 `""`/`[]`。 + * 实测 `/api/v1/mail/{id}` 一封普通邮件: + * + * session_workspace ★缺失 + * body_preview ★缺失 + * permission_result ★缺失 + * attachments ★缺失 + * session_alias 'homeagent-L2-探针' ← 有值时才出现 + * + * 而 `ApiClient` 是 `JSON.parse(rawText) as T`(裸转型,无归一化), + * ArkTS 对"JSON 里没这个 key"**不会**套用 class 的 `= ''` 默认值 + * (那只在**整个对象**缺失时生效)⇒ 字段是 `undefined`。 + * + * 于是 `workspace.trim()` 抛 `TypeError: Cannot read property trim of undefined`, + * 整个页面白屏、应用重启。崩溃日志: + * jscrash-com.jianf.agentmail-...-20260920123121173.log + * at participantAddress (model/ReplyTarget.ts:87:40) + * at fromAddress (pages/MailDetailPage.ets:393:12) + * 触发点是「**展开邮件头部**」—— 一个平时很难点到的 14px 薄弱区。 + * + * ★ 为什么在这里修(而不是在 `ReplyTarget` 里加 `|| ''` 了事): + * `participantAddress` 那处我确实也补了(与 electron 逐字对齐), + * 但**同一个坑还有十几个字段**,逐个打补丁必然漏。 + * 根因是"**裸转型 + omitempty**"这个组合,所以在**解析边界**归一化一次, + * 下游所有代码就都能按"字段一定存在"来写(那才是类型声明本来就该保证的事)。 + * + * ★ 为什么不改服务端去掉 `omitempty`:那会改变**已发布的 API 契约** + * (老客户端可能靠"key 缺失"判断空值),代价比在这里上一层大得多。 + * 而且 electron 侧一直是靠 `?.` / `|| ''` 兜的 —— 说明这个契约是既成事实。 + */ +function str(v: string | undefined | null): string { + return v === undefined || v === null ? '' : v; +} + +function boolOr(v: boolean | undefined | null, fallback: boolean): boolean { + return v === undefined || v === null ? fallback : v; +} + /** 邮件详情 */ export class MailDetail { mail_id: string = ''; @@ -129,6 +174,35 @@ export class MailDetail { session_workspace: string = ''; cc_list: Address[] = []; mail_type: string = ''; + + /** + * 把服务端 `omitempty` 造成的**缺失字段**补回声明的初值。 + * + * 调用点:`MailApi.mailDetail()`(解析边界)。 + * 为什么需要:见本文件顶部那段长注释(这是一个真实崩溃的修法)。 + */ + static normalize(m: MailDetail): MailDetail { + m.mail_id = str(m.mail_id); + m.session_id = str(m.session_id); + m.from_name = str(m.from_name); + m.to_name = str(m.to_name); + m.cc = m.cc === undefined || m.cc === null ? [] : m.cc; + m.subject = str(m.subject); + m.body = str(m.body); + m.created_at = str(m.created_at); + m.status = str(m.status); + m.session_alias = str(m.session_alias); + m.session_workspace = str(m.session_workspace); + m.permission_mode = str(m.permission_mode); + m.permission_enforcement = str(m.permission_enforcement); + m.mail_type = str(m.mail_type); + m.from_human = boolOr(m.from_human, false); + m.to_human = boolOr(m.to_human, false); + m.is_read = boolOr(m.is_read, true); + m.attachments = m.attachments === undefined || m.attachments === null ? [] : m.attachments; + m.cc_list = m.cc_list === undefined || m.cc_list === null ? [] : m.cc_list; + return m; + } } /** @@ -158,17 +232,42 @@ export class ThreadNode { parent_hidden: boolean = false; } -/** 对话树响应 */ +/** + * 对话树响应(服务端 `ThreadPage`,**平铺**的一页,不是嵌套结构)。 + * + * ★★ 2026-09-20 修 —— 又一处 B 分叉(实测撞出来的)。 + * + * 改之前这里写的是另一套字段: + * dir / has_more_up / has_more_down / next_up / next_down + * **服务端一个都没有**,而服务端真正返回的 + * `root_mail_id` / `anchor_depth` / `has_more` / `next_offset` + * 这里**一个都没声明**。 + * + * 后果:`.nodes` 永远读不到(它其实在**顶层**,不在 `thread` 里), + * 于是「对话树」点开是一片空白 —— 接口 200、请求也发出去了、 + * 界面什么都不显示(实测日志里 `GET /mail/{id}/thread` 有,但面板不出内容)。 + * + * 正确形状来自 electron 的权威类型 `types/index.ts:179` 的 `ThreadPage` + * 与服务端实测回包,两边一致: + * {"anchor_depth":1,"anchor_mail_id":"...","has_more":false,"hidden":0, + * "next_offset":60,"nodes":[...],"root_mail_id":"...","total":2} + * + * ★ 教训:**照着自己的直觉声明第三方回包的类型**,编译器不会帮你查 + * (`JSON.parse as T` 是裸转型),只有真跑一次才发现字段全错。 + */ export class ThreadResponse { anchor_mail_id: string = ''; - dir: string = ''; + /** 线索根的 mail_id:整棵树从它展开 */ + root_mail_id: string = ''; + /** 锚点距根的层数,用于高亮定位 */ + anchor_depth: number = 0; nodes: ThreadNode[] = []; total: number = 0; + /** 因权限被过滤掉的节点数 */ hidden: number = 0; - has_more_up: boolean = false; - has_more_down: boolean = false; - next_up: number = 0; - next_down: number = 0; + has_more: boolean = false; + /** 下一页 offset,原样回传即可 */ + next_offset: number = 0; } /** 联系人(= 一条三维地址) */ diff --git a/client/harmony/entry/src/main/ets/model/ReplyTarget.ts b/client/harmony/entry/src/main/ets/model/ReplyTarget.ts index 58b9e19..61d6ccb 100644 --- a/client/harmony/entry/src/main/ets/model/ReplyTarget.ts +++ b/client/harmony/entry/src/main/ets/model/ReplyTarget.ts @@ -80,11 +80,45 @@ export function participantAddress( workspace: string, sessionAlias: string ): string { + /* + * ★★ 2026-09-20 修 —— **这是本函数在两端分叉的实证**(用户要求做的 B)。 + * + * electron 版(权威)写的是: + * return formatAddress(name, (workspace || '').trim(), sessionAlias || null); + * 鸿蒙版写的是: + * return formatAddress(name, workspace.trim(), sessionAlias); + * + * 少了那两个 `|| ''`。看着只是"少写了个防御",实际**会崩**: + * + * ① 服务端的 `SessionWorkspace` 带 **`json:"...,omitempty"`** + * (`server/internal/models/models.go:181`)—— 空值时 Go **根本不输出 + * 这个 key**,回包里没有 `session_workspace` 这一项。 + * ② ArkTS 的 `@State sessionWorkspace: string = ''` 初值是空串, + * 但 `this.sessionWorkspace = mail.session_workspace` 会把 + * **`undefined` 赋进去**(类里的 `= ''` 默认值只在**整个对象**缺失时生效, + * 不用于"JSON 里没这个字段"的情况)。 + * ③ 于是 `workspace.trim()` 抛 `TypeError: Cannot read property trim + * of undefined` —— **整个页面白屏、应用重启**。 + * + * 实测(模拟器,展开邮件头部的那一刻): + * TypeError: Cannot read property trim of undefined + * at participantAddress (model/ReplyTarget.ts:87:40) + * at fromAddress (pages/MailDetailPage.ets:393:12) + * 崩溃日志 `jscrash-com.jianf.agentmail-...-20260920123121173.log`。 + * + * ★ 为什么以前没暴露:`fromAddress()` 只在**展开头部**时才被调用, + * 而展开头部是一个 14px 高的薄弱点击区(上一次修过),很少有人点到。 + * 我这次把动作行加进展开区,等于把这条路走宽了 —— 一展开就崩。 + * + * 修法就是**照抄 electron 的写法**:`(workspace || '').trim()`。 + * 这正是用户要的 B 方向(electron 是唯一真实源泉,鸿蒙向它收敛)—— + * 也说明"两套纯逻辑各写一份"迟早会分叉,而分叉的代价是一个崩溃。 + */ // 人(无工作目录):只有名字,不带 path 也不带会话位 if (isHuman) { return formatAddress(name, '', ''); } - return formatAddress(name, workspace.trim(), sessionAlias); + return formatAddress(name, (workspace || '').trim(), sessionAlias || ''); } /** diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 27da20b..4bffde5 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -7,7 +7,8 @@ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; /* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */ import { Markdown } from '@luvi/lv-markdown-in'; -import { MailApi, SessionBudget } from '../api/MailApi'; +import { MailApi, SessionBudget, ThreadApiResponse } from '../api/MailApi'; +import { ThreadNode } from '../model/Models'; import { SessionApi } from '../api/SessionApi'; import { RenameProposal } from '../model/SessionRename'; import { AccountManager, AccountInfo } from '../api/AccountManager'; @@ -87,6 +88,9 @@ export struct MailDetailView { @State mailType: string = ''; /** 已读状态:`unread` 时头部常驻一个「未读」点(与 WebUI 同一位置与语义) */ @State status: string = ''; + /** 对话树:是否显示弹层 + 已整理好的行(见 `openThread()`) */ + @State showThread: boolean = false; + @State threadLines: string[] = []; @State body: string = ''; @State createdAt: string = ''; @State permissionMode: string = ''; @@ -297,6 +301,86 @@ export struct MailDetailView { this.getUIContext().getRouter().back(); } + /** + * 标记已读(WebUI `MailView.tsx:522` 的「标记已读」)。 + * + * ★ 两个必须照抄的细节(WebUI `MailView.tsx:50-64` 的注释里已经踩过): + * + * ① **就地更新状态**,不等重拉:`markRead` 成功后把 `this.status` 改成 'read'。 + * 只发请求不改状态的话,按钮还挂着、用户以为没生效会再点一次。 + * WebUI 的 store 也是就地改(`mailStore.ts:128-140`)。 + * ② **失败要说出来**:这是写操作,静默失败比报错更坏 + * (用户以为标了,其实没有,下次打开还是未读)。 + * + * 不做乐观更新(先改 UI 再发请求):WebUI 那版也是 `await` 之后才 set —— + * 标已读失败了却显示已读,比慢 0.2 秒更糟。 + */ + async doMarkRead(): Promise { + if (this.status !== 'unread') { + return; // 已读的再标一次是白跑一趟(WebUI 同样只在 unread 时发) + } + try { + if (this.mailApi === null) { + return; + } + await this.mailApi.markRead(this.mailId); + this.status = 'read'; + } catch (e) { + const ae = e as BusinessError; + this.getUIContext().getPromptAction().showToast({ message: '标记已读失败: ' + ae.message }); + } + } + + /** + * 对话树(WebUI `MailView.tsx:528` 的「对话树」,`onThread`)。 + * + * ★ 鸿蒙**原来已经有** `MailApi.thread()`(`api/MailApi.ets:195`), + * 但**一个调用点都没有** —— 写完就搁在那儿了。这里把它接上。 + * + * 交互对齐 WebUI:那里点「对话树」是 `setThreadOf(mail_id)`,弹出一个 + * 沿回复/转发关系展开的视图。鸿蒙这边用同一语义的最小实现: + * 把整条线索**平铺**在一个弹层里(每条显示方向 + 时间 + 主题)—— + * 与 WebUI 的 `ThreadView` 表达同一件事,不引新的路由。 + */ + async openThread(): Promise { + try { + if (this.mailApi === null) { + return; + } + const resp: ThreadApiResponse = await this.mailApi.thread(this.mailId); + /* + * 服务端的线索是 `ThreadResponse.nodes`(不是我以为的 `thread[]`)—— + * 每个 `ThreadNode` 带 **`depth`**(相对锚点的层数)。 + * 所以用缩进表达层级,而不是我自己编的 `dir` 箭头 + * (`ThreadNode` 里根本没有 `dir`/`created_at` —— 我第一版照 WebUI 的 + * React 组件猜字段名,撞了编译错才发现)。 + * + * 显示什么:`from_name` → `to_name` + 主题。WebUI 的 `ThreadView` + * 表达的也是这三个信息(谁发的、给谁、说什么)。 + */ + const lines: string[] = []; + for (let i = 0; i < resp.nodes.length; i++) { + const t: ThreadNode = resp.nodes[i]; + let indent: string = ''; + for (let d = 0; d < t.depth; d++) { + indent += ' '; + } + const who: string = t.from_name.length > 0 ? t.from_name : '(未知)'; + const to: string = t.to_name.length > 0 ? t.to_name : '(未知)'; + lines.push(indent + who + ' → ' + to + '\n' + indent + ' ' + + (t.subject.length > 0 ? t.subject : '(无主题)')); + } + if (lines.length === 0) { + lines.push('这条线索只有这一封。'); + } + this.threadLines = lines; + this.showThread = true; + } catch (e) { + const ae = e as BusinessError; + this.getUIContext().getPromptAction().showToast({ message: '加载对话树失败: ' + ae.message }); + } + } + /** * 发件方在这条会话里的**完整地址**(`name@path.session`)。 * @@ -551,7 +635,13 @@ export struct MailDetailView { } build() { - Column() { + /* + * 最外层为什么是 `Stack` 而不是 `Column`: + * 对话树弹层要盖在**整页**之上(含头部),`Column` 里塞不进"覆盖层"。 + * 与 WebUI 一样,弹层是页面级的(`MailView` 把 `ThreadView` 挂在最外层)。 + */ + Stack() { + Column() { /* * ── 可折叠头部(与 WebUI `CollapsibleHeader` 同构)── * @@ -615,6 +705,43 @@ export struct MailDetailView { if (this.headerOpen) { Column() { + /* + * ── 动作行(展开态)── + * + * ★★ 2026-09-20 补(用户:「还有其他行为都要一一对齐,例如邮件展示页面」)。 + * + * WebUI `MailView.tsx:520-544` 在头部动作行里有**三个**入口,我们原来 + * 只有"转发"其实也没有(转发在下方的球上),这里是**一个都没有**: + * + * ① 标记已读 —— 仅 `status === 'unread'` 时出现(`onRead`) + * ② 对话树 —— 沿回复/转发关系展开整条线索(`onThread`,TreeIcon) + * ③ 转发 —— 我们已有(右下球),不重复 + * + * 顺序、文案、显隐条件**逐项对齐**: + * 「标记已读」是**动作**(蓝、可点性明确);「对话树」是**导航** + * (灰、常态不抢注意力)—— WebUI 用 `text-blue-600` vs + * `text-gray-500` 区分,这里照搬。 + * + * 位置也在同一处:**元信息行之前**(WebUI 是 `mb-1.5` 的那一行, + * 紧接着才是 `发件/收件/抄送` 的 `dl`)。原因很清楚 —— + * 动作是你看完"这是谁发的"之后马上要做的事, + * 放在元信息下面会被那些行推远。 + */ + Row({ space: 16 }) { + if (this.status === 'unread') { + Text('标记已读') + .fontSize(12).fontColor(Theme.accentFor()) + .onClick(() => { this.doMarkRead(); }) + } + Row({ space: 4 }) { + AmIcon({ iconName: 'tree', iconSize: 14, iconColor: Theme.textMuted }) + Text('对话树').fontSize(12).fontColor(Theme.textMuted) + } + .onClick(() => { this.openThread(); }) + } + .width('100%') + .margin({ bottom: 6 }) + this.MetaRow('发件', this.fromAddress()) this.MetaRow('收件', this.toAddress()) if (this.ccList.length > 0) { @@ -979,9 +1106,61 @@ export struct MailDetailView { .position({ x: 0, y: 0 }) } } + } + .width('100%').height('100%') + .backgroundColor(Theme.pageBg) + + /* + * ── 对话树弹层 ── + * + * 位置/形态对齐 WebUI:那里点「对话树」是**盖在正文之上的一个面板**, + * 不是一个新页面(不改变路由,关闭即回到原处)。 + * 用同一套遮罩 + 底部弹层外壳(与回复/转发框同构), + * 这样三个弹层的交互记忆是一致的。 + */ + if (this.showThread) { + Column() { + Column() + .width('100%').layoutWeight(1) + .backgroundColor(Theme.overlay) + .onClick(() => { this.showThread = false; }) + + Column() { + Row() { + Text('对话树') + .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + Text('关闭') + .fontSize(13).fontColor(Theme.accentFor()) + .onClick(() => { this.showThread = false; }) + } + .width('100%') + .margin({ bottom: 10 }) + + List() { + ForEach(this.threadLines, (line: string, idx: number) => { + ListItem() { + Text(line) + .fontSize(12).fontColor(Theme.textMuted) + .width('100%') + .margin({ bottom: 10 }) + } + }, (line: string, idx: number) => idx.toString()) + } + .layoutWeight(1).width('100%') + } + .width('100%') + .height('60%') + .padding(16) + .backgroundColor(Theme.surface) + .borderRadius({ topLeft: 12, topRight: 12 }) + .transition(Theme.paneRiseIn()) + } + .width('100%').height('100%') + .position({ x: 0, y: 0 }) + } } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) } /**