/* * 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 } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { MailDetail, SendMailRequest, ForwardMailRequest, Address } from '../model/Models'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; import { permissionLabel } from '../model/MailGrouping'; import { LIST_FADE_LENGTH } 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 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`。 */ @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; 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.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; 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; } catch (e) { const ae = e as ApiError; this.error = ae.code === 0 ? ae.message : '加载失败'; } finally { this.loading = false; } } 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(); } /** * 发件方在这条会话里的**完整地址**(`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.textSubtle) .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.textSubtle) .width(36).flexShrink(0) if (this.switchingPerm) { LoadingProgress().width(16).height(16) } else { ForEach(['plan', 'workspace', 'full'], (mode: string) => { Text(permissionLabel(mode)) .fontSize(11) .fontColor(this.permissionMode === mode ? Theme.surface : 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%') } build() { Column() { /* * ── 可折叠头部(与 WebUI `CollapsibleHeader` 同构)── * * 收起态只留一行:返回键 + 标题 + 必须常驻的状态点(未读 / 权限请求)+ 展开箭头。 * 点这一行展开收发件人、时间、抄送、档位与操作行。 * 返回键**不能**套在展开点击里(button 嵌 button)—— 与 WebUI 同一取舍。 */ Column() { Row() { Text('‹') .fontSize(24).fontColor(Theme.accent) .width(36).height(36) .textAlign(TextAlign.Center) .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.accent) .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.warnFg) .backgroundColor(Theme.warnBg).borderRadius(4) .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .margin({ left: 6 }) } AmIcon({ iconName: 'chevronRight', iconSize: 16, iconColor: Theme.textSubtle }) .rotate({ angle: this.headerOpen ? 90 : 0 }) .margin({ left: 6 }) } .layoutWeight(1) .onClick(() => { this.headerOpen = !this.headerOpen; }) } .width('100%') if (this.headerOpen) { Column() { this.MetaRow('发件', this.fromAddress()) this.MetaRow('收件', this.toAddress()) if (this.ccList.length > 0) { this.MetaRow('抄送', this.ccText()) } this.MetaRow('时间', localDateTime(this.createdAt)) this.PermissionRow() } .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.textSubtle).margin({ top: 8 }) } .width('100%').layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.error.length > 0) { Column() { Text(this.error).fontSize(14).fontColor(Theme.danger) Button('重试').margin({ top: 12 }).onClick(() => { this.loadMail(this.mailId); }) } .width('100%').layoutWeight(1) .justifyContent(FlexAlign.Center) } else { Stack({ alignContent: Alignment.BottomEnd }) { Scroll() { Column() { /* * 元信息**不在正文区**了:它已经上移到可折叠头部(用户展开才显示)。 * 这里只留正文:主题 + 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 }) /* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */ 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.accent }) } .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.accent : 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) } /** * 转发:`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) } }