══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档) 那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有 `has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0` 恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。 **这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**, 看到 key 不在,就断言服务端从不返回它。事实: · 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着 理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」 · `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key** 实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。 ★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。 判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。 这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛) 是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。 ══ ② 补附件区(鸿蒙原来完全没有) · `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖, 逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。 · `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get<T>`:后者假定 JSON, 取二进制会炸;壁纸当初踩过)。 · `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。 · `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载), 位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。 · 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。 ══ ③ 顺带撞出并修掉两个**真 bug** **bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` —— 而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是 `undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑, 我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。** ⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。 **bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` —— 而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`), `ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。 它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`, 一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。 ⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**: 让误用变成**编译错**,而不是运行时白屏。 同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)—— 空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文, 所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。 ══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例) 从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名 (不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。 ★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报: `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带), 鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。 ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。 已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。 变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。 ══ ⑤ 数据路径已实测(模拟器) 临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52, 默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现), 加临时 hilog 后拿到: AttProbe: mail=531a1629-… attach=1 AttProbe: mail=b68cbbe8-… attach=1 正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。 ══ ⚠️ 本轮**未能**完成设备端视觉验收 模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时), 导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。 主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘) 尚未在设备上看过 —— 待模拟器恢复后补。
1435 lines
62 KiB
Plaintext
1435 lines
62 KiB
Plaintext
/*
|
||
* AgentMail 鸿蒙客户端 — 邮件详情页
|
||
* GET /mail/{id} 展示单封完整内容 + 回复按钮
|
||
* 路由参数:mail_id
|
||
*/
|
||
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, 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';
|
||
import { MailDetail, SendMailRequest, ForwardMailRequest, Address, AttachmentInfo } from '../model/Models';
|
||
import { MailDetailParams } from '../model/RouteParams';
|
||
import { AmIcon } from '../common/Icons';
|
||
import { saveBinaryFile, IcsSaveResult } from '../common/IcsFile';
|
||
import { attachmentLabel } from '../model/Attachment';
|
||
import { permissionLabel } from '../model/MailGrouping';
|
||
import { LIST_FADE_LENGTH, HEADER_BACK_HIT } from '../model/NavItems';
|
||
import { LengthMetrics } from '@kit.ArkUI';
|
||
import {
|
||
participantAddress,
|
||
mailReplyTarget,
|
||
formatAddress
|
||
} from '../model/ReplyTarget';
|
||
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
|
||
|
||
/**
|
||
* 本地时间:与 WebUI `new Date(x).toLocaleString('zh-CN')` 同一口径。
|
||
*
|
||
* 原先详情页直接把 `mail.created_at`(ISO 8601,如 `2026-09-15T03:37:07.14758Z`)
|
||
* 吐到界面上 —— 用户读到的是 UTC 串,既不好读也不是本地时间。
|
||
* 与列表页的 `compactMailTime()` 分开:列表要短(`09/15 11:37`),
|
||
* 详情要完整(含秒与年月日)。两处都走本地时区,不用 ISO 原文。
|
||
*/
|
||
function localDateTime(iso: string): string {
|
||
const value: Date = new Date(iso);
|
||
if (Number.isNaN(value.getTime())) {
|
||
return '';
|
||
}
|
||
const y: number = value.getFullYear();
|
||
const mo: string = (value.getMonth() + 1).toString().padStart(2, '0');
|
||
const d: string = value.getDate().toString().padStart(2, '0');
|
||
const h: string = value.getHours().toString().padStart(2, '0');
|
||
const mi: string = value.getMinutes().toString().padStart(2, '0');
|
||
const s: string = value.getSeconds().toString().padStart(2, '0');
|
||
return y + '/' + mo + '/' + d + ' ' + h + ':' + mi + ':' + s;
|
||
}
|
||
|
||
@Component
|
||
export struct MailDetailView {
|
||
/*
|
||
* 当前是否深色(`MainPage` 经 `AppStorage` 发布)—— 用来选品牌浅底的深浅变体。
|
||
*
|
||
* ★ 2026-09-19:深色主题下这些"浅蓝底"(未读行 / 选中项 / 分段选中)
|
||
* 在深色页面上刺眼。根因是 `Theme.accentSoft` 写死浅色
|
||
* (WebUI 靠 CSS 的 `.dark` 段反转发解决,ArkTS 静态常量没有那层机制)。
|
||
* 窗格自己算不出深浅色 ⇒ 从 AppStorage 读。
|
||
*/
|
||
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
|
||
|
||
/**
|
||
* 既支持旧 router 页面,也支持 Navigation 的 NavDestination 内嵌复用。
|
||
* 内嵌时由父级传入 mailId/accountId;独立页面仍从 router params 读取。
|
||
*/
|
||
@Prop initialMailId: string = '';
|
||
@Prop initialAccountId: string = '';
|
||
@Prop embedded: boolean = false;
|
||
/**
|
||
* 底部悬浮条高度(vp)——嵌入在 `Navigation` 里时由主页面传入。
|
||
*
|
||
* 窄屏 Stack 模式下详情也是画在 `Navigation` 内部的,而悬浮条是它的兄弟、
|
||
* 叠在最上层 —— 所以详情页右下那个回复球要自己抬过条高,否则会压在条上。
|
||
* (窗格不能再靠 padding 让位:那样内容就永远滑不到条底下,
|
||
* 系统材质无东西可糊,玻璃看起来就是一块普通浅色面板。)
|
||
*/
|
||
@Prop navReserve: number = 0;
|
||
onBack: () => void = (): void => {};
|
||
@State mailId: string = '';
|
||
@State accountId: string = '';
|
||
@State subject: string = '';
|
||
@State fromName: string = '';
|
||
@State toName: string = '';
|
||
@State fromHuman: boolean = false;
|
||
@State toHuman: boolean = false;
|
||
@State sessionWorkspace: string = '';
|
||
@State ccList: Address[] = [];
|
||
@State mailType: string = '';
|
||
/** 已读状态:`unread` 时头部常驻一个「未读」点(与 WebUI 同一位置与语义) */
|
||
@State status: string = '';
|
||
/** 这封邮件的附件清单(服务端在详情接口里返回;空则不渲染整块) */
|
||
@State attachments: AttachmentInfo[] = [];
|
||
/** 正在下载的附件 id(防重复点击;空串表示没有在下的) */
|
||
@State downloadingId: string = '';
|
||
/** 对话树:是否显示弹层 + 已整理好的行(见 `openThread()`) */
|
||
@State showThread: boolean = false;
|
||
@State threadLines: string[] = [];
|
||
@State body: string = '';
|
||
@State createdAt: string = '';
|
||
@State permissionMode: string = '';
|
||
@State sessionAlias: string = '';
|
||
@State loading: boolean = true;
|
||
@State error: string = '';
|
||
@State showReplyBox: boolean = false;
|
||
@State replyBody: string = '';
|
||
@State sending: boolean = false;
|
||
/*
|
||
* 转发(2026-09-19 审计发现缺失)。
|
||
*
|
||
* `MailApi.forward()` **早就实现了**(`api/MailApi.ets:191`),
|
||
* 服务端 `forward.go` 也完整(含引用块渲染与 `Fwd:` 叠加处理)——
|
||
* 缺的只是这一页的入口。对齐 WebUI `MailView.tsx:378` 的 `ForwardBar`。
|
||
*/
|
||
/*
|
||
* 会话改名建议(Agent 在正文里提的;服务端从正文的 HTML 注释标记解析出来)。
|
||
*
|
||
* 别名是**人**的寻址入口,所以是"Agent 提议 + 人确认"(WebUI 同口径)。
|
||
* 读的时机:本页挂载时拉一次(与正文同一批)。
|
||
*/
|
||
@State renameProposal: RenameProposal | null = null;
|
||
/** 接受/驳回在飞 —— 两个动作都改服务端状态,防连点 */
|
||
@State renameBusy: boolean = false;
|
||
|
||
/*
|
||
* 往返预算(`GET/PUT /sessions/{id}/budget`)。
|
||
*
|
||
* ★ 为什么这是最该可编辑的地方(服务端注释原话):
|
||
* 「人看着往来内容才知道这件事还值不值得再来几个回合」。
|
||
* WebUI 的 `BudgetEditor` 就摆在对话页头部 —— 鸿蒙此前**完全没有**这一块。
|
||
*/
|
||
@State budget: SessionBudget | null = null;
|
||
/** 是否展开成编辑态(WebUI 的 `editing`:显示态是一个徽标,点它才变成输入框) */
|
||
@State budgetEditing: boolean = false;
|
||
/** 编辑中的草稿(**字符串**:空串表示"不限",与 WebUI 同口径) */
|
||
@State budgetDraft: string = '';
|
||
@State budgetBusy: boolean = false;
|
||
|
||
@State showForwardBox: boolean = false;
|
||
@State forwardTo: string = '';
|
||
@State forwardCc: string = '';
|
||
/** 抄送输入是否展开(WebUI 的 `ccOpen`:默认收起,点「抄送」才出现) */
|
||
@State forwardCcOpen: boolean = false;
|
||
@State forwardComment: string = '';
|
||
@State sessionId: string = '';
|
||
@State switchingPerm: boolean = false;
|
||
/**
|
||
* 头部**默认收起**,点标题行展开。
|
||
*
|
||
* 与 WebUI `CollapsibleHeader` 同一策略,理由也是同一条:
|
||
* 「顶部邮件信息 + 底部输入框同时常驻会把可读区压成一条缝」——
|
||
* 邮件的用途是读,头部信息不是每时每刻都要用的。
|
||
* WebUI 实测 1280×800 下头部占 17%、回复框占 31%,留给正文只剩 48%,
|
||
* 而手机竖屏比那更窄,这条更需要。
|
||
*/
|
||
@State headerOpen: boolean = false;
|
||
/** 当前登录用户名 —— 决定「这封是不是我发的」,进而决定回给谁(见 ReplyTarget) */
|
||
@State me: string = '';
|
||
|
||
private mailApi: MailApi | null = null;
|
||
/**
|
||
* 本页正在用的 API 客户端(与 `mailApi` 同一个实例)。
|
||
*
|
||
* 为什么留一份:改名建议要打**会话级**端点(`/sessions/{id}/...`),
|
||
* 那是 `SessionApi` 的职责;而 client 是在 `aboutToAppear` 里**局部构造**的
|
||
* (带该账号的 base + token)—— 不留一份,后面就没法再拿它建别的 Api 对象。
|
||
* 不把 client 塞进 `MailApi` 暴露(那是"为了少一个字段"而扩大一个类的职责)。
|
||
*/
|
||
private client: ApiClient | null = null;
|
||
|
||
aboutToAppear(): void {
|
||
const ctx = this.getUIContext().getHostContext();
|
||
if (ctx === undefined) {
|
||
this.loading = false;
|
||
this.error = '无法获取应用上下文';
|
||
return;
|
||
}
|
||
if (this.initialMailId.length > 0) {
|
||
this.mailId = this.initialMailId;
|
||
this.accountId = this.initialAccountId;
|
||
} else {
|
||
const params = this.getUIContext().getRouter().getParams() as MailDetailParams;
|
||
this.mailId = params?.mail_id ?? '';
|
||
this.accountId = params?.account_id ?? '';
|
||
}
|
||
const accountManager: AccountManager = AccountManager.getInstance(ctx);
|
||
accountManager.load().then(() => {
|
||
let account: AccountInfo | null = accountManager.getAccount(this.accountId);
|
||
if (account === null) {
|
||
account = accountManager.getActiveAccount();
|
||
}
|
||
if (account === null) {
|
||
this.loading = false;
|
||
this.error = '找不到邮件所属账号';
|
||
return;
|
||
}
|
||
this.accountId = account.id;
|
||
/*
|
||
* 当前登录用户名:决定「这封是不是我发的」,进而决定回给谁。
|
||
* 与 WebUI 的 `useAuthStore(s => s.user?.username)` 同一口径 ——
|
||
* 写死 'human' 是多用户之前的遗留(登录名可能是 jianf,判据恒为假),
|
||
* 那会让「自己发的信」也算成别人发的,回复时回给自己。
|
||
*/
|
||
this.me = account.username;
|
||
const accountClient: ApiClient = new ApiClient(ctx);
|
||
accountClient.setBase(account.server);
|
||
accountClient.setToken(account.token);
|
||
this.client = accountClient;
|
||
this.mailApi = new MailApi(accountClient);
|
||
if (this.mailId.length === 0) {
|
||
this.loading = false;
|
||
this.error = '缺少邮件 ID';
|
||
return;
|
||
}
|
||
this.loadMail(this.mailId);
|
||
});
|
||
}
|
||
|
||
async loadMail(mailId: string): Promise<void> {
|
||
const m: MailApi | null = this.mailApi;
|
||
if (m === null) {
|
||
return;
|
||
}
|
||
this.loading = true;
|
||
this.error = '';
|
||
try {
|
||
const mail: MailDetail = await m.mailDetail(mailId);
|
||
this.subject = mail.subject;
|
||
this.fromName = mail.from_name;
|
||
this.toName = mail.to_name;
|
||
this.fromHuman = mail.from_human;
|
||
this.toHuman = mail.to_human;
|
||
this.sessionWorkspace = mail.session_workspace;
|
||
/* 附件清单(`normalize()` 已把服务端 `omitempty` 的缺失补成 []) */
|
||
this.attachments = mail.attachments;
|
||
this.ccList = mail.cc_list;
|
||
this.mailType = mail.mail_type;
|
||
this.status = mail.status;
|
||
this.body = mail.body;
|
||
this.createdAt = mail.created_at;
|
||
this.permissionMode = mail.permission_mode;
|
||
this.sessionAlias = mail.session_alias;
|
||
this.sessionId = mail.session_id;
|
||
/*
|
||
* ★★ 2026-09-19:改名建议要**在这里拉一次** —— 我第一版把提示条的 UI 写完了
|
||
* 却忘了接数据,于是页面上**永远不显示**那条建议(服务端明明有)。
|
||
* 这正是本仓反复出现的"写好了但没人调用"的形状:
|
||
* 静态判据查得出"代码里有 getRenameProposal 这个方法",
|
||
* 查不出"页面从没调过它"。
|
||
*
|
||
* 拉失败**不影响正文**:建议条是附加信息,拿不到就不显示(不报错、不阻塞)。
|
||
*/
|
||
this.loadRenameProposal();
|
||
/* 预算与建议条同批(都是附加信息,失败不影响正文) */
|
||
this.loadBudget();
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
this.error = ae.code === 0 ? ae.message : '加载失败';
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 拉一次会话改名建议(有就显示提示条,没有就什么都不做)。
|
||
*
|
||
* 单独一个方法而不是塞进 `loadMail` 的 try 里:建议条是**附加信息**,
|
||
* 它失败不该让正文加载看起来也失败了(`loadMail` 的 catch 会把整页变成错误态)。
|
||
*/
|
||
async loadRenameProposal(): Promise<void> {
|
||
const c: ApiClient | null = this.client;
|
||
const sid: string = this.sessionId;
|
||
if (c === null || sid.length === 0) {
|
||
return;
|
||
}
|
||
try {
|
||
this.renameProposal = await new SessionApi(c).getRenameProposal(sid);
|
||
} catch (e) {
|
||
/* 拿不到就不显示 —— 不为一条可选提示把正文也变错误态 */
|
||
this.renameProposal = null;
|
||
}
|
||
}
|
||
|
||
async switchPermission(mode: string): Promise<void> {
|
||
const m: MailApi | null = this.mailApi;
|
||
const sid: string = this.sessionId;
|
||
if (m === null || sid.length === 0 || this.switchingPerm) {
|
||
return;
|
||
}
|
||
this.switchingPerm = true;
|
||
try {
|
||
await m.setPermissionMode(sid, mode);
|
||
this.permissionMode = mode;
|
||
this.getUIContext().getPromptAction().showToast({ message: '权限已切换为 ' + mode });
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
this.getUIContext().getPromptAction().showToast({ message: '切换失败: ' + ae.message });
|
||
} finally {
|
||
this.switchingPerm = false;
|
||
}
|
||
}
|
||
|
||
goBack(): void {
|
||
if (this.embedded) {
|
||
this.onBack();
|
||
return;
|
||
}
|
||
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 `Attachments.tsx:21` 那个可点条目)。
|
||
*
|
||
* 流程:取字节 → 系统文件选择器让用户选位置 → 写盘 → 报结果。
|
||
*
|
||
* ★ 三个必须守住的行为:
|
||
* ① **下载中禁用重复点击**(`downloadingId`):附件可能几十兆,
|
||
* 连点会并发好几个请求,还会弹好几个选择器。
|
||
* ② **用户取消不算失败**(`saveBinaryFile` 返回 `ok=false` 且 `message` 为空)
|
||
* —— 弹"下载失败"会让人以为出错了。这条容易漏(取消返回的是**空数组**
|
||
* 而不是抛异常),所以判在工具函数里、这里只区分两种空。
|
||
* ③ **失败要说出来**:走的是网络 + 文件系统两条链路,静默失败用户无从判断。
|
||
*/
|
||
async downloadAttachment(a: AttachmentInfo): Promise<void> {
|
||
if (this.mailApi === null || this.downloadingId.length > 0) {
|
||
return;
|
||
}
|
||
this.downloadingId = a.attachment_id;
|
||
try {
|
||
const data: ArrayBuffer = await this.mailApi.downloadAttachment(a.attachment_id);
|
||
const ctx = this.getUIContext().getHostContext();
|
||
if (ctx === undefined) {
|
||
return;
|
||
}
|
||
const r: IcsSaveResult = await saveBinaryFile(ctx, data, a.filename);
|
||
if (r.ok) {
|
||
this.getUIContext().getPromptAction().showToast({ message: '已保存到 ' + r.path });
|
||
} else if (r.message.length > 0) {
|
||
this.getUIContext().getPromptAction().showToast({ message: '下载失败: ' + r.message });
|
||
}
|
||
/* r.ok=false 且 message 为空 = 用户取消,不提示(见 ②) */
|
||
} catch (e) {
|
||
const ae = e as BusinessError;
|
||
this.getUIContext().getPromptAction().showToast({ message: '下载失败: ' + ae.message });
|
||
} finally {
|
||
this.downloadingId = '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 对话树(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`)。
|
||
*
|
||
* 与 WebUI `Header` 的 `participantAddress(mail.from_name, mail.from_human, ws, alias)`
|
||
* 逐字对齐:人只有名字(不分段也不带会话位),Agent 要三段。
|
||
* workspace 取**会话的** `session_workspace`,不取 `from_workspace`
|
||
* (后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`)。
|
||
*/
|
||
fromAddress(): string {
|
||
return participantAddress(
|
||
this.fromName, this.fromHuman, this.sessionWorkspace, this.sessionAlias
|
||
);
|
||
}
|
||
|
||
/** 收件方在这条会话里的完整地址 */
|
||
toAddress(): string {
|
||
return participantAddress(
|
||
this.toName, this.toHuman, this.sessionWorkspace, this.sessionAlias
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 抄送行文案:优先用 `raw`(用户输入的原文)。
|
||
*
|
||
* 不用拼回来的字符串替:`.new` 这类**原始意图**在 `raw` 里,
|
||
* 换成真实别名就把「他要新建一条线索」这件事抹掉了。
|
||
*/
|
||
ccText(): string {
|
||
const parts: string[] = [];
|
||
for (let i = 0; i < this.ccList.length; i++) {
|
||
const a: Address = this.ccList[i];
|
||
const shown: string = a.raw.length > 0
|
||
? a.raw
|
||
: formatAddress(a.name, a.path, a.session);
|
||
if (shown.length > 0) {
|
||
parts.push(shown);
|
||
}
|
||
}
|
||
return parts.join('、');
|
||
}
|
||
|
||
/**
|
||
* 单封邮件视图的回复目标地址。
|
||
*
|
||
* **这是发信真正用的地址**,不是显示用的。原先是 `this.fromName + '@'` ——
|
||
* 那个游离的 `@` 让 `name@` 被解析成「有 path、无 session」,落到该 Agent 的
|
||
* **默认会话**,而不是用户正在看的这条线索。
|
||
*/
|
||
replyTargetAddress(): string {
|
||
return mailReplyTarget(
|
||
this.fromName, this.fromHuman, this.toName, this.toHuman,
|
||
this.sessionWorkspace, this.sessionAlias, this.me
|
||
);
|
||
}
|
||
|
||
/** 详情里的一行「标签 + 值」(对应 WebUI `Header` 的 `<Row>`) */
|
||
@Builder
|
||
MetaRow(label: string, value: string) {
|
||
Row() {
|
||
Text(label)
|
||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||
.width(36).flexShrink(0)
|
||
Text(value)
|
||
.fontSize(12).fontColor(Theme.textMuted)
|
||
.layoutWeight(1)
|
||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
}
|
||
.width('100%')
|
||
.margin({ bottom: 2 })
|
||
.alignItems(VerticalAlign.Top)
|
||
}
|
||
|
||
/**
|
||
* 权限档位行:**中文标签**,不是英文 `plan/workspace/full`。
|
||
*
|
||
* `permissionLabel()` 早就在 `MailGrouping.ts` 里了(列表页也用的它),
|
||
* 而详情页原来直接吐英文 mode —— 同一个概念在同一个 App 里两种写法。
|
||
* 标签与 WebUI `MODE_LABEL` 逐字一致(只读 / 目录内 / 全权),可点可改。
|
||
*/
|
||
@Builder
|
||
PermissionRow() {
|
||
Row() {
|
||
Text('权限')
|
||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||
.width(36).flexShrink(0)
|
||
if (this.switchingPerm) {
|
||
LoadingProgress().width(16).height(16)
|
||
} else {
|
||
ForEach(['plan', 'workspace', 'full'], (mode: string) => {
|
||
Text(permissionLabel(mode))
|
||
.fontSize(11)
|
||
/*
|
||
* ★ 2026-09-19 修:选中态前景是 `Theme.accentFg`(白),不是 `Theme.surface`。
|
||
* `surface` 是**会跟随主题翻转的面色**(浅色近白 / 深色近黑)——
|
||
* 压在 `Theme.accent` 蓝底上,浅色下碰巧对、**深色下变成近黑** ⇒ 字看不见。
|
||
* 这是本仓同一个错法的第 N 次出现(同批一共修了 12 处)。
|
||
*/
|
||
.fontColor(this.permissionMode === mode ? Theme.accentFg : Theme.textMuted)
|
||
.backgroundColor(this.permissionMode === mode ? Theme.accent : Theme.chipNeutralBg)
|
||
.borderRadius(4)
|
||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||
.margin({ right: 6 })
|
||
.onClick(() => { this.switchPermission(mode); })
|
||
}, (mode: string) => mode)
|
||
}
|
||
}
|
||
.width('100%')
|
||
}
|
||
|
||
/**
|
||
* 往返预算行 —— 两态(对齐 WebUI `BudgetEditor`):
|
||
*
|
||
* 显示态:一个徽标 `已用/上限 来回`(不限时写「预算不限」;用尽时标红)
|
||
* 编辑态:输入框 + 保存 + 重置(点徽标进入)
|
||
*
|
||
* ★ 「用尽」的判定来自**服务端**的 `remaining`(不是自己拿 used 与 max 相减):
|
||
* 不限时服务端给 `remaining: -1`,而 `used >= max` 那个式子在这种情形下
|
||
* 会算出"已用尽"(`-1` 与 `0` 的比较)—— 两端都得用服务端的口径。
|
||
*/
|
||
@Builder
|
||
BudgetRow() {
|
||
Row() {
|
||
Text('预算')
|
||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||
.width(36).flexShrink(0)
|
||
|
||
if (this.budget === null) {
|
||
/* 还没读到就不显示 —— 不为一块可选信息留空壳 */
|
||
Text('—').fontSize(11).fontColor(Theme.textSubtleFor())
|
||
} else if (this.budgetEditing) {
|
||
/* 编辑态:空串 = 不限(WebUI 同口径,`placeholder` 也是「不限」) */
|
||
TextInput({ text: this.budgetDraft, placeholder: '不限' })
|
||
.width(70).height(28).fontSize(12)
|
||
.type(InputType.Number)
|
||
.onChange((v: string) => { this.budgetDraft = v; })
|
||
|
||
Button('保存')
|
||
.height(28).fontSize(11)
|
||
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
|
||
.enabled(!this.budgetBusy && this.budgetDraftValid())
|
||
.margin({ left: 6 })
|
||
.onClick(() => { this.saveBudget(false); })
|
||
|
||
/* 「重置计数」:把已用归零、上限不变(与「保存」是两件事) */
|
||
Button('重置')
|
||
.height(28).fontSize(11)
|
||
.backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
|
||
.enabled(!this.budgetBusy && this.budget.used_rounds > 0)
|
||
.margin({ left: 6 })
|
||
.onClick(() => { this.saveBudget(true); })
|
||
|
||
Button('取消')
|
||
.height(28).fontSize(11)
|
||
.backgroundColor(Color.Transparent).fontColor(Theme.textMuted)
|
||
.margin({ left: 6 })
|
||
.onClick(() => { this.budgetEditing = false; })
|
||
} else {
|
||
/*
|
||
* 显示态:徽标。用尽的判定走服务端的 `remaining`(见上文注释)。
|
||
*/
|
||
Text(this.budgetLabel())
|
||
.fontSize(11)
|
||
.fontColor(this.budgetExhausted() ? Theme.dangerFor() : Theme.textMuted)
|
||
.backgroundColor(this.budgetExhausted() ? Theme.dangerBg : Theme.chipNeutralBg)
|
||
.borderRadius(4)
|
||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||
.onClick(() => {
|
||
/* 进入编辑态时把当前值填进草稿(不限 ⇒ 空串) */
|
||
this.budgetDraft = this.budget !== null && !this.budget.unlimited
|
||
? this.budget.max_rounds.toString() : '';
|
||
this.budgetEditing = true;
|
||
})
|
||
}
|
||
}
|
||
.width('100%')
|
||
}
|
||
|
||
/** 草稿是否合法:空(不限)或非负整数(与服务端 `max_rounds >= 0` 同口径) */
|
||
budgetDraftValid(): boolean {
|
||
const t: string = this.budgetDraft.trim();
|
||
if (t.length === 0) {
|
||
return true;
|
||
}
|
||
/* 只认纯数字 —— `Number('1e3')` 能过但服务端是 int,传上去会被拒 */
|
||
return /^\d+$/.test(t);
|
||
}
|
||
|
||
/** 徽标文案(对齐 WebUI 的三种写法) */
|
||
budgetLabel(): string {
|
||
const b: SessionBudget | null = this.budget;
|
||
if (b === null) {
|
||
return '—';
|
||
}
|
||
if (b.unlimited) {
|
||
return '预算不限';
|
||
}
|
||
return b.used_rounds.toString() + '/' + b.max_rounds.toString() + ' 来回' +
|
||
(this.budgetExhausted() ? ' · 已用尽' : '');
|
||
}
|
||
|
||
/** 用尽:**以服务端的 remaining 为准**(不限时它是 -1,不能拿它当"用尽") */
|
||
budgetExhausted(): boolean {
|
||
const b: SessionBudget | null = this.budget;
|
||
return b !== null && !b.unlimited && b.remaining === 0;
|
||
}
|
||
|
||
/** 读一次预算(与正文同批,失败不影响正文) */
|
||
async loadBudget(): Promise<void> {
|
||
const m: MailApi | null = this.mailApi;
|
||
const sid: string = this.sessionId;
|
||
if (m === null || sid.length === 0) {
|
||
return;
|
||
}
|
||
try {
|
||
this.budget = await m.getBudget(sid);
|
||
} catch (e) {
|
||
this.budget = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存预算。
|
||
*
|
||
* @param reset 是否把已用次数归零(false = 只改上限)
|
||
*
|
||
* ★ 两个动作走**同一条 PUT**(服务端支持 `max_rounds` 与 `reset` 同时给,
|
||
* 注释原话:「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返)。
|
||
*/
|
||
async saveBudget(reset: boolean): Promise<void> {
|
||
const m: MailApi | null = this.mailApi;
|
||
const sid: string = this.sessionId;
|
||
if (m === null || sid.length === 0 || this.budgetBusy) {
|
||
return;
|
||
}
|
||
this.budgetBusy = true;
|
||
try {
|
||
/* 空草稿 ⇒ 0(不限),与服务端 `max_rounds: 0 = 不限` 同口径 */
|
||
const t: string = this.budgetDraft.trim();
|
||
const maxRounds: number = t.length === 0 ? 0 : Number(t);
|
||
await m.setBudget(sid, maxRounds, reset);
|
||
/* 重新读一次拿服务端算好的 remaining/unlimited(不自己算,见 BudgetRow 注释) */
|
||
await this.loadBudget();
|
||
this.budgetEditing = false;
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: ae.message.length > 0 ? ae.message : '预算保存失败'
|
||
});
|
||
} finally {
|
||
this.budgetBusy = false;
|
||
}
|
||
}
|
||
|
||
build() {
|
||
/*
|
||
* 最外层为什么是 `Stack` 而不是 `Column`:
|
||
* 对话树弹层要盖在**整页**之上(含头部),`Column` 里塞不进"覆盖层"。
|
||
* 与 WebUI 一样,弹层是页面级的(`MailView` 把 `ThreadView` 挂在最外层)。
|
||
*/
|
||
Stack() {
|
||
Column() {
|
||
/*
|
||
* ── 可折叠头部(与 WebUI `CollapsibleHeader` 同构)──
|
||
*
|
||
* 收起态只留一行:返回键 + 标题 + 必须常驻的状态点(未读 / 权限请求)+ 展开箭头。
|
||
* 点这一行展开收发件人、时间、抄送、档位与操作行。
|
||
* 返回键**不能**套在展开点击里(button 嵌 button)—— 与 WebUI 同一取舍。
|
||
*/
|
||
Column() {
|
||
Row() {
|
||
/*
|
||
* 返回键 —— **用图标,不用 `‹` 这个 Unicode 字符**。
|
||
*
|
||
* ★ 本仓明令禁止把 Unicode 符号当图标(渲染依赖字体、各家字形宽窄不一,
|
||
* 还会被部分主题替换成 emoji 字形)。全仓已统一走 `AmIcon` + `ICON_PATHS`,
|
||
* 这里是**最后一处漏网**(其它页面 2026-09-19 那批已经换过)。
|
||
*
|
||
* 尺寸按 `NAV_ITEM_MIN_HIT` 的口径给足 44vp 可点区 ——
|
||
* `AppHeader` 的返回键就是这个尺寸,两处要对齐。
|
||
*/
|
||
Stack({ alignContent: Alignment.Center }) {
|
||
AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() })
|
||
}
|
||
.width(HEADER_BACK_HIT).height(HEADER_BACK_HIT)
|
||
.onClick(() => { this.goBack(); })
|
||
|
||
Row() {
|
||
Text(this.subject)
|
||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
.layoutWeight(1)
|
||
|
||
/* 状态点:收起态也要看得见(与 WebUI 的 meta 同一位置与语义) */
|
||
if (this.status === 'unread') {
|
||
Text('未读')
|
||
.fontSize(10).fontColor(Theme.accentFor())
|
||
.backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4)
|
||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||
.margin({ left: 6 })
|
||
}
|
||
if (this.mailType === 'permission_request') {
|
||
Text('权限请求')
|
||
.fontSize(10).fontColor(Theme.warnFgFor())
|
||
.backgroundColor(Theme.warnBg).borderRadius(4)
|
||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||
.margin({ left: 6 })
|
||
}
|
||
|
||
AmIcon({ iconName: 'chevronRight', iconSize: 16, iconColor: Theme.textSubtleFor() })
|
||
.rotate({ angle: this.headerOpen ? 90 : 0 })
|
||
.margin({ left: 6 })
|
||
}
|
||
.layoutWeight(1)
|
||
/*
|
||
* ★★ 2026-09-19 修(设备实测撞出来的):本行原先**没有高度**,
|
||
* 高度就是内容(标题 14px 字)—— 实测 dump 里可点区是
|
||
* `Row [1277,112][3122,126]`,**只有 14px 高**,而且它落在 y=112
|
||
* (全屏后状态栏也在那个区间)⇒ 点下去经常触发**系统手势**(下拉通知)
|
||
* 而不是展开头部。
|
||
*
|
||
* 我把折叠态的头部反复点了七八次都没展开,每次都回到桌面 ——
|
||
* 而手动点别的区域就能进 —— 这个"难点"本身就是问题:
|
||
* 用户要在 14px 的横条里精准命中才能看到收件人/时间/抄送/档位/预算。
|
||
*
|
||
* 给 36vp(与左边的返回键同高,那一列的高度就是 36):
|
||
* 点击区从 14px 变成 36vp(实测约 104px),且纵向不贴边。
|
||
*/
|
||
.height(36)
|
||
.onClick(() => { this.headerOpen = !this.headerOpen; })
|
||
}
|
||
.width('100%')
|
||
|
||
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) {
|
||
this.MetaRow('抄送', this.ccText())
|
||
}
|
||
this.MetaRow('时间', localDateTime(this.createdAt))
|
||
this.PermissionRow()
|
||
/*
|
||
* 往返预算行 —— 紧接着权限档位(WebUI `CollapsibleHeader` 里
|
||
* 这两块也是相邻的:都是"这条会话当前怎么跑"的旋钮)。
|
||
*/
|
||
this.BudgetRow()
|
||
}
|
||
.width('100%')
|
||
.margin({ top: 8 })
|
||
}
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 8, right: 12, top: 8, bottom: 8 })
|
||
.backgroundColor(Theme.surface)
|
||
.onClick(() => {})
|
||
|
||
Divider().color(Theme.border)
|
||
|
||
if (this.loading) {
|
||
Column() {
|
||
LoadingProgress().width(40).height(40)
|
||
Text('加载中…').fontSize(14).fontColor(Theme.textSubtleFor()).margin({ top: 8 })
|
||
}
|
||
.width('100%').layoutWeight(1)
|
||
.justifyContent(FlexAlign.Center)
|
||
} else if (this.error.length > 0) {
|
||
Column() {
|
||
Text(this.error).fontSize(14).fontColor(Theme.dangerFor())
|
||
Button('重试').margin({ top: 12 }).onClick(() => { this.loadMail(this.mailId); })
|
||
}
|
||
.width('100%').layoutWeight(1)
|
||
.justifyContent(FlexAlign.Center)
|
||
} else {
|
||
Stack({ alignContent: Alignment.BottomEnd }) {
|
||
Scroll() {
|
||
Column() {
|
||
/*
|
||
* 会话改名建议条(WebUI `MailView.tsx:324` 的 `RenameProposalBar`)。
|
||
*
|
||
* ★ 为什么值得一条提示(WebUI 那段注释的原话):
|
||
* 「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」——
|
||
* 别名是**人**的寻址入口,所以 Agent 只能提议、人要确认。
|
||
*
|
||
* 位置与 WebUI 一致:在正文之前(那里原本就是它的位置)。
|
||
* 蓝底(`accentSoft`)而不是警告色:它不是故障,是一个**待决定**。
|
||
*/
|
||
if (this.renameProposal !== null) {
|
||
Column() {
|
||
Row() {
|
||
Column() {
|
||
Text('Agent 建议把会话别名改为 .' + this.renameProposal.alias)
|
||
.fontSize(12).fontColor(Theme.textPrimary)
|
||
.width('100%')
|
||
if (this.renameProposal.reason.length > 0) {
|
||
Text(this.renameProposal.reason)
|
||
.fontSize(11).fontColor(Theme.textMuted)
|
||
.width('100%').margin({ top: 2 })
|
||
}
|
||
/*
|
||
* 后果说明**必须有**:改名会让旧的寻址入口失效,
|
||
* 这是用户做决定前必须知道的(与 WebUI 逐字对齐)。
|
||
*/
|
||
Text('改名后需用 name@path.' + this.renameProposal.alias +
|
||
' 寻址;旧别名立即失效。接受后此别名不再被 Agent 平台的自动命名覆盖')
|
||
.fontSize(10).fontColor(Theme.textMuted)
|
||
.width('100%').margin({ top: 2 })
|
||
}
|
||
.layoutWeight(1)
|
||
.alignItems(HorizontalAlign.Start)
|
||
|
||
Button(this.renameBusy ? '改名中' : '接受')
|
||
.height(28).fontSize(12)
|
||
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
|
||
.enabled(!this.renameBusy)
|
||
.margin({ left: 8 })
|
||
.onClick(() => { this.doAcceptRename(); })
|
||
|
||
/* 「忽略」用描边按钮(次要动作)—— 与「接受」的主次一眼可辨 */
|
||
Button('忽略')
|
||
.height(28).fontSize(12)
|
||
.backgroundColor(Color.Transparent)
|
||
.fontColor(Theme.accentFor())
|
||
.borderRadius(Theme.radiusControl)
|
||
.border({ width: 1, color: Theme.accent })
|
||
.enabled(!this.renameBusy)
|
||
.margin({ left: 6 })
|
||
.onClick(() => { this.doDismissRename(); })
|
||
}
|
||
.width('100%')
|
||
.alignItems(VerticalAlign.Top)
|
||
}
|
||
.width('100%')
|
||
.padding(12)
|
||
.backgroundColor(Theme.accentSoftFor(this.isDarkNow))
|
||
.borderRadius(Theme.radiusControl)
|
||
.margin({ left: 16, right: 16, top: 12, bottom: 4 })
|
||
}
|
||
|
||
/*
|
||
* 元信息**不在正文区**了:它已经上移到可折叠头部(用户展开才显示)。
|
||
* 这里只留正文:主题 + Markdown 正文。
|
||
* 原来那张常驻元信息卡把可读区压掉一块,而那正是用户抱怨的事。
|
||
*/
|
||
Text(this.subject)
|
||
.fontSize(20)
|
||
.fontWeight(FontWeight.Bold)
|
||
.fontColor(Theme.textPrimary)
|
||
.width('100%')
|
||
.padding({ left: 16, right: 16, top: 16, bottom: 8 })
|
||
|
||
/*
|
||
* 邮件正文:**用 Markdown 渲染**,不再直接吐原文。
|
||
*
|
||
* 2026-09-15 之前这里是 `Text(this.body)` —— 正文是 Markdown,
|
||
* 于是用户看到的是 `**加粗**`、`# 标题`、`| 表 |` 的**字面量**。
|
||
* WebUI 侧一直用 `react-markdown` + `remark-gfm` 渲染
|
||
*(`MailView.tsx` 的 `.markdown` 容器),这是两端**功能不对等**的一处硬缺口。
|
||
*
|
||
* 用第三方库 `@luvi/lv-markdown-in`(鸿蒙原生 ArkTS 渲染引擎,
|
||
* **不依赖 WebView**):解析与渲染全在原生层,`ohpm install` 一行接入。
|
||
*/
|
||
Markdown({ text: this.body })
|
||
.width('100%')
|
||
.padding({ left: 16, right: 16, bottom: 16 })
|
||
|
||
/*
|
||
* ── 附件区(只读,点击下载)──
|
||
*
|
||
* ★★ 2026-09-20 补。鸿蒙**原来完全没有这一块**:
|
||
* `MailDetail.attachments` 字段一直在模型里、服务端也在返回,
|
||
* 但界面一个都没画 —— 收到带附件的邮件在鸿蒙上**看不出来**。
|
||
* WebUI `MailView.tsx:148/692` 两处都调 `AttachmentList`。
|
||
*
|
||
* 位置也逐项对齐:WebUI 是
|
||
* <div className="markdown">…正文…</div>
|
||
* <AttachmentList items={…} /> ← 紧跟正文之后
|
||
* 所以这里排在 `Markdown` 之后。
|
||
*
|
||
* 无附件时**整块不渲染**(`if` 而不是画个空标题)——
|
||
* WebUI `AttachmentList` 第一行就是
|
||
* `if (!items || items.length === 0) return null`。
|
||
*/
|
||
if (this.attachments.length > 0) {
|
||
Column() {
|
||
/* 标题行:回形针图标 + 「附件 N」(与 WebUI 同形) */
|
||
Row({ space: 6 }) {
|
||
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.textMuted })
|
||
Text('附件 ' + this.attachments.length)
|
||
.fontSize(11).fontColor(Theme.textMuted)
|
||
}
|
||
.width('100%')
|
||
.margin({ bottom: 8 })
|
||
|
||
ForEach(this.attachments, (a: AttachmentInfo) => {
|
||
Row({ space: 8 }) {
|
||
AmIcon({ iconName: 'file', iconSize: 14, iconColor: Theme.textSubtleFor() })
|
||
Text(attachmentLabel(a.filename, a.size).filename)
|
||
.fontSize(12).fontColor(Theme.textPrimary)
|
||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
.layoutWeight(1)
|
||
Text(attachmentLabel(a.filename, a.size).size)
|
||
.fontSize(10).fontColor(Theme.textSubtleFor())
|
||
AmIcon({ iconName: 'download', iconSize: 14, iconColor: Theme.textSubtleFor() })
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
|
||
.borderRadius(8)
|
||
.border({ width: 1, color: Theme.border })
|
||
.margin({ bottom: 6 })
|
||
.onClick(() => { this.downloadAttachment(a); })
|
||
}, (a: AttachmentInfo) => a.attachment_id)
|
||
}
|
||
.width('100%')
|
||
.padding({ left: 16, right: 16, bottom: 16 })
|
||
}
|
||
|
||
/* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */
|
||
Blank().height(80)
|
||
}
|
||
.width('100%')
|
||
}
|
||
.width('100%').height('100%')
|
||
.scrollBar(BarState.Off)
|
||
/*
|
||
* ★ 上下边缘渐隐(2026-09-17 补)。
|
||
*
|
||
* 用户:「你的顶栏为什么还是硬截断而不是渐变?」
|
||
* 根因:`Scroll` 与 `List` 都能挂 `fadingEdge`(都在 `ScrollableCommonMethod`
|
||
* 上),但**详情页这里一直没挂** —— 而列表页挂了。于是同一次滚动里
|
||
* 列表是渐隐、正文是硬切,看起来就像"顶栏没做渐变"。
|
||
* 同一轮盘点还查出 CalendarPage / SettingsPage / AdminUsersPage 的
|
||
* 滚动容器也漏了,一并补上(见 harmony-nav 的判据)。
|
||
*/
|
||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||
|
||
/*
|
||
* 回复入口:**右下角悬浮球**,与 WebUI 的 `reply-fab` 同形同位置。
|
||
*
|
||
* 原先是底部通栏按钮(占 56vp 常驻)—— WebUI 的注释把理由写得很清楚:
|
||
* 「顶部邮件信息 + 底部输入框同时常驻会把可读区压成一条缝」,
|
||
* 实测 1280×800 下回复框占 31%;输入框不是阅读时每刻都要用的东西,
|
||
* 就该按需展开。球同时与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。
|
||
*/
|
||
/*
|
||
* 两个动作球:转发(次要)+ 回复(主要)。
|
||
*
|
||
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):第一版把两个球**各自**
|
||
* 写在 `Stack({alignContent: BottomEnd})` 里、各带一个 margin ——
|
||
* 于是它们**几乎完全重叠**(实测 dump 的 bounds:
|
||
* 转发 `[2984,2010][3122,2148]`、回复 `[2949,1998][3110,2159]`,
|
||
* 重叠区 2984–3110)。屏幕上只看得到一个球,转发入口等于不存在。
|
||
*
|
||
* 根因:`Stack` 的 `alignContent` 把**每个**子元素都摆到同一个角,
|
||
* margin 只是各自微调 —— 想并排就得用**一个容器**把它们排起来。
|
||
* ⇒ 用 `Row({ space })` 包住两个球,Row 自己带 margin 到右下角。
|
||
*
|
||
* 视觉分工:实心球是**主动作**的语言(回复),
|
||
* 转发用描边球(`surfaceMuted` 底 + 品牌色图标)—— 两个实心球并排
|
||
* 会让人分不出哪个更主要。
|
||
*/
|
||
Row({ space: 12 }) {
|
||
Button() {
|
||
AmIcon({ iconName: 'forward', iconSize: 20, iconColor: Theme.accentFor() })
|
||
}
|
||
.width(48).height(48)
|
||
.borderRadius(24)
|
||
.backgroundColor(Theme.surfaceMuted)
|
||
.onClick(() => {
|
||
/*
|
||
* 打开转发框时把回复框关掉:两者都是底部弹层,
|
||
* 同时开着会叠在一起(WebUI 也用同一份 `forwarding` 状态互斥)。
|
||
*/
|
||
this.showReplyBox = false;
|
||
this.showForwardBox = true;
|
||
})
|
||
|
||
Button() {
|
||
AmIcon({ iconName: 'chatBubble', iconSize: 24, iconColor: Theme.accentFg })
|
||
}
|
||
.width(56).height(56)
|
||
.borderRadius(28)
|
||
.backgroundColor(Theme.accent)
|
||
.onClick(() => { this.showForwardBox = false; this.showReplyBox = true; })
|
||
}
|
||
.alignItems(VerticalAlign.Bottom)
|
||
/* 让出底部悬浮条;右边距与原来的回复球一致(球没变位置,只是多了左边一个) */
|
||
.margin({ right: 16, bottom: this.navReserve + 16 })
|
||
}
|
||
.width('100%').layoutWeight(1)
|
||
|
||
// 回复弹层
|
||
if (this.showReplyBox) {
|
||
Column() {
|
||
// 遮罩
|
||
Column()
|
||
.width('100%').layoutWeight(1)
|
||
.backgroundColor(Theme.overlay)
|
||
.onClick(() => { this.showReplyBox = false; })
|
||
|
||
// 回复框
|
||
Column() {
|
||
Text('回复给 ' + this.replyTargetAddress())
|
||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
.margin({ bottom: 12 })
|
||
|
||
TextArea({ placeholder: '输入回复内容…' })
|
||
.layoutWeight(1).width('100%')
|
||
.fontSize(14)
|
||
.onChange((v: string) => { this.replyBody = v; })
|
||
|
||
Row() {
|
||
Button('取消')
|
||
.width(80).height(36)
|
||
.backgroundColor(Theme.surfaceMuted)
|
||
.fontColor(Theme.textMuted)
|
||
.fontSize(13)
|
||
.onClick(() => { this.showReplyBox = false; })
|
||
|
||
Blank()
|
||
|
||
Button(this.sending ? '发送中…' : '发送')
|
||
.width(80).height(36)
|
||
.backgroundColor(Theme.accent)
|
||
.fontSize(13)
|
||
.enabled(!this.sending && this.replyBody.length > 0)
|
||
.onClick(() => { this.doReply(); })
|
||
}
|
||
.width('100%')
|
||
.margin({ top: 12 })
|
||
}
|
||
.width('100%')
|
||
.height('60%')
|
||
.padding(16)
|
||
.backgroundColor(Theme.surface)
|
||
.borderRadius({ topLeft: 12, topRight: 12 })
|
||
/*
|
||
* 回复框入场(对齐 WebUI `MailView.tsx:410` 那条 `rise-in`:上浮 4vp + 淡入)。
|
||
*
|
||
* ★ 挂在**回复框本身**(下半部那块表面),不是外层遮罩容器:
|
||
* WebUI 那处 `rise-in` 也挂在回复框的 div 上,遮罩是立即出现的。
|
||
* 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 ——
|
||
* 那看起来是"页面在动",而不是"回复框弹出来"。
|
||
*/
|
||
.transition(Theme.paneRiseIn())
|
||
}
|
||
.width('100%').height('100%')
|
||
.position({ x: 0, y: 0 })
|
||
}
|
||
|
||
/*
|
||
* 转发弹层 —— 与回复弹层同构(同一套遮罩 + 底部弹层 + 同一套入场动画),
|
||
* 但字段是 WebUI `ForwardBar` 那四个:收件人 / 抄送(可折叠)/ 说明 / 引用原文。
|
||
*
|
||
* WebUI 的字段顺序与占位文案逐条对齐(`MailView.tsx:412-437`)。
|
||
*/
|
||
if (this.showForwardBox) {
|
||
Column() {
|
||
Column()
|
||
.width('100%').layoutWeight(1)
|
||
.backgroundColor(Theme.overlay)
|
||
.onClick(() => { this.showForwardBox = false; })
|
||
|
||
Column() {
|
||
Row() {
|
||
Text('转发「' + this.subject + '」')
|
||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||
.layoutWeight(1)
|
||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||
/* 「抄送」是个**折叠开关**(WebUI 同形:默认收起,点它才出现输入框) */
|
||
Text(this.forwardCcOpen ? '收起抄送' : '抄送')
|
||
.fontSize(11)
|
||
.fontColor(this.forwardCcOpen ? Theme.accentFor() : Theme.textMuted)
|
||
.onClick(() => { this.forwardCcOpen = !this.forwardCcOpen; })
|
||
}
|
||
.width('100%')
|
||
.margin({ bottom: 10 })
|
||
|
||
TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' })
|
||
.width('100%').height(38).fontSize(13)
|
||
.onChange((v: string) => { this.forwardTo = v; })
|
||
|
||
if (this.forwardCcOpen) {
|
||
TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' })
|
||
.width('100%').height(38).fontSize(13)
|
||
.margin({ top: 8 })
|
||
.onChange((v: string) => { this.forwardCc = v; })
|
||
}
|
||
|
||
/*
|
||
* 说明框用 `layoutWeight(1)` 而不是固定高度:弹层现在有明确高度
|
||
* (`height('60%')`),剩下多少空间它就占多少 —— 这样键盘顶上来时
|
||
* 它**自己会缩短**,把「取消 / 转发」按钮留在屏幕内。
|
||
* 固定 `height(70)` 在键盘弹出后会把按钮挤出视口(第一版就是这个症状)。
|
||
*/
|
||
TextArea({
|
||
placeholder: '转发说明(可选,置于引用原文之前;原文将以引用块附在下方)'
|
||
})
|
||
.width('100%').layoutWeight(1).fontSize(13)
|
||
.margin({ top: 8 })
|
||
.onChange((v: string) => { this.forwardComment = v; })
|
||
|
||
Row() {
|
||
Button('取消')
|
||
.width(80).height(36)
|
||
.backgroundColor(Theme.surfaceMuted)
|
||
.fontColor(Theme.textMuted)
|
||
.fontSize(13)
|
||
.onClick(() => { this.showForwardBox = false; })
|
||
|
||
Blank()
|
||
|
||
Button(this.sending ? '转发中…' : '转发')
|
||
.width(80).height(36)
|
||
.backgroundColor(Theme.accent)
|
||
.fontSize(13)
|
||
/* 收件人为空时不可点 —— WebUI 同口径(`disabled={busy || !to.trim()}`) */
|
||
.enabled(!this.sending && this.forwardTo.length > 0)
|
||
.onClick(() => { this.doForward(); })
|
||
}
|
||
.width('100%')
|
||
.margin({ top: 12 })
|
||
}
|
||
.width('100%')
|
||
/*
|
||
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):
|
||
* 第一版这里**没有高度**,只有 `padding(16)` —— 于是弹层的高度
|
||
* 完全由内容决定,而内容一旦被键盘顶起来,`TextArea` 与
|
||
* 「取消 / 转发」两个按钮就**跑到键盘下面**去了(实测截图:
|
||
* 只看得见收件人输入框 + 键盘,「转发」按钮点不到)。
|
||
*
|
||
* ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`
|
||
* (整个布局上移),而要让"上移之后按钮仍在屏幕内",
|
||
* 弹层必须有**明确的高度**(内容才能在里面重新分配空间)。
|
||
*
|
||
* 与回复框对齐:那里写的是 `height('60%')`,一直没出问题 ——
|
||
* 两个弹层本来就应该同构(同一套交互)。
|
||
*/
|
||
.height('60%')
|
||
.padding(16)
|
||
.backgroundColor(Theme.surface)
|
||
.borderRadius({ topLeft: 12, topRight: 12 })
|
||
/* 同回复框:`rise-in` 挂在**弹层本身**(理由见回复框那段注释) */
|
||
.transition(Theme.paneRiseIn())
|
||
}
|
||
.width('100%').height('100%')
|
||
.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%')
|
||
}
|
||
|
||
/**
|
||
* 接受改名建议 —— 走 `PUT /sessions/{id}/alias`。
|
||
*
|
||
* ★ 与"人手改别名"**同一个端点**(WebUI 也是这么做的):服务端特意不另开,
|
||
* 因为那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
|
||
*
|
||
* ★ 409(别名被占用)要让用户看到 —— 不能默默失败(WebUI 同口径)。
|
||
*/
|
||
async doAcceptRename(): Promise<void> {
|
||
const proposal: RenameProposal | null = this.renameProposal;
|
||
const sessionId: string | null = this.sessionId;
|
||
if (proposal === null || sessionId === null || this.renameBusy) {
|
||
return;
|
||
}
|
||
this.renameBusy = true;
|
||
try {
|
||
const c: ApiClient | null = this.client;
|
||
if (c === null) {
|
||
return;
|
||
}
|
||
await new SessionApi(c).acceptRename(sessionId, proposal.alias);
|
||
/* 成功后本地的建议条消失(服务端也已记录新别名) */
|
||
this.renameProposal = null;
|
||
this.getUIContext().getPromptAction().showToast({ message: '会话别名已改' });
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
/*
|
||
* 别名冲突(409)是最常见的失败,服务端的文案已是一句能照着改的话 ——
|
||
* 原样透出,不自己编一句(编的那句会丢掉"换一个名字"这个提示)。
|
||
*/
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: ae.message.length > 0 ? ae.message : '改名失败'
|
||
});
|
||
} finally {
|
||
this.renameBusy = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 驳回改名建议 —— `POST /sessions/{id}/rename-proposal/dismiss`。
|
||
*
|
||
* 服务端记下被驳回的别名,**不再反复弹同一个**(否则每次打开会话都要再点一次)。
|
||
* 所以成功后本地也把它清掉。
|
||
*/
|
||
async doDismissRename(): Promise<void> {
|
||
const sessionId: string | null = this.sessionId;
|
||
if (sessionId === null || this.renameBusy) {
|
||
return;
|
||
}
|
||
this.renameBusy = true;
|
||
try {
|
||
const c: ApiClient | null = this.client;
|
||
if (c === null) {
|
||
return;
|
||
}
|
||
await new SessionApi(c).dismissRename(sessionId);
|
||
this.renameProposal = null;
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: ae.message.length > 0 ? ae.message : '操作失败'
|
||
});
|
||
} finally {
|
||
this.renameBusy = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 转发:`POST /me/mail/{id}/forward`(服务端 `forward.go`)。
|
||
*
|
||
* 字段与 WebUI `ForwardBar.submit()` 逐条对齐:`to` / `cc` / `comment`
|
||
* (`subject` 留空让服务端自动加 `Fwd: ` 前缀 —— 它处理了"Fwd: Fwd:" 无限叠加)。
|
||
*
|
||
* ★ 成功后**不刷新列表**:WebUI 那里刷了 inbox/sent/sessions/contacts 四份,
|
||
* 因为它是长驻的 SPA、靠 store 驱动。鸿蒙这一页是 push 出来的详情页,
|
||
* 返回时会重新挂载列表页(`aboutToAppear` 重新拉)——
|
||
* 在这里刷是白拉一次(而且用户还看不到,因为列表在下一层)。
|
||
*/
|
||
async doForward(): Promise<void> {
|
||
const m: MailApi | null = this.mailApi;
|
||
if (m === null || this.sending || this.forwardTo.length === 0) {
|
||
return;
|
||
}
|
||
this.sending = true;
|
||
try {
|
||
const req: ForwardMailRequest = new ForwardMailRequest();
|
||
req.to = this.forwardTo;
|
||
req.cc = this.forwardCc;
|
||
req.comment = this.forwardComment;
|
||
/* subject 留空 ⇒ 服务端自动加 `Fwd: ` 前缀(不在这里拼,避免叠成 Fwd: Fwd:) */
|
||
await m.forward(this.mailId, req);
|
||
this.showForwardBox = false;
|
||
this.getUIContext().getPromptAction().showToast({ message: '已转发' });
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
this.getUIContext().getPromptAction().showToast({
|
||
message: ae.message.length > 0 ? ae.message : '转发失败'
|
||
});
|
||
} finally {
|
||
this.sending = false;
|
||
}
|
||
}
|
||
|
||
async doReply(): Promise<void> {
|
||
const m: MailApi | null = this.mailApi;
|
||
if (m === null || this.replyBody.length === 0) {
|
||
return;
|
||
}
|
||
this.sending = true;
|
||
try {
|
||
const req: SendMailRequest = new SendMailRequest();
|
||
/*
|
||
* ★ 用 `replyTargetAddress()`,**不是** `this.fromName + '@'`。
|
||
*
|
||
* 原先那个游离的 `@` 让 `name@` 被后端 `ParseAddress` 解析成
|
||
* 「有 path、无 session」⇒ 落到该 Agent 的**默认会话**,而不是用户
|
||
* 正在看的这条线索 —— 回复会跑到另一条任务里去。
|
||
* 正确形式是 `name@path.session`(人则只有名字),会话位必须带上。
|
||
*/
|
||
req.to = this.replyTargetAddress();
|
||
req.subject = 'Re: ' + this.subject;
|
||
req.body = this.replyBody;
|
||
req.reply_to = this.mailId;
|
||
await m.send(req);
|
||
this.showReplyBox = false;
|
||
this.replyBody = '';
|
||
this.getUIContext().getPromptAction().showToast({ message: '回复已发送' });
|
||
} catch (e) {
|
||
const ae = e as ApiError;
|
||
this.getUIContext().getPromptAction().showToast({ message: '发送失败: ' + ae.message });
|
||
} finally {
|
||
this.sending = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 旧 router 页面入口:与 Navigation 目标页共用同一份 MailDetailView。 */
|
||
@Entry
|
||
@Component
|
||
struct MailDetailPage {
|
||
/**
|
||
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
|
||
*
|
||
* ★ 避让加在**这个 `@Entry` 包装层**,**不在** `MailDetailView` 里面:
|
||
* 同一个 `MailDetailView` 还被 `MainPage` 的 `Navigation` 内嵌复用
|
||
* (`MailDetailDestination`),而 `MainPage` 已经给内容层加过避让了 ——
|
||
* 加在里面就变成**让两次**(39vp 变 78vp,白白多一条空档)。
|
||
* 这类"同一个组件两种入口"的坑与 `harmony-nav` 里那条"悬浮加号要放在
|
||
* Navigation 内部、不能当它的兄弟"同源:**让位的量取决于它被挂在哪一层**。
|
||
*/
|
||
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
|
||
|
||
build() {
|
||
Column() {
|
||
MailDetailView()
|
||
}
|
||
.width('100%').height('100%')
|
||
.padding({ top: topInset(this.windowInsets) })
|
||
.backgroundColor(Theme.surface)
|
||
}
|
||
}
|