/* * 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 { 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 { 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 { 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 { 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 { 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 { 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` 的 ``) */ @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 { 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 { 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 是 *
…正文…
* ← 紧跟正文之后 * 所以这里排在 `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 { 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 { 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 { 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 { 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) } }