Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
JianFeeeee b7c5d8b1e7 跨端: 邮件转发上线(详情页缺的入口)+ 修两个真 bug(动作球重叠 / 键盘挡住按钮)
审计发现详情页缺三块功能之一 —— **转发**。服务端 `forward.go` 完整、
`MailApi.forward()` 也早就写好了,缺的只是这一页的入口。

## 一、转发(对齐 WebUI `ForwardBar`)

字段与占位文案逐条对齐:收件人 / 抄送(**可折叠**,默认收起)/ 说明 / 引用原文。
`subject` 留空让服务端自动加 `Fwd: ` 前缀(它处理了 "Fwd: Fwd:" 无限叠加)。

★ 请求体用了**专用类型** `ForwardMailRequest`,不复用 `SendMailRequest`:
服务端 `forwardRequest` 只认五个字段,而它的 `Decode()` 是 `DisallowUnknownFields()`
⇒ 多带一个(`body` / `reply_to` / `attachment_ids`…)就 **400**。
—— 这正是我今天在 `AppearancePayload` 上刚犯过的那个错,**端点一个类型一个请求体**。

★ 顺带发现:`MailApi.forward` 的签名**原本就写错了**(参数类型是 `SendMailRequest`)
—— 一直没被发现,因为**从来没有调用方**。"写好了但没人用"的代码,
连它自己的类型对不对都没人验过。

## 二、真 bug ①:两个动作球**几乎完全重叠**

转发球与回复球**各自**写在 `Stack({alignContent: BottomEnd})` 里、各带一个 margin。
实测 dump 的 bounds(密度 2.875):

    转发 [2984,2010][3122,2148]
    回复 [2949,1998][3110,2159]      ← 重叠区 x ∈ [2984,3110]

屏幕上只看得到一个球,**转发入口等于不存在**。
根因:`Stack.alignContent` 把**每个**子元素都摆到同一个角,margin 只是各自微调。
修法:用 `Row({ space: 12 })` 包住两个球、由 Row 带 margin 到角落。
**设备实测**(修后 dump):`[2776,2021][2914,2159]` 与 `[2949,1998][3110,2159]`,不重叠。

## 三、真 bug ②:键盘一弹,「转发」按钮就被顶出屏幕

转发弹层第一版**没有高度**(只有 `padding(16)`)⇒ 尺寸由内容决定。
而 ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`(整体上移)——
上移之后 `TextArea` 与「取消 / 转发」按钮**跑到键盘下面**,点不到。
实测截图:只看得见收件人输入框 + 键盘。

修法两半(缺一不可):
① 弹层给明确高度 `height('60%')`(与回复弹层一致,它一直没出问题);
② 说明框改 `layoutWeight(1)`(不是固定 `height(70)`)—— 键盘顶上来时它自己缩短,
   把按钮留在屏内。

**设备实测**(键盘弹出时 dump):`取消 [1196,2054][1426,2158]`、
`转发 [2880,2054][3110,2158]` 都在屏内(屏高 2232),且 `clickable=true`。

## 四、判据(这两条固化了上面两个形状)

`harmony-admin` 新增两条**静态形状**判据(它们抓的是写法,不需要设备):
1. **同一 `Stack` 里的多个圆形按钮必须被 `Row` 包住**(否则重叠);
2. **底部弹层必须有明确高度** + 会撑高的子元素用 `layoutWeight`
   (否则键盘一弹按钮就被顶出屏幕)。

★ 为什么用静态判据而不是设备判据:这两个 bug 的**形状**在源码里就看得见
(`Stack` + 各自 margin / 弹层缺 `.height`),而设备判据要摆出"键盘弹出"这个态,
成本高且不稳。静态判据在这里是**更快更准**的那一层。
(设备判据仍保留在别处,验"真的能打开、真的渲染出来"。)

## 五、验证

`run-all.mjs` → `files=32 ran=32 checks=509 pass=509 fail=0 skip=0
red=0 broken=0 unreported=0`(`harmony-admin` 28 → 30)。`hvigorw assembleHap` 成功。

**设备实测**:转发球与回复球分开显示(各自图标可见);
点转发球 → 转发框打开(「转发「…」」+「抄送」折叠开关 + 取消/转发);
键盘弹出后按钮仍在屏内可点。

**未验**:真发一封转发(收件人输入在自动化里不稳 ——
`uitest inputText` 是**追加**而非替换,且 `keyEvent Back` 会退出页面而不是收键盘。
这条留待真人操作窗口,与 `harmony-p4c-boundary-decls` 那笔同性质)。
2026-09-19 17:00:44 +08:00

781 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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<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;
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<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();
}
/**
* 发件方在这条会话里的**完整地址**(`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.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<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)
}
}