harmony(ui): 登录页 1:1 对齐 WebUI 卡片规格 + 通信页改用系统 Navigation Auto

登录页(LoginPage.ets):对照 WebUI LoginPage.tsx 同规格重写 build()
- maxWidth 380vp 居中卡片(与 WebUI 380px 同档)
- 14vp 圆角(Theme.glassRadius)
- 48vp 品牌图标(Envelope 浅蓝底 accentSoft)
- 显式字段标签(服务器/用户名/密码/用户密钥)
- 40vp 输入框与登录按钮(WebUI 控件同高)
- 分段式登录方式切换(账号密码 / 用户密钥)
- 窄屏安全边距 16vp + Scroll 可滚

通信页(MainPage.ets CommPage):改用鸿蒙系统 Navigation 组件
- Navigation(navPathStack) 包住列表,navDestination 注册邮件详情目标页
- mode(NavigationMode.Auto):phone 自动 Stack、tablet/2in1 自动 Split
- navBarWidth(320) 对齐 WebUI MailList 320px;navBarWidthRange([280,360])
- minContentWidth(360) 参与 Auto 断点计算
- Inbox/Sent 选中邮件改走 navPathStack.pushPath,不再 router.pushUrl
- 切收件箱/发件箱/授权时清空详情栈避免跨栏残留

详情页(MailDetailPage.ets):抽出可复用 MailDetailView
- 既有详情实现导出为 @Component struct MailDetailView
- 新增 initialMailId/initialAccountId/embedded/onBack 输入
- 无 initialMailId 时才读路由参数(兼容旧 router 入口)
- embedded=true 时返回走 onBack 回调(NavPathStack.pop)
- 薄壳 @Entry MailDetailPage 用 Column 包裹(@Entry 根节点须容器)

module.json5:deviceTypes 扩展为 phone/tablet/2in1 三形态

判据:harmony-widescreen.test.mjs 新增 ⑥ 钉住 Navigation Auto 不许退回手搓分栏

验证:ArkTS 编译通过;harmony-arkts/widescreen/nav/cross-client-theme 35/35 通过
This commit is contained in:
2026-09-17 10:58:31 +08:00
parent f44b15684b
commit a5f680a147
5 changed files with 332 additions and 122 deletions

View File

@ -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);

View File

@ -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)
}
}

View File

@ -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;
}
}
}
}
/** 旧 router 页面入口:与 Navigation 目标页共用同一份 MailDetailView。 */
@Entry
@Component
struct MailDetailPage {
build() {
Column() {
MailDetailView()
}
.width('100%').height('100%')
}
}

View File

@ -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%')
/*

View File

@ -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",