/* * 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 ? : ...` —— * `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 { 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 { 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 { 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` 逐项对齐: * * 即**同一个右尖括号图标,展开时转 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`: * * * * {别名} * * {source==='platform' && 平台} * {source==='new' && 新建会话} * {unread>0 && {unread}} * * {title && source!=='new' && {title}} * * * ★ 四个要点我第一版**全漏了**,逐个补: * ① 别名 `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) } }
{title}