Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/LoginPage.ets
JianFeeeee 13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00

433 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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<void> {
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<void> {
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)
}
}