diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index d84deb7..32d28a3 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -89,6 +89,33 @@ test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768 assert.match(code_, /bottom:\s*this\.isWide \? 0 : NAV_CONTENT_RESERVE/, '宽屏内容 padding 要归零(没有底部条了)'); }); +test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row 分栏', () => { + const main = read('pages/MainPage.ets'); + const code_ = stripComments(main); + /* + * phone / tablet / 2in1 的列表→详情行为交给系统: + * - 窄宽度自动 Stack(详情覆盖列表,可返回); + * - 宽窗口自动 Split(列表 + 详情并排)。 + * 断点由 navBarWidthRange + minContentWidth 共同决定,不复制一套 onAreaChange。 + */ + assert.match(code_, /private navPathStack: NavPathStack = new NavPathStack\(\)/, + '通信页必须持有 NavPathStack'); + assert.match(code_, /Navigation\(this\.navPathStack\)/, + '通信页列表/详情必须由原生 Navigation 承载'); + assert.match(code_, /\.navDestination\(this\.DestinationBuilder\)/, + 'Navigation 必须注册邮件详情目标页 builder'); + assert.match(code_, /\.mode\(NavigationMode\.Auto\)/, + '必须用 NavigationMode.Auto,让系统自动选择 Stack / Split'); + assert.match(code_, /\.navBarWidth\(320\)/, + '宽屏列表栏应与 WebUI MailList 的 320px 同档'); + assert.match(code_, /\.navBarWidthRange\(\[280, 360\]\)/, + '列表栏应允许系统在 280–360vp 内适配,而不是固定死布局'); + assert.match(code_, /\.minContentWidth\(360\)/, + '详情栏最小宽度参与 Auto 断点计算'); + assert.match(code_, /this\.navPathStack\.pushPath\(\{ name: MAIL_DETAIL_ROUTE, param: params \}\)/, + '选中邮件必须进入 NavPathStack,而不是绕回 router.pushUrl'); +}); + test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比一复刻的骨架)', () => { const main = read('pages/MainPage.ets'); const code_ = stripComments(main); diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index 6a22172..db17f97 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -161,87 +161,163 @@ struct LoginPage { } build() { - Column() { - // 标题区 + Scroll() { Column() { - Text('AgentMail') - .fontSize(28) - .fontWeight(FontWeight.Bold) - .fontColor(Theme.accent) - Text('邮件驱动 · 多智能体协作平台') - .fontSize(14) + Column() { + // 与 WebUI 登录页一致:品牌标识不是巨幅文字,而是一枚紧凑的浅蓝图标卡。 + Stack() { + Text('✉') + .fontSize(22) + .fontColor(Theme.accent) + } + .width(48) + .height(48) + .borderRadius(12) + .backgroundColor(Theme.accentSoft) + + Text('AgentMail') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(Theme.textPrimary) + .margin({ top: 12 }) + Text('邮件驱动的多智能体协作平台') + .fontSize(12) + .fontColor(Theme.textMuted) + .margin({ top: 4 }) + } + .width('100%') + .alignItems(HorizontalAlign.Center) + .margin({ bottom: 24 }) + + Text('服务器地址') + .width('100%') + .fontSize(11) + .fontWeight(FontWeight.Medium) .fontColor(Theme.textMuted) - .margin({ top: 6 }) - } - .margin({ top: 100, bottom: 48 }) - - // 服务器地址 - TextInput({ placeholder: '服务器地址(含 /api/v1)', text: this.serverAddr }) - .width('85%') - .height(48) - .margin({ bottom: 12 }) - .onChange((v: string) => { - this.serverAddr = v; - // 边输边算:合规性提示在按下"登录"之前就看得见 - this.addrWarning = validateApiBase(v).warning; - }) - - if (this.addrWarning.length > 0) { - Text(this.addrWarning) - .fontSize(11).fontColor(Theme.warnFg) - .width('85%').margin({ bottom: 12 }) - } - - // 切换登录方式 - Row() { - Text('账号密码').fontSize(14).fontColor(this.mode === 0 ? Theme.accent : Theme.textSubtle) - .onClick(() => { this.mode = 0; }) - Text(' | ').fontSize(14).fontColor(Theme.border) - Text('用户密钥').fontSize(14).fontColor(this.mode === 1 ? Theme.accent : Theme.textSubtle) - .onClick(() => { this.mode = 1; }) - } - .margin({ bottom: 16 }) - - if (this.mode === 0) { - TextInput({ placeholder: '用户名', text: this.username }) - .width('85%').height(48).margin({ bottom: 12 }) - .onChange((v: string) => { this.username = v; }) - TextInput({ placeholder: '密码', text: this.password }) - .width('85%').height(48).margin({ bottom: 24 }) - .type(InputType.Password) - .onChange((v: string) => { this.password = v; }) - } else { - TextInput({ placeholder: '粘贴用户密钥 (Bearer token)', text: this.userKey }) - .width('85%').height(48).margin({ bottom: 24 }) - .onChange((v: string) => { this.userKey = v; }) - } - - Button(this.loading ? '登录中…' : '登 录') - .width('85%').height(48) - .backgroundColor(Theme.accent) - .fontSize(16) - .enabled(!this.loading) - .onClick(() => { - hilog.info(0x0001, 'LoginPage', 'onClick fired, loading=%{public}s', this.loading.toString()); - this.doLogin(); - }) - - // 地址提示:默认值就是推荐值;末尾 /api/v1 必须带(少了会 404,而 404 以前只显示"无法连接") - Text('地址要写到 /api/v1 为止,例如 ' + DEFAULT_API_BASE) - .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 20 }) - Text('模拟器 NAT 不通时用 ' + EMULATOR_HOST_BASE) - .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 4 }) - - if (this.loggedIn) { - // 登录成功 → 进入主界面(占位,后续 M2 替换) - Blank() - Text('登录成功:' + this.me.username) - .fontSize(16).fontColor(Theme.accent).margin({ bottom: 60 }) + .margin({ bottom: 4 }) + TextInput({ placeholder: 'https://example.com/api/v1', text: this.serverAddr }) + .width('100%') + .height(40) + .fontSize(14) + .border({ width: 1, color: Theme.border }) + .borderRadius(Theme.radiusControl) + .backgroundColor(Theme.surface) + .padding({ left: 12, right: 12 }) + .onChange((v: string) => { + this.serverAddr = v; + this.addrWarning = validateApiBase(v).warning; + }) + + if (this.addrWarning.length > 0) { + Text(this.addrWarning) + .fontSize(11) + .fontColor(Theme.warnFg) + .width('100%') + .margin({ top: 6 }) + } + + Row() { + Button('账号密码') + .type(ButtonType.Normal) + .height(32) + .layoutWeight(1) + .fontSize(12) + .fontWeight(this.mode === 0 ? FontWeight.Medium : FontWeight.Regular) + .fontColor(this.mode === 0 ? Theme.accentStrong : Theme.textMuted) + .backgroundColor(this.mode === 0 ? Theme.accentSoft : Color.Transparent) + .borderRadius(Theme.radiusControl) + .onClick(() => { this.mode = 0; }) + Button('用户密钥') + .type(ButtonType.Normal) + .height(32) + .layoutWeight(1) + .fontSize(12) + .fontWeight(this.mode === 1 ? FontWeight.Medium : FontWeight.Regular) + .fontColor(this.mode === 1 ? Theme.accentStrong : Theme.textMuted) + .backgroundColor(this.mode === 1 ? Theme.accentSoft : Color.Transparent) + .borderRadius(Theme.radiusControl) + .onClick(() => { this.mode = 1; }) + } + .width('100%') + .padding(3) + .margin({ top: 12, bottom: 12 }) + .borderRadius(Theme.radiusControl) + .backgroundColor(Theme.surfaceMuted) + + if (this.mode === 0) { + Text('用户名') + .width('100%').fontSize(11).fontWeight(FontWeight.Medium) + .fontColor(Theme.textMuted).margin({ bottom: 4 }) + TextInput({ placeholder: '用户名', text: this.username }) + .width('100%').height(40).fontSize(14) + .border({ width: 1, color: Theme.border }).borderRadius(Theme.radiusControl) + .backgroundColor(Theme.surface).padding({ left: 12, right: 12 }) + .onChange((v: string) => { this.username = v; }) + Text('密码') + .width('100%').fontSize(11).fontWeight(FontWeight.Medium) + .fontColor(Theme.textMuted).margin({ top: 12, bottom: 4 }) + TextInput({ placeholder: '密码', text: this.password }) + .width('100%').height(40).fontSize(14) + .border({ width: 1, color: Theme.border }).borderRadius(Theme.radiusControl) + .backgroundColor(Theme.surface).padding({ left: 12, right: 12 }) + .type(InputType.Password) + .onChange((v: string) => { this.password = v; }) + } else { + Text('用户密钥') + .width('100%').fontSize(11).fontWeight(FontWeight.Medium) + .fontColor(Theme.textMuted).margin({ bottom: 4 }) + TextInput({ placeholder: '在网页版「账号」页创建', text: this.userKey }) + .width('100%').height(40).fontSize(14) + .border({ width: 1, color: Theme.border }).borderRadius(Theme.radiusControl) + .backgroundColor(Theme.surface).padding({ left: 12, right: 12 }) + .type(InputType.Password) + .onChange((v: string) => { this.userKey = v; }) + Text('密钥仅保存在本机,用于客户端访问你的邮箱。') + .width('100%').fontSize(10).fontColor(Theme.textSubtle) + .lineHeight(15).margin({ top: 5 }) + } + + Button(this.loading ? '登录中…' : '登录') + .width('100%') + .height(40) + .margin({ top: 16 }) + .borderRadius(Theme.radiusControl) + .backgroundColor(Theme.accent) + .fontColor(Theme.accentFg) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .enabled(!this.loading) + .onClick(() => { + hilog.info(0x0001, 'LoginPage', 'onClick fired, loading=%{public}s', this.loading.toString()); + this.doLogin(); + }) + + Text('地址须以 /api/v1 结尾;模拟器 NAT 不通时可用 ' + EMULATOR_HOST_BASE) + .width('100%') + .fontSize(10) + .fontColor(Theme.textSubtle) + .textAlign(TextAlign.Center) + .lineHeight(15) + .margin({ top: 12 }) + + if (this.loggedIn) { + Text('登录成功:' + this.me.username) + .fontSize(12).fontColor(Theme.accent).margin({ top: 12 }) + } } + .width('100%') + .constraintSize({ maxWidth: 380 }) + .padding(24) + .borderRadius(Theme.glassRadius) + .backgroundColor(Theme.surface) + .shadow({ radius: 24, color: Theme.overlay, offsetX: 0, offsetY: 8 }) + .alignItems(HorizontalAlign.Start) + .margin({ top: 24, bottom: 24 }) } .width('100%') .height('100%') - .backgroundColor(Theme.pageBg) - .alignItems(HorizontalAlign.Center) + .padding({ left: 16, right: 16 }) + .backgroundColor(Theme.surfaceMuted) + .align(Alignment.Center) } } \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 2795c68..1bf738d 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -13,9 +13,16 @@ import { MailDetail, SendMailRequest } from '../model/Models'; import { MailDetailParams } from '../model/RouteParams'; import { AmIcon } from '../common/Icons'; -@Entry @Component -struct MailDetailPage { +export struct MailDetailView { + /** + * 既支持旧 router 页面,也支持 Navigation 的 NavDestination 内嵌复用。 + * 内嵌时由父级传入 mailId/accountId;独立页面仍从 router params 读取。 + */ + @Prop initialMailId: string = ''; + @Prop initialAccountId: string = ''; + @Prop embedded: boolean = false; + onBack: () => void = (): void => {}; @State mailId: string = ''; @State accountId: string = ''; @State subject: string = ''; @@ -42,9 +49,14 @@ struct MailDetailPage { this.error = '无法获取应用上下文'; return; } - const params = this.getUIContext().getRouter().getParams() as MailDetailParams; - this.mailId = params?.mail_id ?? ''; - this.accountId = params?.account_id ?? ''; + if (this.initialMailId.length > 0) { + this.mailId = this.initialMailId; + this.accountId = this.initialAccountId; + } else { + const params = this.getUIContext().getRouter().getParams() as MailDetailParams; + this.mailId = params?.mail_id ?? ''; + this.accountId = params?.account_id ?? ''; + } const accountManager: AccountManager = AccountManager.getInstance(ctx); accountManager.load().then(() => { let account: AccountInfo | null = accountManager.getAccount(this.accountId); @@ -114,6 +126,14 @@ struct MailDetailPage { } } + goBack(): void { + if (this.embedded) { + this.onBack(); + return; + } + this.getUIContext().getRouter().back(); + } + build() { Column() { // 顶栏 @@ -123,7 +143,7 @@ struct MailDetailPage { .width(40).height(40) .textAlign(TextAlign.Center) .onClick(() => { - this.getUIContext().getRouter().back(); + this.goBack(); }) Text(this.subject.length > 20 ? this.subject.substring(0, 20) + '…' : this.subject) .fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) @@ -332,4 +352,16 @@ struct MailDetailPage { this.sending = false; } } -} \ No newline at end of file +} + +/** 旧 router 页面入口:与 Navigation 目标页共用同一份 MailDetailView。 */ +@Entry +@Component +struct MailDetailPage { + build() { + Column() { + MailDetailView() + } + .width('100%').height('100%') + } +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index efb38ed..c298a74 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -10,6 +10,7 @@ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { AmIcon } from '../common/Icons'; import { WideSidebar } from './WideSidebar'; +import { MailDetailView } from './MailDetailPage'; import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; @@ -81,11 +82,52 @@ import { /** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */ const INBOX_PAGE_SIZE: number = 50; +const MAIL_DETAIL_ROUTE: string = 'mail-detail'; + +/** + * Navigation 目标页:按官方示例在 NavDestination.onReady 读取路径参数。 + * 系统 Navigation 自动决定 Stack / Split;这里不读窗口宽度、不手搓分栏。 + */ +@Component +struct MailDetailDestination { + @State mailId: string = ''; + @State accountId: string = ''; + private pathStack: NavPathStack = new NavPathStack(); + + handleReady(ctx: NavDestinationContext): void { + const params: MailDetailParams = ctx.pathInfo.param as MailDetailParams; + this.mailId = params?.mail_id ?? ''; + this.accountId = params?.account_id ?? ''; + this.pathStack = ctx.pathStack; + } + + build() { + NavDestination() { + if (this.mailId.length > 0) { + MailDetailView({ + initialMailId: this.mailId, + initialAccountId: this.accountId, + embedded: true, + onBack: (): void => { this.pathStack.pop(); } + }) + } else { + Column() { + LoadingProgress().width(32).height(32) + } + .width('100%').height('100%').justifyContent(FlexAlign.Center) + } + } + .hideTitleBar(true) + .onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); }) + } +} @Component struct InboxTab { /** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */ @Prop bgActive: boolean = false; + /** 选中邮件回调:宽屏内联到 Navigation 右栏,窄屏 push 到目标页 */ + onOpenMail: (mailId: string, accountId: string) => void = (): void => {}; @State mails: MailSummary[] = []; /** 按会话折叠后的列表(单封的组平铺渲染) */ @State groups: SessionGroup[] = []; @@ -273,14 +315,7 @@ struct InboxTab { } openMail(mail: MailLike): void { - const params: MailDetailParams = { - mail_id: mail.mail_id, - account_id: mail.source_account_id - }; - this.getUIContext().getRouter().pushUrl({ - url: 'pages/MailDetailPage', - params: params - }); + this.onOpenMail(mail.mail_id, mail.source_account_id); } openCompose(): void { @@ -611,6 +646,8 @@ struct InboxTab { struct SentTab { /** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */ @Prop bgActive: boolean = false; + /** 选中邮件回调 */ + onOpenMail: (mailId: string, accountId: string) => void = (): void => {}; @State groups: SessionGroup[] = []; @State expandedKeys: string[] = []; @State loading: boolean = false; @@ -681,11 +718,7 @@ struct SentTab { } openMail(mail: MailLike): void { - const params: MailDetailParams = { - mail_id: mail.mail_id, - account_id: mail.source_account_id - }; - this.getUIContext().getRouter().pushUrl({ url: 'pages/MailDetailPage', params: params }); + this.onOpenMail(mail.mail_id, mail.source_account_id); } @Builder @@ -1074,6 +1107,8 @@ struct CommPage { @State commTab: string = 'inbox'; @State unreadCount: number = 0; @State pendingCount: number = 0; + /** Navigation 路由栈:宽屏 Split 时列表+详情并排,窄屏 Stack 时详情 push 覆盖 */ + private navPathStack: NavPathStack = new NavPathStack(); aboutToAppear(): void { this.refreshCounts(); @@ -1177,6 +1212,7 @@ struct CommPage { .border({ width: { bottom: this.commTab === key ? 2 : 0 }, color: Theme.accent }) .onClick(() => { this.commTab = normalizeCommTab(key); + this.navPathStack.clear(); this.refreshCounts(); }) }, (key: string) => key) @@ -1185,37 +1221,68 @@ struct CommPage { .backgroundColor(Theme.surface) } + openMail(mailId: string, accountId: string): void { + const params: MailDetailParams = { + mail_id: mailId, + account_id: accountId + }; + this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params }); + } + + @Builder + DestinationBuilder(name: string, param: Object) { + if (name === MAIL_DETAIL_ROUTE) { + MailDetailDestination() + } + } + build() { Stack({ alignContent: Alignment.BottomEnd }) { - Column() { - Row() { - Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - Blank() - /* - * 设置入口。 - * 图标用 `person`:与 WebUI 的「我的」(`NarrowNav.tsx` 的 `PersonIcon`)同一枚, - * 而设置页里正是账号 / 外观 / 密钥这些“属于这个人的东西”。 - * 2026-09-15 之前这里是 emoji `⚙`(理由与替换过程见 `model/NavItems.ts`)。 - */ - AmIcon({ iconName: 'person', iconSize: 20, iconColor: Theme.textMuted }) - .width(36).height(36) - .onClick(() => { - this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' }); - }) - } - .width('100%').height(52).padding({ left: 16, right: 8 }) - .backgroundColor(Theme.surface) + Navigation(this.navPathStack) { + Column() { + Row() { + Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + /* + * 设置入口。 + * 图标用 `person`:与 WebUI 的「我的」(`NarrowNav.tsx` 的 `PersonIcon`)同一枚, + * 而设置页里正是账号 / 外观 / 密钥这些“属于这个人的东西”。 + * 2026-09-15 之前这里是 emoji `⚙`(理由与替换过程见 `model/NavItems.ts`)。 + */ + AmIcon({ iconName: 'person', iconSize: 20, iconColor: Theme.textMuted }) + .width(36).height(36) + .onClick(() => { + this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' }); + }) + } + .width('100%').height(52).padding({ left: 16, right: 8 }) + .backgroundColor(Theme.surface) - this.CommTabBar() + this.CommTabBar() - if (this.commTab === 'sent') { - SentTab({ bgActive: this.bgActive }) - } else if (this.commTab === 'permissions') { - PermissionTab({ bgActive: this.bgActive }) - } else { - InboxTab({ bgActive: this.bgActive }) + if (this.commTab === 'sent') { + SentTab({ + bgActive: this.bgActive, + onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); } + }) + } else if (this.commTab === 'permissions') { + PermissionTab({ bgActive: this.bgActive }) + } else { + InboxTab({ + bgActive: this.bgActive, + onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); } + }) + } } + .width('100%').height('100%') } + .navDestination(this.DestinationBuilder) + .mode(NavigationMode.Auto) + /* WebUI 邮件列表栏固定约 320px;Auto 的断点 = 列表最小宽 + 详情最小宽。 */ + .navBarWidth(320) + .navBarWidthRange([280, 360]) + .minContentWidth(360) + .hideTitleBar(true) .width('100%').height('100%') /* diff --git a/client/harmony/entry/src/main/module.json5 b/client/harmony/entry/src/main/module.json5 index 6891be4..d6dc72e 100644 --- a/client/harmony/entry/src/main/module.json5 +++ b/client/harmony/entry/src/main/module.json5 @@ -5,11 +5,19 @@ "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ - "phone" + "phone", + "tablet", + "2in1" ], "deliveryWithInstall": true, "installationFree": false, "pages": "$profile:main_pages", + "metadata": [ + { + "name": "client_id", + "value": "2039846327155747840" + } + ], "abilities": [ { "name": "EntryAbility",