/* * AgentMail 鸿蒙客户端 — 登录页 * 两种模式:账号密码(Cookie) / 用户密钥(Bearer) * 服务器地址可配置(跨设备/模拟器场景) */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { AuthApi } from '../api/AuthApi'; import { AccountManager } from '../api/AccountManager'; import { SseService } from '../api/SseService'; import { Me } from '../model/Models'; import { DEFAULT_API_BASE, EMULATOR_HOST_BASE } from '../common/Config'; import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { PushService } from '../api/PushService'; import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { AmIcon } from '../common/Icons'; @Entry @Component struct LoginPage { /* * 当前是否深色(`MainPage` 经 `AppStorage` 发布)—— 用来选品牌浅底的深浅变体。 * * ★ 2026-09-19:深色主题下这些"浅蓝底"(未读行 / 选中项 / 分段选中) * 在深色页面上刺眼。根因是 `Theme.accentSoft` 写死浅色 * (WebUI 靠 CSS 的 `.dark` 段反转发解决,ArkTS 静态常量没有那层机制)。 * 窗格自己算不出深浅色 ⇒ 从 AppStorage 读。 */ @StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false; @State serverAddr: string = DEFAULT_API_BASE; /** 地址合法但值得提醒(例如公网明文 http)—— 直接渲染在输入框下面,不靠一次性 toast */ @State addrWarning: string = ''; @State username: string = ''; @State password: string = ''; @State userKey: string = ''; @State mode: number = 1; // 0 = 账号密码, 1 = 用户密钥 @State loading: boolean = false; @State loggedIn: boolean = false; @State me: Me = new Me(); private client: ApiClient | null = null; private authApi: AuthApi | null = null; aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); if (ctx !== undefined) { const client: ApiClient = ApiClient.getInstance(ctx); this.client = client; this.authApi = new AuthApi(client); // 检查多账号管理器:已有账号则自动恢复 const acctMgr: AccountManager = AccountManager.getInstance(ctx); acctMgr.load().then(() => { const active = acctMgr.getActiveAccount(); if (active !== null) { // 恢复活跃账号的 token client.setBase(active.server); client.setToken(active.token); this.serverAddr = active.server; // 为所有账号建立 SSE 连接 SseService.getInstance().connectAll(acctMgr); /* * ★ 这条快速路径也是"一次登录",所以同样要补报: * 它**不经** doLogin/tryRestore,是"已有账号直接进主界面"。 * 漏了这里,老用户(最需要推送的那批)恰好永远不补报。 */ this.reportPushToken(client); this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); return; } // 无账号,走正常登录流程 client.init().then(() => { const base: string = client.getBase(); this.serverAddr = base; const token: string = client.getToken(); if (token.length > 0) { this.tryRestore(token); } }); }); } } /** * 用**已存下来的 token** 恢复登录(启动时 `init()` 读到 token 就走这里)。 * * ★★ 2026-09-18 修:原来这个方法**验完 token 就结束了** —— 它设了 `loggedIn = true` * (于是界面上出现「登录成功:jianf」)却**从不跳转主界面**。 * 症状最恶劣的地方在于它**看起来是成功的**:用户看到"登录成功"四个字, * 然后永远停在登录页,再点登录又会重新走一遍。实测(模拟器、jianf 的永久 key): * 日志只有 `→ GET /auth/me` 然后什么都没有。 * * 本文件里另外两条成功路径都有跳转(`aboutToAppear` 的快速路径、 * `doLogin` 末尾),唯独这条没有 —— 而这条**恰恰是老用户最常走的那条** * (重启 App 时 token 还在,`doLogin` 根本不会被调用)。 * * 顺带补齐 `doLogin` 有而这里缺的三件事,否则"恢复登录"进主界面之后 * 是个瘸的状态: * ① 写进多账号管理器(少了它,设置页/账号切换看不到这个账号, * 而 `aboutToAppear` 的快速路径正是靠它命中的 ⇒ 下次启动又会重走这里); * ② 为账号建 SSE(少了它,主界面收不到实时事件); * ③ 跳转主界面。 * 推送 token 的补报本来就有(下一轮起 `reportPushToken` 会命中已报过的缓存)。 */ async tryRestore(token: string): Promise { this.loading = true; try { const c: ApiClient | null = this.client; if (c === null) { return; } const user = await new AuthApi(c).loginWithKey(token); this.me = user; this.loggedIn = true; // ① 登记账号 + ② 建 SSE(与 doLogin 同一套) const ctx: Context | undefined = this.getUIContext().getHostContext(); if (ctx !== undefined) { const acctMgr: AccountManager = AccountManager.getInstance(ctx); await acctMgr.load(); const server: string = c.getBase(); const acct = await acctMgr.addAccount(server, user.username, token, user.display_name.length > 0 ? user.display_name : user.username); SseService.getInstance().connectForAccount(acct.id, acct.server, acct.token); } this.reportPushToken(c); // ③ ★ 跳转 —— 原来缺的就是这一句 await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); } catch (e) { const c2: ApiClient | null = this.client; if (c2 !== null) { c2.clearAuth(); } } finally { this.loading = false; } } /** * 登录成功后的**补报**(收尾项,pi 邮件 `66bbd929` §三)。 * * 为什么必须有这一处:`EntryAbility.onCreate` 里那次上报发生在**登录之前** —— * 那时 `ApiClient` 还没有 token,服务端回 401,而 `reportToken` 的失败是**静默**的 * (设计如此),所以"启动时报过一次"并不等于"这个账号登记过"。 * 少了这一处,链路的症状是:App 能登录、能 GET,但**永远不 POST** token。 * * 三条性质沿用 `PushService.reportToken` 自己的:开关 gate 在最前(关着就零开销)、 * 失败全静默(不弹、不阻塞登录跳转)、且**幂等**(已报过且账号没换就不打网络)。 * 所以这里不 await、不 catch 提示 —— 登录跳转不该被一个可选通道拖住。 */ private reportPushToken(c: ApiClient): void { const ctx: Context | undefined = this.getUIContext().getHostContext(); if (ctx === undefined) { return; } try { const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext); push.reportToken(c).catch((err: Object) => { hilog.info(0x0001, 'LoginPage', '登录后补报失败(静默):%{public}s', JSON.stringify(err)); }); } catch (e) { hilog.info(0x0001, 'LoginPage', '登录后补报跳过(静默):%{public}s', JSON.stringify(e)); } } async doLogin(): Promise { if (this.loading) { return; } /* * ★ 先把地址归一化/校验,**不合法就地拦下**(2026-09-15 的故障就是这个入口漏的)。 * 要点两条: * 1. 少了 `/api/v1` 的地址会被补全(用户只填 `https://域名/` 是本次最可能的直接原因); * 2. 不合法的地址**绝不**写进 preferences —— 否则下次启动会带着一个坏地址 * 去"无法连接",而用户已经忘了自己填过什么。 */ if (!this.applyServerAddr()) { return; } const c: ApiClient | null = this.client; const a: AuthApi | null = this.authApi; if (c === null || a === null) { return; } this.loading = true; try { // 保存服务器地址(覆盖默认)。setBase 是同步的,不要 await(lint: await-thenable) c.setBase(this.serverAddr); await c.persistBase(this.serverAddr); hilog.info(0x0001, 'LoginPage', 'base saved, calling login'); let user: Me; if (this.mode === 0) { // 账号密码:调 login 拿 user,token 交给 cookie 管理 user = await a.login(this.username, this.password); } else { // 用户密钥 user = await a.loginWithKey(this.userKey.trim()); } hilog.info(0x0001, 'LoginPage', 'login ok: %{public}s', user.username); this.me = user; this.loggedIn = true; // 存入多账号管理器 const ctx: Context | undefined = this.getUIContext().getHostContext(); if (ctx !== undefined) { const acctMgr: AccountManager = AccountManager.getInstance(ctx); await acctMgr.load(); const token: string = this.mode === 1 ? this.userKey.trim() : c.getToken(); const server: string = c.getBase(); const acct = await acctMgr.addAccount(server, user.username, token, user.display_name.length > 0 ? user.display_name : user.username); // 为该账号建立 SSE 连接 SseService.getInstance().connectForAccount(acct.id, acct.server, acct.token); } // ★ 登录成功 ⇒ 补报一次推送 token(见 reportPushToken 的说明:启动那次是在登录之前) this.reportPushToken(c); // 跳转主界面(含 Tab 导航) await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); } catch (e) { const ae = e as ApiError; const msg: string = ae.code === 0 ? ae.message : (ae.message.length > 0 ? ae.message : '登录失败'); hilog.error(0x0001, 'LoginPage', 'login failed: code=%{public}d msg=%{public}s', ae.code, msg); this.getUIContext().getPromptAction().showToast({ message: msg }); } finally { this.loading = false; } } /** * 校验并归一化登录页上填的地址(逻辑在 `model/ApiBase.ts`,判据跑那一份)。 * 返回 false 时**已经把可执行的提示弹给用户**,调用方直接 return 即可。 */ applyServerAddr(): boolean { const check: ApiBaseCheck = validateApiBase(this.serverAddr); this.addrWarning = check.warning; if (!check.ok) { // 改用户填进去的原文是帮倒忙(他会以为自己看错了),只提示不动它 this.getUIContext().getPromptAction().showToast({ message: check.error }); return false; } // 归一化结果回填:用户能看见"我填的地址被补成了什么",而不是默默变 this.serverAddr = check.base; return true; } build() { Scroll() { Column() { Column() { /* * 品牌标识(与 WebUI 登录页一致:一枚紧凑的浅蓝图标卡,不是巨幅文字)。 * * ★★ 2026-09-18 修:这里原先是 `Text('✉')`(Unicode U+2709)。 * HarmonyOS 的字体链里有**彩色 emoji 字体**,U+2709 自带 emoji 字形 * ⇒ 它被渲染成一枚**彩色 emoji**(黄白色风信封),而不是单色图标: * 既不跟主题色走(`fontColor(Theme.accent)` 对彩色 emoji **无效** —— 界面看着 * 就是那个 emoji 自带的颜色),也和底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言。 * 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。 * * 正确做法是 `AmIcon({ iconName: 'brandMark' })` —— 本仓**早就有**这个图标 * (`common/Icons.ets` 的 `ICON_PATHS.brandMark`,就是 App 图标上那个信封), * 而它走 `Path.stroke()`,跟主题色走、与其余图标同一套描边。 * * ★ 纪律:**界面里不许用 Unicode 符号当中图标**。 * 它在某些字体链上会被渲染成 emoji,而 emoji 不吃 `fontColor`。 */ /* * ★ 尺寸/底色加在**外层 `Stack`** 上,**不加在 `AmIcon` 上**。 * * 直接把 `.width(48)` 链在 `AmIcon` 后面是个陷阱:它内部那个容器固定是 * `iconSize` 那么大、且**默认靠左上**,外层盒子变大而图标不动 ⇒ * 图标贴在卡片左上角。实测(三折叠 3.5 密度,dumpLayout 读的实际 bounds): * 卡片 [1523,521][1661,659] 138×138px * 图标 [1526,524][1589,587] 63×63px ← 左边距 3px、上边距 3px * 中心偏了 10vp(用户:「你自己看看那个图标的位置正常吗」)。 * * `Stack({ alignContent: Alignment.Center })` 是"给图标一个更大的、 * 居中的、可上色的盒子"的正确写法 —— 仓里另外两处(回复球、悬浮加号) * 就是这么做的(它们把尺寸加在外层 `Button` 上,所以一直是对的)。 */ Stack({ alignContent: Alignment.Center }) { AmIcon({ iconName: 'brandMark', iconSize: 24, iconColor: Theme.accentFor() }) } .width(48) .height(48) .borderRadius(12) .backgroundColor(Theme.accentSoftFor(this.isDarkNow)) 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({ 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.accentSoftFor(this.isDarkNow) : 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.accentSoftFor(this.isDarkNow) : 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.accentFor()).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%') .padding({ left: 16, right: 16 }) .backgroundColor(Theme.surfaceMuted) .align(Alignment.Center) } }