跨端: 修一个真崩溃(omitempty)+ 邮件详情补"标记已读/对话树"两个动作

用户:「还有其他行为都要一一对齐,例如邮件展示页面」。做这件事时**撞出一个真崩溃**。

══ ① 崩溃:`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)

根因是**服务端 `omitempty` + 客户端裸转型**这个组合:
· 服务端 `Mail` 有 12 个字段带 `json:"...,omitempty"`(models.go:140-238)——
  Go 对零值**根本不输出这个 key**。实测 `/api/v1/mail/{id}`:
      session_workspace   ★缺失
      body_preview        ★缺失
      permission_result   ★缺失
      attachments         ★缺失
· `ApiClient` 是 `JSON.parse(rawText) as T`(裸转型、无归一化)——
  ArkTS 对"JSON 里没这个 key"**不会**套用 class 的 `= ''` 默认值
  (那只在**整个对象**缺失时生效)⇒ 字段变成 `undefined`。
· 于是 `workspace.trim()` 当场抛。

★ 这正是用户要做的 **B**(两套纯逻辑各写一份)的实证分叉:
  electron 写的是 `(workspace || '').trim()`,鸿蒙写的是 `workspace.trim()`。
  少了那两个 `||`,代价是一个崩溃。已在 `ReplyTarget.ts` 照 electron 逐字对齐。

★ 但**不在那里了事**(同一个坑还有十几个字段,逐个打补丁必然漏):
  在**解析边界**加一层归一化 `MailDetail.normalize()`,接在 `mailApi.mailDetail()` 上。
  下游从此可以按"字段一定存在"来写(那本来就是类型声明该保证的事)。
  不改服务端去掉 omitempty —— 那会动已发布的 API 契约,代价大得多;
  而且 electron 一直靠 `?.`/`|| ''` 兜,说明这个契约是既成事实。

★ 为什么以前没暴露:`fromAddress()` 只在**展开头部**时才调用,
  而展开头部是个 14px 的薄弱点击区(之前修过一次)。我把动作行加进展开区,
  等于把这条路走宽了 —— 一展开就崩。

══ ② 又一处 B 分叉:对话树回包类型整个是错的(接口 200,界面空白)

鸿蒙 `ThreadResponse` 声明的是 `dir / has_more_up / has_more_down / next_up /
next_down` —— **服务端一个都没有**;服务端真正返回的 `root_mail_id /
anchor_depth / has_more / next_offset` 这里**一个都没声明**。
更糟的是 `ThreadApiResponse` 还包了一层 `thread`,而服务端是**平铺**的:
    {"anchor_depth":1,"anchor_mail_id":"...","has_more":false,"hidden":0,
     "next_offset":60,"nodes":[...],"root_mail_id":"...","total":2}
⇒ `resp.thread.nodes` 永远读不到 ⇒ 点「对话树」什么都不显示。
已按 electron 的 `types/index.ts:179 ThreadPage` 与服务端实测回包对齐。

★ 教训记下来:`JSON.parse as T` 是裸转型,**照自己直觉声明第三方回包类型,
  编译器不会查**。改成 electron 那样的 `ThreadPage` 形状后才对。

══ ③ 补两个动作(对齐 WebUI `MailView.tsx:520-544`)

· **标记已读** —— 仅 `status === 'unread'` 时出现;成功后**就地**把 `this.status`
  改成 `'read'`(只发请求不改状态的话按钮还挂着,用户会以为没生效再点一次);
  失败要 toast(写操作静默失败比报错更坏)。不做乐观更新 ——
  "标已读失败了却显示已读"比慢 0.2 秒更糟。
· **对话树** —— 盖在内容之上的弹层(不改路由),与 WebUI 的 `onThread` 同义。
· 转发不重复(右下球已有)。
· 位置、顺序、显隐条件逐项对齐:动作行在**元信息行之前**(WebUI 是 `mb-1.5`
  那一行),蓝色表示动作、灰色表示导航,与 WebUI 的 `text-blue-600` /
  `text-gray-500` 同一取舍。

设备验证:展开头部不再崩(无新 faultlog);`POST /mail/{id}/read` 已发出且
「未读」徽标与按钮同时消失;对话树弹出并显示真实数据(2 封,dsh → jianf)。

★ 工具坑记一笔:这台模拟器上 `devecocli ui tap` **点了不生效**,
  `hdc shell "uitest uiInput click X Y"` 才有效 —— 为此白跑过两轮。
This commit is contained in:
2026-09-20 12:48:37 +08:00
parent 44e277e0b6
commit 5c04b41900
4 changed files with 383 additions and 14 deletions

View File

@ -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<void> {
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<void> {
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)
}
/**