用户两项裁定落地(均为 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,产物待重构建)
969 lines
43 KiB
Plaintext
969 lines
43 KiB
Plaintext
/*
|
||
* 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)
|
||
}
|
||
}
|