跨端: 修一个真崩溃(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

@ -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());
}
/** 对话树 */

View File

@ -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;
}
/** 联系人(= 一条三维地址) */

View File

@ -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 || '');
}
/**

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