审计发现详情页缺三块功能之一 —— **转发**。服务端 `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` 那笔同性质)。
781 lines
32 KiB
Plaintext
781 lines
32 KiB
Plaintext
/*
|
||
* AgentMail 鸿蒙客户端 — 邮件详情页
|
||
* GET /mail/{id} 展示单封完整内容 + 回复按钮
|
||
* 路由参数:mail_id
|
||
*/
|
||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||
import { Theme } from '../common/Theme';
|
||
/* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */
|
||
import { Markdown } from '@luvi/lv-markdown-in';
|
||
import { MailApi } 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)
|
||
}
|
||
}
|