Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/ComposePage.ets
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

969 lines
43 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, splitEditing, normalizeCandidates, filterSets, AddressParts, CandidateSets, EditingSplit, 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 = '';
/*
* ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,比如抄送,转发等」)——
* 鸿蒙写信页原来**连抄送框都没有**,而 WebUI `ComposePage.tsx:277` 有一个,
* 且带完整补全。服务端 `SendMailRequest` 也一直有 `cc` 字段(`Models.ets`)。
* ⇒ 字段 + 补全都补上。
*/
@State cc: 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;
this.scheduleSuggest('to');
}
/*
* ★★ 2026-09-21 新增(用户:「webui 存在好几个自动填充位置,比如抄送,转发等」)——
*
* 原来补全只服务**收件人一个字段**(`fetchSuggestions()` 硬编码读 `this.to`)。
* WebUI 的 `AddressInput` 是个**可复用组件**:同一个补全逻辑往六个地方
* 一挂就行(收件人 / 抄送 ×写信 / 回复 / 转发 / 日历事件)。
*
* 这里用 `suggestField` 记住"当前在给哪个字段补全",
* 把那一套拉取/过滤/应用逻辑变成**字段无关**的。
*
* ★ 一个 `@State` 就够,不需要真做成可复用子组件:ArkUI 的
* `@Builder` 参数是**值传递**,把 `onChange` 回调穿进子组件时 `this`
* 会丢(本仓已撞过,见 `@BuilderParam` 那段纪律)。
* 字段标记法没有这个问题,代价是每个新字段多两行。
*/
private scheduleSuggest(field: string): void {
this.suggestField = field;
if (this.suggestTimer >= 0) {
clearTimeout(this.suggestTimer);
}
this.suggestTimer = setTimeout(() => {
this.fetchSuggestions();
}, 120);
}
/** 当前正在补全的字段名(`'to'` | `'cc'`),以及它是否允许多个地址 */
private suggestField: string = 'to';
/** 该字段是否允许多个地址 —— 抄送类为 true(`allowMultiple`) */
private suggestMulti(): boolean {
return this.suggestField === 'cc';
}
/** 读当前字段的整串值 */
private suggestValue(): string {
return this.suggestField === 'cc' ? this.cc : this.to;
}
/** 写回当前字段(多地址时拼在 `head` 之后,与 WebUI `apply` 同形) */
private writeSuggestValue(v: string): void {
if (this.suggestField === 'cc') {
this.cc = v;
} else {
this.to = v;
}
}
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;
}
/*
* ★ 多地址字段先切段、只拿**最后一段**去解析。
* 不切的话,`a@x, b@y` 的 `indexOf('@')` 会取到第一条的 `@`,
* 整串被当成一条地址 ⇒ 候选全是错的。
*/
const editing: string = splitEditing(this.suggestValue(), this.suggestMulti()).editing;
const parts: AddressParts = parseParts(editing);
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` 守一道再取。
*/
/*
* ★★ 2026-09-21 改:归一化 + 过滤抽到 `model/AddressSuggest.ts`
* (`normalizeCandidates` / `filterSets`)。
*
* 原来这 30 行内联在这里。而转发条、日历事件也要同一套 ⇒
* 内联三份必然漂移(一份记得守 `omitempty`、另一份忘了)。
* 抽出来后一套实现被三个调用点共用,并被 `cross-client-logic` 跑。
*/
const sets: CandidateSets = normalizeCandidates(
all,
cands.map((c) => c === undefined ? undefined : c.title),
cands.map((c) => c === undefined ? undefined : c.source),
cands.map((c) => c === undefined ? undefined : c.unread));
/* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
const keep: CandidateSets = filterSets(sets, frag);
this.suggestItems = keep.items;
this.suggestTitles = keep.titles;
this.suggestSources = keep.sources;
this.suggestUnreads = keep.unreads;
this.suggestActive = 0;
this.suggestOpen = keep.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 full: string = this.suggestValue();
const multi: boolean = this.suggestMulti();
const split: EditingSplit = splitEditing(full, multi);
const parts: AddressParts = parseParts(split.editing);
const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name');
const merged: string = mergeCandidate(parts, kind, choice);
/*
* 多地址:前缀(含分隔符)原样保留,只换最后一段 ——
* 与 WebUI `AddressInput.tsx:125` 的
* `onChange(allowMultiple ? head + (head ? ' ' : '') + next : next)`
* 同一个拼法(`head` 自己尾上已经带了逗号,再加一个空格)。
*/
this.writeSuggestValue(multi && split.head.length > 0 ? split.head + ' ' + merged : merged);
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();
/*
* ★★ 2026-09-21 新增:抄送也要 trim。
* 服务端 `SendMailRequest.CC` 一直是存在的,只是鸿蒙从来没传过 ——
* 不加这句,用户填了抄送也发不出去(静默丢字段)。
*/
request.cc = this.cc.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 : '选择账号';
}
/**
* 账号选择菜单(`bindMenu` 的内容)—— 原来内联在 `build()` 里的那块列表。
*
* ★★ 2026-09-21 新增:从内联 `if/ForEach` 改为官方菜单内容。
*
* ★ 逐项不再挂 `Theme.menuIn()`:那个过渡原本是为了掩盖"列表推进布局流
* 导致下方内容被顶下去"。现在菜单是系统浮层(不占布局),
* 出入场由系统菜单自己负责 —— 再挂一层会与系统动画叠在一起。
*
* ★ 保留逐项的高亮与勾选(选中态的语言不变);
* 只是把"怎么弹出来"交给了系统。
*/
@Builder
AccountMenu() {
Column() {
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(220).height(40).padding({ left: 12, right: 12 })
.backgroundColor(this.selectedAccountId === acct.id
? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.selectedAccountId = acct.id;
this.showAccountPicker = false;
})
}, (acct: AccountInfo) => acct.id)
}
.width(220)
.padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Start)
}
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 })
}
.width('100%').height(40).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
/*
* ★★ 2026-09-21 改用 `bindMenu(isShow, content)`(**API 11** 重载)。
*
* 第一版写的是 `bindMenu(... ? this.AccountMenu() : undefined, {onDisappear})`
* —— 那是 API 7 那个靠"内容为 undefined 就不弹"的重载。
* 改成 `isShow` 版后:
* · 显隐是**显式参数**(不再靠内容是不是 undefined 隐式表达);
* · `$$` 两向绑定让"用户点外部关闭"自动回写状态(不再靠 onDisappear 补)。
*
* ★ 与 `bindSheet($$this.showAddDialog, …)` 同一套写法 ——
* 两处弹层用一种形状,而不是两种。
*
* ★ 仍然与 `MainPage` 的账号筛选器一致(同一交互,不该两种形状)。
*/
.bindMenu($$this.showAccountPicker, this.AccountMenu())
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
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)
/*
* ★★ 2026-09-21 新增:**抄送**字段(用户:「webui 存在好几个自动填充位置,
* 比如抄送,转发等」)。
*
* 原来鸿蒙写信页**根本没有抄送框**,而:
* · WebUI 有(`ComposePage.tsx:277`,且带完整 `AddressInput` 补全);
* · 服务端 `SendMailRequest` 一直有 `cc` 字段(`Models.ets`);
* · 转发条里的抄送我们**反而做了**(`MailDetailPage.ets:1427`)。
* ⇒ 写信页缺这一项是单点遗漏,不是设计选择。
*
* 提示文案与 WebUI 逐字一致:`多个地址用逗号分隔`。
* 补全走同一个菜单(`suggestField='cc'` ⇒ 多地址模式)。
*/
Divider().color(Theme.border)
Row() {
Text('抄送').fontSize(14).fontColor(Theme.textSubtleFor()).width(60)
TextInput({ placeholder: '多个地址用逗号分隔', text: this.cc })
.layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent)
.onChange((v: string) => {
this.cc = v;
this.scheduleSuggest('cc');
})
.onKeyEvent((e: KeyEvent) => { this.onToKey(e); })
.onBlur(() => { this.suggestOpen = false; })
}
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
Divider().color(Theme.border)
/*
* 候选列表 —— 贴在收件人行**下方**(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)
/*
* ★★ 2026-09-21 补:**候选列表的入场过渡**(原来一个都没有 ⇒ 硬弹)。
*
* 这是 `Theme.menuIn()` 的注释里点名要覆盖的那一类("账号选择器、**下拉候选**"),
* 但它从来没有真正挂到候选列表上 —— 只有账号选择器用了。
*
* ── 为什么现在才暴露 ──
* 三个原本挂 `menuIn()` 的地方里,两个账号选择器今天换成了官方
* `bindMenu`(系统自带转场),`menuIn()` 于是只剩定义、没有调用点。
* 我去查"要不要删掉这个死方法"时,顺手拿它的**注释**对了一遍实际用法:
* 它写的是给"账号选择器、下拉候选",**候选那一半从来没兑现**。
*
* ── 为什么必须补(不是纯观感)──
* WebUI 的同一处在 `AddressInput.tsx:261` 挂着 `animate-menu-in`
* (`.animate-menu-in` 就是 `@keyframes menu-in`,与 `menuIn()` 逐值对齐)。
* 也就是说:**WebUI 的候选是动的、鸿蒙的是硬弹** —— 这是真实的跨端不一致,
* 不是"我们少做了个美化"。
*
* ★ 挂在这一整层 `Column` 上(整块列表一起动),与 WebUI 的
* `<div class="animate-menu-in">` 包整块一致。
* (历史上一度逐项挂 `menuIn()` 的是**账号选择器**,那时是为了掩盖
* "下拉推进布局流把列表顶下去";现在的候选是浮在内容之上的,
* 整块入场才是对的,别再逐项。)
*/
.transition(Theme.menuIn())
}
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();
/**
* **页面间转场**(用户:「加页面转场,因为是桌面应用了」)。
*
* 照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`):
* from { opacity: 0; transform: translateY(4px) }
* to { opacity: 1; transform: none }
* ⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。
*
* ★ 官方文档(`ts-page-transition-animation`)说明:
* 「当路由(router)进行切换时,可以通过在 `pageTransition` 函数中
* 自定义页面入场和页面退场的转场动效」,且
* 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。
*
* ⇒ 所以:**走 `router` 的本页用 `pageTransition`**;
* 而应用内的 Navigation 跳转(邮件详情 / 写信,都走 `pushPath`)
* 另有 `NavDestination` 的机制 —— 两套不混用。
*
* ★ 退场只做**淡出、不做位移**:两端都位移会看起来互相推挤。
*/
pageTransition() {
PageTransitionEnter({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise })
.opacity(0)
.translate({ y: Theme.riseInOffset })
PageTransitionExit({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise })
.opacity(0)
}
build() {
Column() {
ComposeView()
}
.width('100%').height('100%')
.padding({ top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
}
}