跨端: 修一个真崩溃(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:
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user