Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/ComposePage.ets
JianFeeeee 20fc8a5800 跨端: 修三个真崩溃/失败 —— @BuilderParam 丢 this、发送后退错页、漏校验 body
用户 2026-09-21:「点击发送邮件直接闪退,点击授权也直接闪退,所有功能全部不可用」。
三个都是**真 bug**,逐个拿到证据后修的(不是猜的)。

## ① 点「授权」必崩:`@BuilderParam` 把 `this` 换掉了

崩溃日志(`jscrash-…-20260921150832132.log`)给出的栈:

    Reason: TypeError
    Error message: Cannot read property length of undefined
    at anonymous entry (MainPage.ets:1458:23)       ← this.requests.length
    at … Surface.ets:717:7                          ← AppHeader 里 this.trailing()

`MainPage.ets:1458` 是 `if (this.requests.length > 0)`,
而它住在 `PendingTrailing()` 这个 `@Builder` 里 —— **传给 `AppHeader` 的
`@BuilderParam` 之后,它执行时的 `this` 变成了 `AppHeader`**,
而 `AppHeader` 上当然没有 `requests` ⇒ `undefined.length` ⇒ 崩。

★ 这是 ArkUI 的老坑:`@BuilderParam` 是**按值传递一个函数**,
  调用方的 `this` 不会跟着过去。全仓**4 处**都踩了(`MainPage` 的
  `SentCountTrailing`/`PendingTrailing`、`AdminUsersPage`/`SettingsPage`
  的 `HeaderTrailing`)—— 它们各自读 `this.loaded`/`this.load()`。

修法:改成**尾随闭包**(`AppHeader({...}) { this.XxxTrailing() }`),
闭包捕获的是**定义处**的 `this`(本组件的),而不是 AppHeader 的。

★ 为什么判据没抓到:那 4 处此前都只是"静态源码里有这个 builder",
  而崩溃只发生在**运行时的 `this` 绑定**上 —— 形态判据看不见绑定。
  这一条只能靠设备实测(我这次是靠真机崩溃日志)。

## ② 发送成功后"闪退":其实是退错了页

`ComposePage.doSend()` 成功分支里是**无条件** `router.back()`。

而内嵌时(宽屏右栏 / 窄屏 `Navigation` 覆盖)写信只是 `MainPage` 的一个
**右栏状态** —— `router.back()` 退掉的是**整个 MainPage**,用户看到的就是
"发送之后 App 没了"(报成闪退)。

★ 同一个文件里,顶栏「取消」键(上面几十行)**早就写对了**:

    if (this.embedded) { this.onBack(); return; }
    this.getUIContext().getRouter().back();

我加 `doSend` 时没照着抄。`MailDetailView.goBack()` 也是这个正确形状 ——
**只有 `doSend` 是那个异类**。已改成与取消键同一判据。

## ③ 发送真的失败:校验漏了 `body`,且没 trim

日志里 `→ POST …/mail/send` 发出去了,但服务端 400。
直接打服务端复现:

    curl -d '{"to":"pi@root.new","subject":"t","body":""}'
    → {"error":"Missing to, subject, or body"}

而 WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**:
    to.trim() !== '' && subject.trim() !== '' && body.trim() !== '' && …
鸿蒙这边只校验了 `to` 与 `subject` —— **漏了 `body`**。

⇒ 用户在"正文本来就是可选的"观感下不填正文,请求照样发出去、被拒。

同时补 `trim()`:WebUI 发的是 `to.trim()` / `subject.trim()`,
而 `pi@root.new ` 与 `pi@root.new` 在服务端是**两条不同地址**。

## 设备验证(改前 → 改后)

· 点「授权」:崩(进程消失,新增 jscrash) → **进程存活,页面正常渲染,
                                待决策徽标 "4" 正确显示**(证明 `this.requests` 绑定对了)
· 发送邮件:POST 发出但服务端 400,且"闪退" → **回到收件箱,
                              发件箱里 `realtest` 已落库**(服务端实测 9 封)

## 另修:候选补全的菜单按 WebUI 补齐四件

用户:「收件人填充能力完全不可用,根本没有与 webui 对齐」。
实测后确认功能是通的(`pi` → `pi@` → 路径 → 会话 → 完整地址,
三段链逐段验过),但**行内渲染漏了 WebUI 的四个要素**(`AddressInput.tsx:186-231`):

  ① 别名 `font-mono`(地址类文本全仓等宽)
  ② 标题在**第二行**(原来挤在右边同一行)
  ③ `source` 三态视觉:`platform` 蓝胶囊 / `new` 灰字 / `mail` 无标
  ④ `unread > 0` 红徽标(服务端 `SessionCandidate.Unread`,带 `omitempty`)

`AddressSuggestion` 顺带补 `unread` 字段并守住 `omitempty`
(缺键时裸 cast 是 `undefined`,不是类里的 `= 0` —— 与 `title` 同一个坑)。

★ 另外修掉一个我自己写错的参数:`suggestAddress` 原来把 `'?name=…'` 传给
  `ApiClient.get(path, query)`,而**问号是那个方法自己加的**
  ⇒ 会拼成 `??name=`。约定:`query` 只放 `k=v`,不含问号。
2026-09-21 15:21:47 +08:00

811 lines
35 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 鸿蒙客户端 — 写邮件页
* 收件人 / 主题 / 正文 / 附件选择 / 发送
* 路由参数可选: to, reply_to, session_alias
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { MailApi, AddressSuggestionResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SendMailRequest } from '../model/Models';
import { ComposeParams } from '../model/RouteParams';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { picker } from '@kit.CoreFileKit';
import { AmIcon } from '../common/Icons';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
import { PressEffectModifier } from '../common/Surface';
import { Motion } from '../common/Motion';
import { KeyCode } from '@kit.InputKit';
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, AddressParts, SuggestQuery } from '../model/AddressSuggest';
/**
* 写邮件窗格。
*
* ★★ 2026-09-20 拆成 `View` + `@Entry 包装`(用户:「写邮件 webui 的宽屏样式不是在
* 右侧打开吗」)。
*
* WebUI 宽屏下写信**不是新页面**,而是在**右栏**打开
* (`App.tsx:179`:`const main = composing ? <ComposePage/> : ...` ——
* `composing` 只决定 `main` 那一格渲染什么,侧栏与列表栏都还在)。
* 而鸿蒙原来无条件 `pushUrl('pages/ComposePage')` —— 一个盖住全屏的 `@Entry` 页,
* 左侧的列表栏整片消失。这是**结构性不符**,不是样式细节。
*
* 拆法与 `MailDetailView` / `MailDetailPage` 完全一致(那是既有先例):
* · `ComposeView` —— 真正的内容,`embedded` 时走 `onBack()` 而不是 `router.back()`
* · `ComposePage`(本文件末尾)—— `@Entry` 包装,负责**窗口避让**
*
* ★ 为什么避让留在 `@Entry` 包装层、**不在** `ComposeView` 里:
* 同一个 View 也被 `MainPage` 的 `NavDestination` 内嵌复用,
* 而 `MainPage` 已经给内容层加过避让了 —— 加在里面就**让两次**
* (与 `MailDetailView` 那里同一条理由,同一个坑)。
*/
@Component
export struct ComposeView {
/*
* 当前是否深色(`MainPage` 经 `AppStorage` 发布)—— 用来选品牌浅底的深浅变体。
*
* ★ 2026-09-19:深色主题下这些"浅蓝底"(未读行 / 选中项 / 分段选中)
* 在深色页面上刺眼。根因是 `Theme.accentSoft` 写死浅色
* (WebUI 靠 CSS 的 `.dark` 段反转发解决,ArkTS 静态常量没有那层机制)。
* 窗格自己算不出深浅色 ⇒ 从 AppStorage 读。
*/
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
/**
* 是否被 `MainPage` 内嵌在**右栏**(宽屏写信)。
*
* true ⇒ 不消费窗口避让(父层已加)、返回走 `onBack()`(弹栈,不是退页)
* false ⇒ 作为独立 `@Entry` 页(窄屏 pushUrl 上来),行为与原来完全一致
*/
@Prop embedded: boolean = false;
/** 内嵌时的返回回调(`pop` 当前 `NavDestination`);独立页时用不到 */
onBack: () => void = (): void => {};
/** 内嵌时由父层直接给的初值 —— 独立页时留空、改从 router 参数取 */
@Prop initialTo: string = '';
@Prop initialReplyTo: string = '';
@Prop initialSessionAlias: string = '';
@Prop initialAccountId: string = '';
/**
* 窗口避让区(`@Entry` 包装层写进 `AppStorage`)。
*
* 内嵌时**不读**它(父层已加过,读了会算两遍)—— 见顶栏那处的注释。
*/
@StorageProp(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State to: string = '';
@State subject: string = '';
@State body: string = '';
@State replyTo: string = '';
@State sessionAlias: string = '';
@State attachmentIds: string[] = [];
@State attachmentNames: string[] = [];
@State sending: boolean = false;
@State uploading: boolean = false;
@State status: string = '';
// 发信账号选择
@State accountList: AccountInfo[] = [];
@State selectedAccountId: string = '';
@State showAccountPicker: boolean = false;
/*
* ── 收件人候选补全(用户 2026-09-21:「上下键切换发信目标」「回车展开输入框」)──
*
* 与 WebUI `AddressInput.tsx` 同口径,规则本身在 `model/AddressSuggest.ts`
* (纯逻辑,已被 `cross-client-logic.test.mjs` 与 electron 逐例比对)。
* 这里只放**界面状态**。
*/
@State suggestItems: string[] = [];
@State suggestTitles: string[] = [];
/*
* 候选的 `source`(`mail` | `platform` | `new`)与未读数。
*
* ★ 对齐 WebUI `AddressInput.tsx:200-228`:这两者各有视觉
* (`platform` 蓝胶囊 / `new` 灰字 / `unread>0` 红徽标)。
* 服务端 `SessionCandidate` 里 `unread` 带 `omitempty` ⇒ 缺键要守。
*/
@State suggestSources: string[] = [];
@State suggestUnreads: number[] = [];
@State suggestActive: number = 0;
@State suggestOpen: boolean = false;
/** 输入防抖(WebUI 是 120ms;打字每字符都打接口会打断输入) */
private suggestTimer: number = -1;
/**
* 收件人输入变化 —— 防抖后拉候选。
*
* 与 WebUI `AddressInput.tsx:49-85` 同一流程:拆段 → 决定问哪一层 →
* 拉 → 按当前片段过滤(**下标同序**)。
*
* ★ 防抖 120ms 与 WebUI 同值:每个字符都打接口会打断输入(网络往返期间
* UI 线程虽不阻塞,但候选会跳)。
*/
private onToChanged(v: string): void {
this.to = v;
if (this.suggestTimer >= 0) {
clearTimeout(this.suggestTimer);
}
this.suggestTimer = setTimeout(() => {
this.fetchSuggestions();
}, 120);
}
private async fetchSuggestions(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const api: MailApi | null = this.createSelectedMailApi(ctx);
if (api === null) {
return;
}
const parts: AddressParts = parseParts(this.to);
const q: SuggestQuery = queryFor(parts);
try {
const res: AddressSuggestionResponse = await api.suggestAddress(q.name, q.path);
const all: string[] = res.suggestions ?? [];
const cands = res.candidates ?? [];
/*
* 取标题 —— `title` 带 `omitempty`,缺键时裸 cast 给的是 `undefined`
* (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。
*/
const titles: string[] = [];
const sources: string[] = [];
const unreads: number[] = [];
for (let i = 0; i < cands.length; i++) {
const c = cands[i];
const raw: string | undefined = c === undefined ? undefined : c.title;
titles.push(typeof raw === 'string' ? raw : '');
/*
* `source` 与 `unread` 同样带 `omitempty`
* (`platform_sessions.go:97/101`)⇒ 缺键时裸 cast 是 `undefined`。
* `Unread` 缺键时给 `undefined`,直接比较 `> 0` 在 ArkTS 里类型不过 ⇒ 守一道。
*/
const src: string | undefined = c === undefined ? undefined : c.source;
sources.push(typeof src === 'string' ? src : '');
const un: number | undefined = c === undefined ? undefined : c.unread;
unreads.push(typeof un === 'number' ? un : 0);
}
/* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
const keep: number[] = filterIndexes(all, titles, frag);
const items: string[] = [];
const keepTitles: string[] = [];
const keepSources: string[] = [];
const keepUnreads: number[] = [];
for (let i = 0; i < keep.length; i++) {
const k: number = keep[i];
items.push(all[k]);
keepTitles.push(titles[k] ?? '');
keepSources.push(sources[k] ?? '');
keepUnreads.push(unreads[k] ?? 0);
}
this.suggestItems = items;
this.suggestTitles = keepTitles;
this.suggestSources = keepSources;
this.suggestUnreads = keepUnreads;
this.suggestActive = 0;
this.suggestOpen = items.length > 0;
} catch {
/*
* 拉不到候选**不是错误**:静默收起,用户照常手打地址。
* ★ 这里刻意用**无绑定** catch:写 `catch (e)` 会让 `e` 是 `any`,
* 撞 `arkts-no-any-unknown`(工程里别处也是这个写法)。
*/
this.suggestItems = [];
this.suggestTitles = [];
this.suggestOpen = false;
}
}
/** 选中一个候选 —— 拼回地址;name/path 段选完**仍停在补全态**(继续下一段) */
private applySuggestion(choice: string): void {
const parts: AddressParts = parseParts(this.to);
const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name');
this.to = mergeCandidate(parts, kind, choice);
if (kind === 'session') {
this.suggestOpen = false;
this.suggestItems = [];
} else {
/* 还有下一段要选 ⇒ 立刻再拉一次(WebUI 同行为) */
this.fetchSuggestions();
}
}
/**
* 收件人输入框的键盘处理 —— **这就是用户要的那几条**:
* · ↑ / ↓ 切换候选(`nextActiveIndex` 负责循环)
* · Enter / Tab 选中当前候选
* · Esc 收起候选
*
* ★ 用 `onKeyEvent` 而不是 `keyboardShortcut`:后者是**全局组合键**
* (Ctrl+字母 / F 键),而这几条是**输入框内**的导航键 ——
* 它们只有在焦点在这个输入框里时才有意义。
* `keyboardShortcut` 是"无论焦点在哪都响应",用在这里会让
* Enter 在页面任何地方都去改收件人。
*/
private onToKey(e: KeyEvent): void {
if (e.type !== KeyType.Down) {
return;
}
if (!this.suggestOpen || this.suggestItems.length === 0) {
return;
}
if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) {
this.suggestActive = nextActiveIndex(this.suggestActive, this.suggestItems.length, 1);
} else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) {
this.suggestActive = nextActiveIndex(this.suggestActive, this.suggestItems.length, -1);
} else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) {
if (this.suggestActive >= 0 && this.suggestActive < this.suggestItems.length) {
this.applySuggestion(this.suggestItems[this.suggestActive]);
}
} else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) {
this.suggestOpen = false;
}
}
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
/*
* 参数来源按**入口**分岔:
* · 内嵌(宽屏右栏):父层用 `@Prop` 直接给初值 —— 因为内嵌时根本没有
* 走 `router`,`getRouter().getParams()` 拿到的是**上一次 push 的残留**,
* 那会把上一封信的收件人带进来(比空更坏)。
* · 独立页(窄屏):走 router 参数,与原来逐字相同。
*/
let routeAccountId: string = '';
if (this.embedded) {
routeAccountId = this.initialAccountId;
this.to = this.initialTo;
this.replyTo = this.initialReplyTo;
this.sessionAlias = this.initialSessionAlias;
} else {
const params = this.getUIContext().getRouter().getParams() as ComposeParams;
routeAccountId = params?.account_id ?? '';
this.to = params?.to ?? '';
this.replyTo = params?.reply_to ?? '';
this.sessionAlias = params?.session_alias ?? '';
}
const accountManager: AccountManager = AccountManager.getInstance(ctx);
accountManager.load().then(() => {
this.accountList = accountManager.getAccounts();
let selectedId: string = routeAccountId;
if (accountManager.getAccount(selectedId) === null) {
const active: AccountInfo | null = accountManager.getActiveAccount();
selectedId = active !== null ? active.id : '';
}
this.selectedAccountId = selectedId;
});
}
getSelectedAccount(): AccountInfo | null {
for (let i = 0; i < this.accountList.length; i++) {
if (this.accountList[i].id === this.selectedAccountId) {
return this.accountList[i];
}
}
return null;
}
createSelectedMailApi(ctx: Context): MailApi | null {
const account: AccountInfo | null = this.getSelectedAccount();
if (account === null) {
return null;
}
const accountClient: ApiClient = new ApiClient(ctx);
accountClient.setBase(account.server);
accountClient.setToken(account.token);
return new MailApi(accountClient);
}
/** 选择附件(通过文件选择器) */
async pickAttachment(): Promise<void> {
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined || this.uploading) {
return;
}
try {
const documentPicker = new picker.DocumentViewPicker(ctx);
const result: string[] = await documentPicker.select({
maxSelectNumber: 5,
fileSuffixFilters: ['*']
});
if (result.length === 0) {
return;
}
const mailApi: MailApi | null = this.createSelectedMailApi(ctx);
if (mailApi === null) {
this.getUIContext().getPromptAction().showToast({ message: '请先选择发信账号' });
return;
}
this.uploading = true;
this.status = '上传中…';
for (let i = 0; i < result.length; i++) {
const fileUri: string = result[i];
const nameStart: number = fileUri.lastIndexOf('/');
const name: string = nameStart >= 0 ? fileUri.substring(nameStart + 1) : fileUri;
try {
const attachmentId: string = await mailApi.uploadAttachment(fileUri, name);
this.attachmentIds.push(attachmentId);
this.attachmentNames.push(name);
hilog.info(0x0001, 'Compose', 'uploaded: %{public}s → %{public}s', name, attachmentId);
} catch (e) {
const apiError = e as ApiError;
this.getUIContext().getPromptAction().showToast({ message: '上传失败: ' + name + ' - ' + apiError.message });
}
}
this.status = this.attachmentIds.length + ' 个附件已上传';
} catch (e) {
this.status = '';
} finally {
this.uploading = false;
}
}
/** 移除附件 */
removeAttachment(index: number): void {
if (index >= 0 && index < this.attachmentIds.length) {
this.attachmentIds.splice(index, 1);
this.attachmentNames.splice(index, 1);
}
}
/** 发送邮件 */
async doSend(): Promise<void> {
if (this.sending) {
return;
}
/*
* ★★ 2026-09-21 修(用户:「点击发送邮件直接闪退」)。
*
* 真正的失败链有**两段**,两段都得修:
*
* ── ① 校验漏了一项,把服务端会拒的请求发出去了 ──
*
* WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**:
* roundsError === null && to.trim() !== '' && subject.trim() !== '' &&
* body.trim() !== '' && aliasError === null && !sending
*
* 我这里只有 `to` 与 `subject` 两项 —— **漏了 `body`**。
* 而服务端 `mail.go` 要求正文非空,实测:
* curl -d '{"to":"pi@root.new","subject":"t","body":""}'
* → {"error":"Missing to, subject, or body"}
*
* ⇒ 用户填了收件人和主题、**没填正文**(正文本来就是可选的观感)时,
* 请求真的发出去了、服务端 400、弹出"发送失败"。
*
* ── ② 更糟的是"失败之后退到哪"──
*
* 那段在下面 `doSend` 的成功分支里(原来是**无条件** `router.back()`,
* 而内嵌时那会退掉整个 MainPage)——已一并修。
*
* ★ 为什么两段要一起修:只修 ①,用户永远看不到 ②;
* 只修 ②,"发送失败"仍会在最正常的输入下出现。它们是一条链上的两环。
*/
if (this.to.trim().length === 0) {
this.getUIContext().getPromptAction().showToast({ message: '请填写收件人' });
return;
}
if (this.subject.trim().length === 0) {
this.getUIContext().getPromptAction().showToast({ message: '请填写主题' });
return;
}
if (this.body.trim().length === 0) {
this.getUIContext().getPromptAction().showToast({ message: '请填写正文' });
return;
}
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const mailApi: MailApi | null = this.createSelectedMailApi(ctx);
if (mailApi === null) {
this.getUIContext().getPromptAction().showToast({ message: '请先选择发信账号' });
return;
}
this.sending = true;
try {
const request: SendMailRequest = new SendMailRequest();
/*
* 逐个 `trim()` —— 对齐 WebUI `ComposePage.tsx:144`:
* api.sendMail(to.trim(), subject.trim(), body, …)
* 用户常手滑留一个尾空格,而不 trim 时那个地址会**服务端判存在性失败**
* (`pi@root.new ` 与 `pi@root.new` 是两条不同地址)。
*/
request.to = this.to.trim();
request.subject = this.subject.trim();
request.body = this.body;
if (this.replyTo.length > 0) {
request.reply_to = this.replyTo;
}
if (this.sessionAlias.length > 0) {
request.session_alias = this.sessionAlias;
}
if (this.attachmentIds.length > 0) {
request.attachment_ids = this.attachmentIds;
}
await mailApi.send(request);
this.getUIContext().getPromptAction().showToast({ message: '邮件已发送' });
/*
* ★★ 2026-09-21 修:这里原来是**无条件** `router.back()`。
*
* 而内嵌(宽屏右栏 / 窄屏 `Navigation` 覆盖)时我们只是 `MainPage` 的一个
* 右栏状态 —— `router.back()` 退掉的是**整个 MainPage**,不是写信这一层。
*
* 同一个文件里,顶栏那个「取消」键(上面几十行)**早就写对了**:
* if (this.embedded) { this.onBack(); return; }
* this.getUIContext().getRouter().back();
* 我加 `doSend` 时没照着抄,于是**发送成功之后退到了登录页/空白**
* (用户报的「点击发送邮件直接闪退」——看着像闪退,其实是退页)。
*
* ⇒ 改成与取消键**同一个判据**(`embedded` → 弹自己的栈)。
* 两处必须一致,否则"取消"和"发送成功"会走两条不同的退路。
*/
if (this.embedded) {
this.onBack();
} else {
this.getUIContext().getRouter().back();
}
} catch (e) {
const apiError = e as ApiError;
this.getUIContext().getPromptAction().showToast({ message: '发送失败: ' + apiError.message });
} finally {
this.sending = false;
}
}
/** 获取选中的账号显示名 */
getSelectedAccountName(): string {
const account: AccountInfo | null = this.getSelectedAccount();
return account !== null ? account.displayName : '选择账号';
}
build() {
Column() {
// 顶栏
Row() {
Text('取消')
.fontSize(15).fontColor(Theme.dangerFor())
.onClick(() => {
/*
* 内嵌时必须弹**自己的**栈,不能 `router.back()` ——
* 那会退掉整个 `MainPage`(写信只是它的一个右栏状态)。
* 与 `MailDetailView.goBack()` 同一条判断。
*/
if (this.embedded) {
this.onBack();
return;
}
this.getUIContext().getRouter().back();
})
Blank()
Text('写邮件').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
Button(this.sending ? '发送中…' : '发送')
.fontSize(13).height(32)
.backgroundColor(Theme.accent)
.enabled(!this.sending && !this.uploading)
.onClick(() => { this.doSend(); })
}
/*
* ★ 高度与 padding-top 一起加避让:只加 padding 会把 56 里切掉 39,
* 按钮被压扁;只加 height 内容还是贴着状态栏。
* 背景照常铺到 y=0(`backgroundColor` 画在 border box 上,含 padding 区),
* 所以黑边不会回来 —— 「背景铺满、内容让开」正是 `MainPage` 的同一口径。
* 取 0 时(未全屏)表达式的值与旧代码**逐字相同**:height=56、无 top。
*/
/*
* 内嵌时避让取 0:`MainPage` 的内容层已经让过状态栏了,
* 这里再让一次就是**让两次**(凭空多一条 39vp 空档)。
* 取 0 时表达式的值与"未全屏"那一支逐字相同。
*/
.width('100%').height(56 + (this.embedded ? 0 : topInset(this.windowInsets)))
.padding({
left: 12,
right: 12,
top: this.embedded ? 0 : topInset(this.windowInsets)
})
.backgroundColor(Theme.surface)
Divider().color(Theme.border)
// 发信账号选择(多账号时显示)
if (this.accountList.length > 1) {
Row() {
Text('发信账号').fontSize(13).fontColor(Theme.textSubtleFor()).width(72)
// 显示当前选中的账号名
Text(this.getSelectedAccountName())
.fontSize(13).fontColor(Theme.accentFor())
.layoutWeight(1)
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
/*
* 下拉指示 —— 与 WebUI `AccountSwitcher.tsx:84` 逐项对齐:
* <ChevronRightIcon className={`w-3 h-3 transition-transform
* ${open ? 'rotate-90' : ''}`} />
* 即**同一个右尖括号图标,展开时转 90°**(不是一个专门的"下箭头")。
* 原来这里写的是 `Text(' ▾')` —— 一个 Unicode 字符(本仓禁用当图标)。
*/
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() })
.rotate({ angle: this.showAccountPicker ? 90 : 0 })
/* 展开/收起箭头平滑旋转(WebUI `transition-transform` 的对应物) */
.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
.width('100%').height(40).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
// 账号选择下拉
if (this.showAccountPicker) {
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Text(acct.displayName)
.fontSize(13)
.fontColor(this.selectedAccountId === acct.id ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1)
if (this.selectedAccountId === acct.id) {
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() })
}
}
.width('100%').height(40).padding({ left: 40, right: 12 })
.backgroundColor(this.selectedAccountId === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.selectedAccountId = acct.id;
this.showAccountPicker = false;
})
/*
* 弹层入场(对齐 WebUI `@keyframes menu-in`:下移 4vp + 缩到 0.985 + 淡入)。
*
* ★ 只给**弹层**挂 —— WebUI 那条 `.animate-menu-in` 的注释把适用范围
* 钉得很窄("只给真正是弹层的东西"),因为它曾经挂着 `glass-control`,
* 于是每次切视图页面上**所有**按钮与输入框一起淡入位移(几十个元素同时动),
* 2026-09-15 被摘掉。别把这个放到常驻控件上。
*
* 挂在 `Row` 上(即 `ForEach` 的每一项):整张候选列表逐项轻落,
* 与 WebUI 里"整块列表一起动"略有差别 —— 但 ArkUI 的 `ForEach` 每项
* 是独立节点,逐项入场反而更接近"列表展开"的观感,且不会让整层不可交互。
*/
.transition(Theme.menuIn())
}, (acct: AccountInfo) => acct.id)
}
Divider().color(Theme.border)
}
// 收件人(带三段式补全;键盘 ↑↓ 切换、Enter/Tab 选中、Esc 收起)
Row() {
Text('收件人').fontSize(14).fontColor(Theme.textSubtleFor()).width(60)
TextInput({ placeholder: 'name@path.session', text: this.to })
.layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent)
.onChange((v: string) => { this.onToChanged(v); })
.onKeyEvent((e: KeyEvent) => { this.onToKey(e); })
.onBlur(() => { this.suggestOpen = false; })
}
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
/*
* 候选列表 —— 贴在收件人行**下方**(WebUI `AddressInput` 的绝对定位菜单)。
*
* ★ 为什么不用 `bindPopup`/`bindMenu`:那两者各有自己的焦点体系,
* 用户的按键会先被它们吃掉,"↑↓ 切换候选"就落不到 `onToKey` 上。
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
*/
if (this.suggestOpen && this.suggestItems.length > 0) {
Column() {
ForEach(this.suggestItems, (item: string, idx: number) => {
/*
* 一行候选的结构逐条对齐 WebUI `AddressInput.tsx:186-231`:
*
* <button class="px-2.5 py-1.5 {active ? bg-blue-50 : hover:bg-gray-50}">
* <div class="flex items-center gap-1.5">
* <span class="text-sm font-mono truncate">{别名}</span>
* <div class="flex-1" />
* {source==='platform' && <span class="bg-blue-100 text-blue-700">平台</span>}
* {source==='new' && <span class="text-gray-400">新建会话</span>}
* {unread>0 && <span class="bg-red-600 text-white">{unread}</span>}
* </div>
* {title && source!=='new' && <p class="text-3xs text-gray-400 mt-0.5">{title}</p>}
* </button>
*
* ★ 四个要点我第一版**全漏了**,逐个补:
* ① 别名 `font-mono` —— 它是 `name@path.session` 的一段,
* 等宽才看得清(WebUI 全仓地址类文本都是 mono);
* ② 标题在**第二行**(`mt-0.5` 单独一段),不是挤在右边同一行;
* ③ `source` 三种取值各有视觉:`platform` 蓝胶囊 / `new` 灰字 / 空(mail)无标;
* ④ `unread>0` 有红徽标(服务端 `SessionCandidate.Unread`,`omitempty`)。
*/
Column() {
Row() {
Text(item)
.fontSize(13)
/* 等宽:对齐 WebUI 的 `font-mono`(地址类文本全仓都是 mono) */
.fontFamily('monospace')
.fontColor(idx === this.suggestActive ? Theme.accentFor() : Theme.textPrimary)
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
if (idx < this.suggestSources.length && this.suggestSources[idx] === 'platform') {
Text('平台')
.fontSize(10).fontColor(Theme.accentFor())
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.borderRadius(4)
.backgroundColor(Theme.accentSoftFor(this.isDarkNow))
}
if (idx < this.suggestSources.length && this.suggestSources[idx] === 'new') {
Text('新建会话').fontSize(10).fontColor(Theme.textSubtleFor())
}
if (idx < this.suggestUnreads.length && this.suggestUnreads[idx] > 0) {
Text(this.suggestUnreads[idx].toString())
.fontSize(10).fontColor(Theme.accentFg)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.borderRadius(4)
.backgroundColor(Theme.danger)
}
}
.width('100%')
/* 标题第二行 —— `source==='new'` 不显示(它没有真标题) */
if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0
&& !(idx < this.suggestSources.length && this.suggestSources[idx] === 'new')) {
Text(this.suggestTitles[idx])
.fontSize(11).fontColor(Theme.textSubtleFor())
.width('100%')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
}
.width('100%')
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.backgroundColor(idx === this.suggestActive ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
.onClick(() => { this.applySuggestion(item); })
}, (item: string, idx: number) => item + '#' + idx.toString())
}
.width('100%')
.backgroundColor(Theme.surface)
.borderRadius(Theme.glassRadius)
}
Divider().color(Theme.border)
// 主题
Row() {
Text('主题').fontSize(14).fontColor(Theme.textSubtleFor()).width(60)
TextInput({ placeholder: '邮件主题', text: this.subject })
.layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent)
.onChange((v: string) => { this.subject = v; })
}
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
Divider().color(Theme.border)
// 正文
TextArea({ placeholder: '输入邮件正文…' })
.layoutWeight(1).width('100%')
.fontSize(14)
.backgroundColor(Theme.surface)
.onChange((v: string) => { this.body = v; })
// 附件区
if (this.attachmentNames.length > 0) {
Column() {
ForEach(this.attachmentNames, (name: string, idx: number) => {
Row() {
AmIcon({ iconName: 'paperclip', iconSize: 12, iconColor: Theme.textMuted })
Text(name)
.fontSize(12).fontColor(Theme.textPrimary)
.margin({ left: 6 })
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
AmIcon({ iconName: 'close', iconSize: 14, iconColor: Theme.dangerFor() })
.onClick(() => { this.removeAttachment(idx); })
}
.width('100%').height(32)
.padding({ left: 12, right: 12 })
.backgroundColor(Theme.pageBg)
}, (_name: string, idx: number) => idx.toString())
}
.width('100%')
}
// 底部工具栏
Row() {
Row() {
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.accentFor() })
Text('添加附件')
.fontSize(13).fontColor(Theme.accentFor()).margin({ left: 6 })
.onClick(() => { this.pickAttachment(); })
}
if (this.uploading) {
Text(this.status)
.fontSize(11).fontColor(Theme.textMuted)
.margin({ left: 12 })
} else if (this.status.length > 0) {
Text(this.status)
.fontSize(11).fontColor(Theme.approveFor())
.margin({ left: 12 })
}
}
.width('100%').height(44)
.padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
}
.width('100%').height('100%')
/*
* ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。
*
* 这里原来是 `backgroundColor(Theme.pageBg)` —— 一个**不透明**的页面底。
* 而 `ComposeView` 是**装在圆角窗格内部**的(`ComposeDestination` 的
* `NavDestination` / `MainPage` 内容列),实测它的边界
* `[30,142][978,1955]` 比外壳 `[28,140][980,1957]` **四周各小 2px**
* ⇒ 那块不透明的白在四个角上从外壳的圆角里**露出来**,形成直角白边。
*
* 像素实测(修前):y=1946 时圆角已收窄到 x=45,而 x=30..39 仍是纯白;
* y=1952 时 x=30..48 仍是纯白 —— 就是那圈"白框"。
*
* ── 为什么改成透明而不是把圆角加到这一层 ──
*
* 对齐 WebUI:它的 `.app-shell > *`(对应我们的**外壳**)才有
* `border-radius`,面板内部(`ComposePage` 自己)是 `bg-white` 的**直角块**——
* 因为在 WebUI 里那个圆角是**靠 `overflow`/`background-clip` 裁到子节点上的**。
* 我们这层的 `borderRadius` 同样**不裁 `backgroundColor`**,
* 给这层再加一个圆角只会多一道弧、白边照样在。
*
* 正确做法是让**外壳那层玻璃**显出来:这一层不铺底
* (它不是页面的"背景",只是窗格里的内容)。
*
* ★ `embedded` 时尤其必须透明:那时我们**就是**右栏面板本身,
* 再铺一层不透明底等于把外壳的玻璃盖掉(用户报的「玻璃不透明」同源)。
*/
.backgroundColor(this.embedded ? Color.Transparent : Theme.pageBg)
/*
* 整页入场(对齐 WebUI 写信页的 `rise-in`:4vp 上浮 + 淡入)。
*
* WebUI 实测(`ComposePage.tsx:174`):`fromRect ? '' : 'rise-in …'` ——
* 没有动画起点时(例如刷新后直接进写信页)才挂 `rise-in`;
* 有"从 FAB 长出来"的起点时改走另一条(两条都动 transform/opacity,同挂会打架)。
* 本仓没有"从 FAB 长出来"那条,所以直接挂就是它的"无起点"分支。
*/
.transition(Theme.paneRiseIn())
}
}
/**
* 独立页包装(**窄屏**走这条:`pushUrl('pages/ComposePage')`)。
*
* 只做一件事:消费窗口避让区。内容全部在 `ComposeView`。
*
* ★ 避让为什么加在**这一层**、不加在 `ComposeView` 里:
* 同一个 `ComposeView` 还被 `MainPage` 的 `NavDestination` **内嵌**在右栏复用
* (宽屏写信),而 `MainPage` 已经给内容层加过避让了 ——
* 加在里面就变成**让两次**(凭空多一条 39vp 空档)。
* 与 `MailDetailPage` / `MailDetailView` 是同一取舍、同一个坑。
*
* ★ `setWindowLayoutFullScreen(true)` 是**窗口级**的:一旦设上,这个窗口里
* **所有** `pushUrl` 推上来的页都从 y=0 开始画。所以推上来的页也必须消费它,
* 否则「取消/发送」会与时钟、wifi/电量图标重叠(2026-09-18 实测截图硬证)。
*/
@Entry
@Component
struct ComposePage {
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
build() {
Column() {
ComposeView()
}
.width('100%').height('100%')
.padding({ top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
}
}