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

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