用户 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`,不含问号。
811 lines
35 KiB
Plaintext
811 lines
35 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, 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)
|
||
}
|
||
}
|