跨端: 邮件转发上线(详情页缺的入口)+ 修两个真 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` 那笔同性质)。
This commit is contained in:
2026-09-19 17:00:44 +08:00
parent 63649031d8
commit b7c5d8b1e7
5 changed files with 334 additions and 13 deletions

View File

@ -555,7 +555,14 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住逻辑与接线)');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用并等到它到前台');
await new Promise((r) => setTimeout(r, 2000));
/*
* ★★ 先回主界面:前面跑过的设备判据可能把前台留在 **push 出去的页面**上
* (详情页 / 管理页 / 写信页 —— 它们没有侧栏)。实测:设备停在邮件详情页时
* 本判据跑了 201 秒才失败,报"要能进「我的」页"—— 而那是**判据间干扰**,
* 不是功能坏了(`backToMain` 的注释里记着同一个根因)。
*/
assert.ok(await D.backToMain(hdc), '要能回到主界面(侧栏/底栏可见)');
await new Promise((r) => setTimeout(r, 1500));
const dims = () => {
let w = 0;
@ -725,3 +732,97 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
'★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' +
`只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`);
});
test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => {
/*
* ★★ 2026-09-19 真 bug,设备实测撞出来的:
*
* 详情页右下角有两个动作球(转发 + 回复)。第一版把它们**各自**写在
* `Stack({ alignContent: Alignment.BottomEnd })` 里、各带一个 margin ——
* 于是它们**几乎完全重叠**。实测 dump 的 bounds(密度 2.875):
*
* 转发 [2984,2010][3122,2148]
* 回复 [2949,1998][3110,2159]
* 重叠区 x ∈ [2984, 3110]
*
* 屏幕上只看得到一个球,**转发入口等于不存在**。
* 根因是 ArkUI 的 `Stack.alignContent` 把每个子元素都摆到**同一个角**,
* margin 只是各自微调 —— 想并排就得用**一个容器**把子元素排起来。
*
* 判据形状:找"同一容器下的多个圆形 Button,各自只带 margin"这种写法。
* 正确写法是外面包一层 `Row({ space })`。
*/
const src = code(join(ETS, 'pages/MailDetailPage.ets'));
/*
* 找 `Stack({ alignContent: ... })` 块里**直接**出现的两个以上圆形按钮
* (`.borderRadius(N)` 且 N 使它是圆:宽高等于 2N)。
* 简化判定:在同一个 Stack 块内出现 ≥2 处 `.borderRadius(` + 各自 `.margin(`
* 而没有包在 Row 里。
*/
const stackBlocks = [...src.matchAll(/Stack\(\{[^}]*alignContent[^}]*\}\)\s*\{([\s\S]*?)\n\s{8}\}/g)];
const bad = [];
for (const m of stackBlocks) {
const body = m[1];
const circles = (body.match(/\.borderRadius\(\d+\)/g) ?? []).length;
if (circles >= 2 && !/Row\(\{\s*space:/.test(body)) {
const line = src.slice(0, m.index).split('\n').length;
bad.push(`MailDetailPage.ets:${line}(Stack 里有 ${circles} 个圆角元素且没有 Row 包住)`);
}
}
assert.deepEqual(bad, [],
'★ `Stack.alignContent` 会把**每个**子元素都摆到同一个角 —— ' +
'多个圆形按钮各自带 margin 时会**重叠**(实测:屏幕上只看得到一个球)。\n' +
`命中:\n ${bad.join('\n ')}\n` +
'正确写法:外面包一层 `Row({ space: N })`,由 Row 负责把它们排开、' +
'Row 自己带 margin 到角落。');
});
test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被顶出屏幕)', () => {
/*
* ★★ 2026-09-19 真 bug(与上面那条同一次实测):
*
* 详情页的转发弹层第一版**没有高度**(只有 `padding(16)`)⇒ 高度完全由内容决定。
* 而 ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`(整体上移)——
* 上移之后,弹层里的 `TextArea` 与「取消 / 转发」按钮**跑到键盘下面**去了。
* 实测截图:只看得见收件人输入框 + 键盘,「转发」按钮**点不到**。
*
* 修法两半(缺一不可):
* ① 弹层要有**明确高度**(与回复弹层一致,`height('60%')`)——
* 内容才能在里面重新分配空间;
* ② 会撑高的那个子元素(说明框)要用 `layoutWeight(1)` 而不是固定高度 ——
* 键盘顶上来时它自己缩短,把按钮留在屏内。
*/
const src = code(join(ETS, 'pages/MailDetailPage.ets'));
/* 找所有"底部弹层"(`borderRadius({ topLeft: ... })` 是这类弹层的标志) */
const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)];
assert.ok(sheets.length >= 2,
`详情页应有多个底部弹层(回复 / 转发)—— 实际找到 ${sheets.length} 个`);
/*
* 逐个弹层:从 borderRadius 往回找它的高度声明。
* 在 `.borderRadius({ topLeft` 之前的一段里找 `.height(`。
*/
const noHeight = [];
for (const m of sheets) {
const before = src.slice(Math.max(0, m.index - 1200), m.index);
/* 最近的 .height(...) 要在这一段里(弹层的修饰符链上) */
if (!/\.height\(('100%'|\d+|'[0-9]+%')\)/.test(before)) {
const line = src.slice(0, m.index).split('\n').length;
noHeight.push(`MailDetailPage.ets:${line}`);
}
}
assert.deepEqual(noHeight, [],
'★ 底部弹层必须有明确高度 —— 没有高度时它的尺寸由内容决定,' +
'而键盘弹出(默认 `KeyboardAvoidMode.OFFSET`)会把内容整体上移,' +
'**「取消 / 转发」按钮会被顶到键盘下面**(实测截图:按钮点不到)。\n' +
`缺高度的弹层:\n ${noHeight.join('\n ')}`);
/* ② 说明框用 layoutWeight 而不是固定高度(否则它撑住高度、按钮仍被挤出) */
const ta = /TextArea\(\{[\s\S]{0,200}?转发说明[\s\S]{0,400}?\n\s*\}\)([\s\S]{0,300}?)\.onChange/.exec(src);
assert.ok(ta, '要能找到转发弹层的说明框(TextArea)');
assert.match(ta[1], /\.layoutWeight\(1\)/,
'★ 转发说明框要用 `layoutWeight(1)`(不是固定 `height(N)`)—— ' +
'弹层高度固定后,只有它会伸缩才能把「取消 / 转发」按钮留在键盘之上');
});

View File

@ -115,7 +115,7 @@ const SUITE = [
['test/criteria-hygiene.test.mjs', [], 6],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28],
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],

View File

@ -6,7 +6,7 @@
*/
import { ApiClient } from './ApiClient';
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse } from '../model/Models';
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest} from '../model/Models';
/** 收件箱响应 */
export class InboxResponse {
@ -187,8 +187,19 @@ export class MailApi {
return this.client.post<SendMailResponse>('/me/mail/send', req);
}
/** 转发 */
async forward(mailId: string, req: SendMailRequest): Promise<SendMailResponse> {
/**
* 转发。
*
* ★★ 参数类型是 **`ForwardMailRequest`**(不是 `SendMailRequest`)——
* 两者字段集不同:服务端 `forwardRequest` 没有 `body` / `reply_to` /
* `attachment_ids` / `max_rounds` / `permission_mode`,
* 而它用的 `Decode()` 是 `DisallowUnknownFields()` ⇒ 多带字段直接 400。
*
* 这个方法**原先的签名写的就是 `SendMailRequest`** —— 一直没被发现,
* 因为**从来没有调用方**(详情页没有转发入口)。审计补上入口时才暴露出来:
* "写好了但没人用"的代码,连它自己的类型对不对都没人验过。
*/
async forward(mailId: string, req: ForwardMailRequest): Promise<SendMailResponse> {
return this.client.post<SendMailResponse>('/me/mail/' + mailId + '/forward', req);
}

View File

@ -255,6 +255,31 @@ export class SendMailRequest {
permission_mode: string = '';
}
/**
* 转发请求体(`POST /me/mail/{id}/forward`)。
*
* ★★ 为什么**不**复用 `SendMailRequest`(这是我刚踩过的那个坑的第二次):
* 服务端 `forwardRequest`(`forward.go:23`)只认五个字段 ——
* `to` / `cc` / `comment` / `subject` / `session_alias`,
* **没有** `body`、`reply_to`、`attachment_ids`、`max_rounds`、`permission_mode`。
* 而服务端的 `Decode()` 是 `DisallowUnknownFields()`(严格)⇒
* 多带一个字段就 **400**。
*
* 同一个错误我今天在 `AppearancePayload` 上刚犯过一次(拿响应类型当请求体),
* 表现也一样隐蔽:本地状态立刻变、看起来成功了,只有看 HTTP 状态码才发现 400。
* ⇒ 请求体一个端点一个类型,**不复用**。
*/
export class ForwardMailRequest {
to: string = '';
cc: string = '';
/** 转发者附加的说明,置于引用原文之前 */
comment: string = '';
/** 留空 ⇒ 服务端自动加 `Fwd: ` 前缀(它处理了 "Fwd: Fwd:" 无限叠加) */
subject: string = '';
/** 仅在目标地址以 .new 结尾时生效 */
session_alias: string = '';
}
/** 发信响应(含预算) */
export class SendMailResult {
mail_id: string = '';

View File

@ -9,7 +9,7 @@ import { Theme } from '../common/Theme';
import { Markdown } from '@luvi/lv-markdown-in';
import { MailApi } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { MailDetail, SendMailRequest, Address } from '../model/Models';
import { MailDetail, SendMailRequest, ForwardMailRequest, Address } from '../model/Models';
import { MailDetailParams } from '../model/RouteParams';
import { AmIcon } from '../common/Icons';
import { permissionLabel } from '../model/MailGrouping';
@ -94,6 +94,19 @@ export struct MailDetailView {
@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;
/**
@ -460,14 +473,50 @@ export struct MailDetailView {
* 实测 1280×800 下回复框占 31%;输入框不是阅读时每刻都要用的东西,
* 就该按需展开。球同时与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。
*/
Button() {
AmIcon({ iconName: 'chatBubble', iconSize: 24, iconColor: Theme.accentFg })
/*
* 两个动作球:转发(次要)+ 回复(主要)。
*
* ★★ 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; })
}
.width(56).height(56)
.borderRadius(28)
.backgroundColor(Theme.accent)
.margin({ right: 16, bottom: this.navReserve + 16 })
.onClick(() => { this.showReplyBox = true; })
.alignItems(VerticalAlign.Bottom)
/* 让出底部悬浮条;右边距与原来的回复球一致(球没变位置,只是多了左边一个) */
.margin({ right: 16, bottom: this.navReserve + 16 })
}
.width('100%').layoutWeight(1)
@ -530,12 +579,147 @@ export struct MailDetailView {
.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) {