## 一、真 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`。
433 lines
19 KiB
Plaintext
433 lines
19 KiB
Plaintext
/*
|
||
* 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)
|
||
}
|
||
} |