Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
JianFeeeee 13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00

1142 lines
48 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, SessionBudget } from '../api/MailApi';
import { SessionApi } from '../api/SessionApi';
import { RenameProposal } from '../model/SessionRename';
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`。
*/
/*
* 会话改名建议(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<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;
/*
* ★★ 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<void> {
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<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%')
}
/**
* 往返预算行 —— 两态(对齐 WebUI `BudgetEditor`):
*
* 显示态:一个徽标 `已用/上限 来回`(不限时写「预算不限」;用尽时标红)
* 编辑态:输入框 + 保存 + 重置(点徽标进入)
*
* ★ 「用尽」的判定来自**服务端**的 `remaining`(不是自己拿 used 与 max 相减):
* 不限时服务端给 `remaining: -1`,而 `used >= max` 那个式子在这种情形下
* 会算出"已用尽"(`-1` 与 `0` 的比较)—— 两端都得用服务端的口径。
*/
@Builder
BudgetRow() {
Row() {
Text('预算')
.fontSize(11).fontColor(Theme.textSubtle)
.width(36).flexShrink(0)
if (this.budget === null) {
/* 还没读到就不显示 —— 不为一块可选信息留空壳 */
Text('—').fontSize(11).fontColor(Theme.textSubtle)
} 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.danger : 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<void> {
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<void> {
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() {
Column() {
/*
* ── 可折叠头部(与 WebUI `CollapsibleHeader` 同构)──
*
* 收起态只留一行:返回键 + 标题 + 必须常驻的状态点(未读 / 权限请求)+ 展开箭头。
* 点这一行展开收发件人、时间、抄送、档位与操作行。
* 返回键**不能**套在展开点击里(button 嵌 button)—— 与 WebUI 同一取舍。
*/
Column() {
Row() {
Text('‹')
.fontSize(24).fontColor(Theme.accentFor())
.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.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.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)
/*
* ★★ 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() {
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.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() {
/*
* 会话改名建议条(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 })
/* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */
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.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)
}
/**
* 接受改名建议 —— 走 `PUT /sessions/{id}/alias`。
*
* ★ 与"人手改别名"**同一个端点**(WebUI 也是这么做的):服务端特意不另开,
* 因为那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
*
* ★ 409(别名被占用)要让用户看到 —— 不能默默失败(WebUI 同口径)。
*/
async doAcceptRename(): Promise<void> {
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<void> {
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<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)
}
}