跨端: 修一个真崩溃(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:
@ -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<MailDetail> {
|
||||
return this.client.get<MailDetail>('/mail/' + mailId);
|
||||
const m: MailDetail = await this.client.get<MailDetail>('/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<void> {
|
||||
/* 空请求体也要是个**已声明的类型**(ArkTS 禁无类型对象字面量)。
|
||||
复用一个现成的空类即可,不为"传个空"再造一个类型。 */
|
||||
await this.client.post<void>('/mail/' + mailId + '/read', new EmptyRequest());
|
||||
}
|
||||
|
||||
/** 对话树 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/** 联系人(= 一条三维地址) */
|
||||
|
||||
@ -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 || '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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