diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets
index e4da969..ddb9c93 100644
--- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets
+++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets
@@ -14,9 +14,29 @@ import { picker } from '@kit.CoreFileKit';
import { AmIcon } from '../common/Icons';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
-@Entry
+/**
+ * 写邮件窗格。
+ *
+ * ★★ 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
-struct ComposePage {
+export struct ComposeView {
/*
* 当前是否深色(`MainPage` 经 `AppStorage` 发布)—— 用来选品牌浅底的深浅变体。
*
@@ -27,18 +47,30 @@ struct ComposePage {
*/
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
- @State to: string = '';
/**
- * 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
+ * 是否被 `MainPage` 内嵌在**右栏**(宽屏写信)。
*
- * ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
- * 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
- * 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
- * (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
- * "顶栏被状态栏压住",同属只做一半。
+ * true ⇒ 不消费窗口避让(父层已加)、返回走 `onBack()`(弹栈,不是退页)
+ * false ⇒ 作为独立 `@Entry` 页(窄屏 pushUrl 上来),行为与原来完全一致
*/
- @StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
+ @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 = '';
@@ -58,11 +90,26 @@ struct ComposePage {
if (ctx === undefined) {
return;
}
- const params = this.getUIContext().getRouter().getParams() as ComposeParams;
- const routeAccountId: string = params?.account_id ?? '';
- this.to = params?.to ?? '';
- this.replyTo = params?.reply_to ?? '';
- this.sessionAlias = params?.session_alias ?? '';
+ /*
+ * 参数来源按**入口**分岔:
+ * · 内嵌(宽屏右栏):父层用 `@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(() => {
@@ -210,7 +257,18 @@ struct ComposePage {
Row() {
Text('取消')
.fontSize(15).fontColor(Theme.dangerFor())
- .onClick(() => { this.getUIContext().getRouter().back(); })
+ .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()
@@ -227,8 +285,17 @@ struct ComposePage {
* 所以黑边不会回来 —— 「背景铺满、内容让开」正是 `MainPage` 的同一口径。
* 取 0 时(未全屏)表达式的值与旧代码**逐字相同**:height=56、无 top。
*/
- .width('100%').height(56 + topInset(this.windowInsets))
- .padding({ left: 12, right: 12, top: topInset(this.windowInsets) })
+ /*
+ * 内嵌时避让取 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)
@@ -374,4 +441,34 @@ struct ComposePage {
*/
.transition(Theme.paneRiseIn())
}
-}
\ No newline at end of file
+}
+
+/**
+ * 独立页包装(**窄屏**走这条:`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)
+ }
+}
diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets
index 3f9519b..2d7cfb9 100644
--- a/client/harmony/entry/src/main/ets/pages/MainPage.ets
+++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets
@@ -75,6 +75,7 @@ import { PushService, PushRoute } from '../api/PushService';
* 这条注释留在 **import 区**而不是跟着表走,因为要守的是"import 在最前"这个位置本身。
*/
import { CalendarPage } from './CalendarPage';
+import { ComposeView } from './ComposePage';
import { SettingsPane } from './SettingsPage';
import { LengthMetrics, ComponentContent } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS } from '../model/WindowInsets';
@@ -99,6 +100,13 @@ import {
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
+/**
+ * 写信页签的路由名(宽屏在**右栏**内嵌打开,见 `ComposeDestination`)。
+ *
+ * WebUI `App.tsx:179` 宽屏下写信只是把 `main` 那一格换成 ``,
+ * 侧栏与列表栏都还在。鸿蒙用 `Navigation` 的同一个栈表达这件事。
+ */
+const COMPOSE_ROUTE: string = 'compose';
/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */
function compactMailTime(iso: string): string {
@@ -215,6 +223,57 @@ struct MailDetailDestination {
}
}
+/**
+ * 写信窗格的内嵌壳(宽屏右栏)。
+ *
+ * ★★ 2026-09-20 加(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
+ *
+ * 与 `MailDetailDestination` **完全同构**(那是既有先例):
+ * 同一个 `NavDestination` 机制,宽屏并排在右栏、窄屏 push 覆盖全屏 ——
+ * 由 `Navigation.mode(Auto)` 按宽度自动决定,这里不自己判断宽窄。
+ *
+ * ★ 为什么是路由而不是一个 `@State showCompose`:
+ * 路由让"返回"这件事自动正确(系统返回键、手势、头部返回都弹同一个栈),
+ * 而一个布尔状态要自己接三条返回路径 —— 那正是 2026-09-17 那批
+ * 「返回直接回到登录页」bug 的来源。
+ */
+@Component
+struct ComposeDestination {
+ @State to: string = '';
+ @State replyTo: string = '';
+ @State sessionAlias: string = '';
+ @State accountId: string = '';
+ @Prop navReserve: number = 0;
+ @Prop bgActive: boolean = false;
+ private pathStack: NavPathStack = new NavPathStack();
+
+ handleReady(ctx: NavDestinationContext): void {
+ const params: ComposeParams = ctx.pathInfo.param as ComposeParams;
+ this.to = params?.to ?? '';
+ this.replyTo = params?.reply_to ?? '';
+ this.sessionAlias = params?.session_alias ?? '';
+ this.accountId = params?.account_id ?? '';
+ this.pathStack = ctx.pathStack;
+ }
+
+ build() {
+ NavDestination() {
+ ComposeView({
+ embedded: true,
+ initialTo: this.to,
+ initialReplyTo: this.replyTo,
+ initialSessionAlias: this.sessionAlias,
+ initialAccountId: this.accountId,
+ onBack: (): void => { this.pathStack.pop(); }
+ })
+ }
+ .hideTitleBar(true)
+ /* 与 `MailDetailDestination` 同一处圆角修法(WebUI `app-shell > *` 的对应物) */
+ .borderRadius(this.bgActive ? Theme.glassRadius : 0)
+ .onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
+ }
+}
+
@Component
struct InboxTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@@ -229,6 +288,8 @@ struct InboxTab {
@Prop navReserve: number = 0;
/** 选中邮件回调:宽屏内联到 Navigation 右栏,窄屏 push 到目标页 */
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
+ /** 打开写信(见 `openCompose()` 的注释:宽屏要在右栏开,不能自己 pushUrl) */
+ onOpenCompose: (accountId: string) => void = (): void => {};
/*
* 当前是否深色(`MainPage` 发布)—— 用来选品牌浅底的深浅变体。
*
@@ -1476,14 +1537,36 @@ struct CommPage {
}
}
+ /** 写一封全新的(`accountId` 为空时用当前活跃账号)。 */
openCompose(): void {
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
+ this.openComposeWith(accountId);
+ }
+
+ /**
+ * 按指定发信账号打开写信。
+ *
+ * ★★ 2026-09-20 改(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
+ *
+ * 原来是 `router.pushUrl('pages/ComposePage')` —— 一个盖住**全屏**的独立页,
+ * 左侧的列表栏整片消失。而 WebUI 宽屏下写信只是把**右栏**换掉
+ * (`App.tsx:179`:`const main = composing ? : ...`),
+ * 侧栏与列表栏都还在。
+ *
+ * 改走本页自己的 `Navigation` 栈:`mode(Auto)` 按宽度自动决定
+ * **并排(宽屏 ⇒ 写信在右栏)** 还是 **覆盖(窄屏 ⇒ 整页铺开)** ——
+ * 两条路径同一套代码,不自己判断宽窄(这是 `Navigation` 本来就该干的事)。
+ *
+ * 为什么收一个 `accountId`:收件箱里有"按当前筛选账号写信"(`InboxTab`),
+ * 收件箱外有"用活跃账号写信"(`CommPage` 的 FAB)。两条入口共用这个方法。
+ */
+ openComposeWith(accountId: string): void {
const params: ComposeParams = { to: '', reply_to: '', session_alias: '', account_id: accountId };
- this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
+ this.navPathStack.pushPath({ name: COMPOSE_ROUTE, param: params });
}
@Builder
@@ -1579,6 +1662,8 @@ struct CommPage {
DestinationBuilder(name: string, param: Object) {
if (name === MAIL_DETAIL_ROUTE) {
MailDetailDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
+ } else if (name === COMPOSE_ROUTE) {
+ ComposeDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
}
}
@@ -1615,7 +1700,8 @@ struct CommPage {
bgActive: this.bgActive,
currentMailId: this.currentMailId,
navReserve: this.navReserve,
- onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); }
+ onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); },
+ onOpenCompose: (accountId: string): void => { this.openComposeWith(accountId); }
})
}
}