Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/SettingsPage.ets
JianFeeeee 4ecddfc2b6 跨端: 语义色/三级文字在深色下没提亮(设备扫出 12 处)+ 判据取样法修到第三版
## 一、判据基建先修对,否则是在听噪声

深色可读性扫描的**取样法迭代了三版**,每版都因为**假红**才改的
(记在这里,因为"判据自己错"比"界面错"更费时间):

1. **取中心一个像素** ⇒ 落在笔画之间、读到的是底色 ⇒ 一片 ratio=1.00 假红。
2. **取全局最暗/最亮** ⇒ 会采到**两个不同的东西**:细字的抗锯齿中间色。
   实例:segmented「日」报 ink `rgb(32,34,35)` / bg `rgb(98,100,101)` 2.69:1 ——
   而截图里它要么蓝底白字、要么深底浅字,两种都远高于 3:1。
   **反复核对才确认是判据的错,不是界面的错。**
3. **步长采样 + 众数** ⇒ 仍然假红:周表头「一」报 2.03:1,
   而逐像素的真实墨是 `rgb(166,167,167)`(**6.6:1**)——
   稀疏网格**整个跳过了笔画**。这不是调参能修好的(字越小越糟)。
4. **逐像素 + 众数=底 + 离底最远者=墨**(最终版)。
   理由:一个文字框里**面积最大的一定是底**,而"离底色最远"就是笔画最实那部分。
   ⚠️ 逐像素是**负担得起的**(整屏已解码在内存,一个框最多 9 万像素)——
   **不要再为了省开销退回抽样**,前两版都因此假红。

## 二、真 bug 三批(都是"深色下没提亮")

### ① 语义色前景(12 处 → 修完设备复扫 0)
WebUI 的语义色也是**双通道**,`.dark` 段把 `red/green/amber-700` 换成浅色
(`248/118/249 → 164/219/195` 那组)。鸿蒙只有一个深色值:
- 「归档」按钮 `Theme.danger` #B91C1C 压深面 ⇒ **2.47:1**(联系人页 7 处)
- 「今天」/「日」等 ⇒ **2.38–2.77:1**

加 `dangerDark/approveDark/warnFgDark`(逐字对齐 WebUI `.dark`)
+ `dangerFor()/approveFor()/warnFgFor()` 入口,22 处接入。

### ② 日历工具条整块配色抄错(不是"深色档选错")
对照 WebUI `CalendarView.tsx:313-331`:

| 元素 | WebUI | 鸿蒙(改前) |
|---|---|---|
| 「今天」 | `border-gray-300` + `text-gray-700`(**中性**) | `accentSoft` 底 + `accentStrong` 字 |
| 月/周/日 未选中 | `bg-white` + `text-gray-700` | `accentSoft` 底 |

鸿蒙把**次要的中性按钮**画成了**品牌色按钮** —— 视觉重量跟主操作一样,
三个档看起来"都像选中"。已按 WebUI 改成中性。

### ③ 三级文字在深色下 2.82:1(**71 处**,全局最大的一个)
WebUI 的 `.dark` 段注释逐字写着这个坑:

> 「`gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400
>  只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— **看得见但读不动**。」

鸿蒙用的系统三级色 `ohos_id_color_text_tertiary` 深色下是 `rgb(102,103,105)`
压深面 **2.82–3.05:1**,而它被用在 10–11px 的**小字**上
(时间戳、农历、空态、辅助说明)。

**不换掉系统令牌**("系统拥有的维度"纪律照旧),加 `textSubtleFor()`:
深色下改用二级色(实测 `rgb(166,167,167)` ≈ 7.15:1,
且与 WebUI 的 `gray-500` 同档位)。71 处接入。

**设备复扫(修后)**:通信/日历/联系三页 **176 段文字全部 ≥3:1**。

## 三、欠账与底本

- `baseline.sha` 第 4 次重算。仍**先逐个取证**:
  `git diff --quiet HEAD -- <f> && echo STALE || echo INTENTIONAL`
  ⇒ 三个全是 `INTENTIONAL`(我改的),不是变异残留。
  把这条取证命令也写进文件,下次照抄。
- `dangerDark/approveDark/warnFgDark` 已登记进 `SELF_OWNED_COLORS`
  + `Theme.ets` 登记表(各带理由与 WebUI 出处)。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:①「我的」页(底部头像按钮,扫描脚本没找到入口)没扫;
② 浅色下的对比度没扫(本轮全部针对深色)。
2026-09-19 20:27:09 +08:00

1210 lines
48 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 鸿蒙客户端 — “我的”页(对齐 WebUI `AccountPage`)
*
* 八个 section 与 WebUI 一一对应:基本资料 / 权限范围 / 修改密码 / 多账号 /
* 客户端连接密钥 / 外观(主题+壁纸) / 登录状态(退出) / 管理(仅管理员)。
*
* ★ 页面标题是「我的」而不是「账号管理」:WebUI 侧它就是导航的「我的」入口
* (`Sidebar.tsx` 的 `setViewMode('account')`),多账号只是它里面的一段,
* 而不是整页的目的 —— 叫「账号管理」会让人以为这里是账号 CRUD 页。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AuthApi } from '../api/AuthApi';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
import { LengthMetrics } from '@kit.ArkUI';
import { AmIcon } from '../common/Icons';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { AppearanceSnapshot, isDarkMode, statusLabel } from '../model/Appearance';
import { MeApi } from '../api/AdminApi';
import { AdminUser, UserKey } from '../model/Models';
import { isAdminRole } from '../model/AdminUsers';
import { ApiBaseCheck, validateApiBase } from '../model/ApiBase';
import { BackgroundPicker } from '../common/BackgroundPicker';
import { PushService } from '../api/PushService';
import { performLogout } from '../api/Logout';
import { PushTokenList } from '../model/PushContract';
@Component
export struct SettingsPane {
/** 背景开关(与其它窗格同一约定:开着就让出页面底) */
@Prop bgActive: boolean = false;
/** 底部悬浮条高度(窄屏非 0)—— 本页滚动容器的内容末尾要让出它 */
@Prop navReserve: number = 0;
@State accounts: AccountInfo[] = [];
@State activeId: string = '';
@State showAddDialog: boolean = false;
@State newDisplayName: string = '';
@State newServer: string = '';
@State newUsername: string = '';
@State newToken: string = '';
@State adding: boolean = false;
/*
* 外观(主题 + 壁纸):服务端是权威、本机只是缓存。
* 页面只保存**显示用的一份副本** —— ArkTS 的 @State 观察不到类内部字段的变化。
*/
@State appearanceTheme: string = 'system';
/**
* 当前**实际**是不是深色(`appearanceTheme='system'` 时要看系统反馈的模式)。
*
* ★ 2026-09-19 加:用来选品牌浅底(`Theme.accentSoftFor`)。
* 实测问题:深色主题下「多账号」那张选中卡片仍是**接近纯白的浅蓝**
* (`accentSoft` 是写死的 `#EFF6FF`)—— 在深色页面上刺眼得像 bug。
* WebUI 靠 CSS 变量反转发解决,静态常量没有那层机制。
*/
@State isDarkNow: boolean = false;
@State appearanceStatus: string = 'local-only';
/** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */
@State isAdmin: boolean = false;
/*
* ── 「我的」页的个人资料与密钥(对齐 WebUI `AccountPage`)──
*
* 原来这一页叫「账号管理」、内容只有多账号列表 + 外观 + 推送 + 管理入口,
* 而 WebUI 的 `AccountPage` 有八个 section。缺的这四块(基本资料 / 权限范围 /
* 修改密码 / 客户端连接密钥)**服务端都有、`AuthApi` 大半也写好了**:
* `listKeys` / `createKey` / `revokeKey` / `logout` 在 `AuthApi.ets` 里一直没人调。
*
* `profile` 直接存 `MeApi.get()` 的返回(`AdminUser` 含 `status` /
* `created_at` / `last_login` / `allowed_agents` / `allowed_paths`)——
* `loadRole()` 本来就在拉它,只是把别的字段丢掉了。
*/
@State profile: AdminUser | null = null;
@State keys: UserKey[] = [];
@State keysLoading: boolean = false;
@State keysError: string = '';
/** 新签发密钥的全文:**只在创建响应里出现一次**,关掉就再也看不到 */
@State newKeyToken: string = '';
@State keyLabel: string = '';
@State creatingKey: boolean = false;
/* 修改密码表单 */
@State oldPw: string = '';
@State newPw: string = '';
@State confirmPw: string = '';
@State pwBusy: boolean = false;
@State pwMsg: string = '';
@State pwError: string = '';
/*
* ★ 2026-09-15:接入系统通知 = **可配置项**(用户强调多次:“用户可以自由部署自己的 agentmail”)。
* 默认 **关** —— 没开就不取 token / 不弹权限 / 不打网关。开关持久化在 PushService 的偏好里。
*/
@State pushEnabled: boolean = false;
@State pushServerStatus: string = 'n/a';
/*
* 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改,
* 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。
*
* ★ 为什么**不**用「页面 @Watch 到 bgKind/bgDim… 变化就推服务端」:
* `adoptSnapshot` 把**服务端**的值复制进来时也会触发那个 @Watch ⇒
* 推一次服务端 ⇒ 再同步 ⇒ 再触发…… 要么死循环,要么得再加一个抑制标志,
* 而那个标志本身又是一处能忘掉的状态。
* 改成"选择器在**用户改值**时显式回调"之后,方向是单向的:
* 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。**
*/
/*
* 外观变更的发布键(`AppStorage`)—— 改完外观后 MainPage 靠它重算壁纸层。
* 常量而不是字面量:`AppStorage.get('拼错的名字')` 不报错、只会恒为 undefined,
* 而那正好是"壁纸永远不跟着变"这个症状(与 windowInsets 同一个坑)。
*/
private readonly KEY_APPEARANCE_REVISION: string = 'agentmail.appearance.revision';
@State bgKind: string = 'none';
@State bgPresetId: string = 'aurora';
@State bgDim: number = 12;
@State bgBlur: number = 4;
private client: ApiClient | null = null;
private acctMgr: AccountManager | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.client = ApiClient.getInstance(ctx);
this.acctMgr = AccountManager.getInstance(ctx);
this.acctMgr.load().then(() => {
this.refreshList();
this.loadAppearance();
this.loadRole();
this.loadPushSetting();
this.loadKeys();
});
}
}
/**
* 用户改了背景 ⇒ 推一次服务端(与 `setTheme` 同一形状)。
*
* ★ 只有**用户动作**会走到这里(选择器改值后回调 `onUserChanged`);
* 服务端同步进来的值不会(理由见 `bgKind` 那段的注释)。
* ⇒ "值变了就推一次"只有**这一处**实现,而不是每个 onClick 各推一次。
*
* ★ 与 `setTheme` 一样:本机先生效,但**推不上去就要说"仅本机"**
* (`appearanceStatus`),不能让用户以为换设备也带着走。
*/
async setBackground(kind: string, presetId: string, dim: number, blur: number): Promise<void> {
const ctx = this.getUIContext().getHostContext();
const client: ApiClient | null = this.client;
if (ctx === undefined) {
return;
}
const store: AppearanceStore = AppearanceStore.getInstance();
const snap: AppearanceSnapshot = store.current();
snap.bgKind = kind;
snap.bgPresetId = presetId;
snap.bgDim = dim;
snap.bgBlur = blur;
if (client === null) {
this.appearanceStatus = 'local-only';
return;
}
try {
await new AppearanceApi(client).put(snap, store.wallpaper !== null);
store.saveLocal(ctx, snap);
this.appearanceStatus = 'synced';
} catch (e) {
this.appearanceStatus = 'local-only';
}
/*
* ★★ 2026-09-19 补(设备实测撞出来的**真 bug**):把"外观变了"这件事
* **发布出去**,让 `MainPage` 重算 `bgPlan` 并重画壁纸层。
*
* 原来的缺口:本页改了档位、也存进了 store,但**只有本页的 @State 变了** ——
* `MainPage` 的 `bgPlan`/`bgActive` 是启动时 `syncAppearance()` 算一次的,
* 之后没人再动它。症状:色板出现、状态显示「已同步」、服务端也真的存了,
* 而**页面背景一点没变**(壁纸层压根没画出来)。
*
* 机制沿用 `AppStorage` 单向发布(与徽标、windowInsets 同一套):
* 窗格侧**写**(这里),MainPage 侧 `@Watch` **读**(见 MainPage 的声明)。
* 不用 AppearanceStore 加订阅 —— 单例上加回调列表就要管注销,
* 而这里只需要一个"变化计数器"(revision),越简单越不容易漏。
*
* 计数而不是布尔:连续改两次压暗也应各触发一次重算
* (布尔从 true 设到 true 不产生变化通知 —— 那是"改了没反应"的经典形态)。
*/
try {
const prev: number = AppStorage.get<number>(this.KEY_APPEARANCE_REVISION) ?? 0;
AppStorage.setOrCreate(this.KEY_APPEARANCE_REVISION, prev + 1);
} catch (e) {
hilog.warn(0x0001, 'Settings', '发布外观变更失败:%{public}s', JSON.stringify(e));
}
}
/**
* 上传成功后**重新以服务端为准同步**(P4c 第③条)。
*
* ★ 刻意**不**在这里把 `bgKind` 设成 'image':服务端才是权威,
* 如果服务端因为任何原因没把档位记成 image,本地先改成 image 就会
* 显示一块取不回来的空白(`resolveBackground` 对"image 档但没图"给 none)。
* 同步回来是什么就是什么。
*/
async onWallpaperUploaded(message: string): Promise<void> {
const ctx = this.getUIContext().getHostContext();
const client: ApiClient | null = this.client;
this.getUIContext().getPromptAction().showToast({ message: message });
if (ctx === undefined || client === null) {
return;
}
const store: AppearanceStore = AppearanceStore.getInstance();
await store.syncFromServer(ctx, client);
this.adoptSnapshot(store.current(), store.statusText());
}
/** 把 store 里的快照复制进 @State(@State 观察不到类内部字段变化) */
adoptSnapshot(snap: AppearanceSnapshot, status: string): void {
this.appearanceTheme = snap.theme;
/* `system` 时要看系统反馈的色彩模式(`Theme` 是静态类,算不出这件事) */
const ctx2 = this.getUIContext().getHostContext();
if (ctx2 !== undefined) {
const mode: number = ctx2.resourceManager.getConfigurationSync().colorMode;
this.isDarkNow = isDarkMode(snap.theme, mode);
}
this.bgKind = snap.bgKind;
this.bgPresetId = snap.bgPresetId;
this.bgDim = snap.bgDim;
this.bgBlur = snap.bgBlur;
this.appearanceStatus = status;
}
/**
* 读一次当前身份,决定「管理」入口显不显示。
*
* ★ 失败**保持 false**:入口不显示,而不是「显示一个点进去 403 的东西」。
* 这里刻意不弹错误 —— 身份读不到不影响本页任何其它功能,
* 为它弹一个 toast 只会让人以为设置页坏了。
*/
async loadRole(): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null) {
return;
}
try {
const me: AdminUser = await new MeApi(client).get();
/*
* 连资料一起存下来:`/me` 返回的不只是 `role`,还有
* `status` / `created_at` / `last_login` / `allowed_agents` / `allowed_paths`。
* 原来只取了 `role`,其余字段白白拉回来就扔 —— 于是「基本资料」
* 与「权限范围」两块在界面上就无法显示,而数据一直都在。
*/
this.profile = me;
this.isAdmin = isAdminRole(me.role);
} catch (e) {
this.isAdmin = false;
}
}
/** 拉一次「客户端连接密钥」(`GET /me/keys`) */
async loadKeys(): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null) {
return;
}
this.keysLoading = true;
this.keysError = '';
try {
this.keys = await new AuthApi(client).listKeys();
} catch (e) {
const ae = e as ApiError;
this.keysError = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.keysLoading = false;
}
}
/**
* 新建一把客户端密钥。
*
* 服务端只在**创建响应**里给一次全文(之后列表只返回 `token_hint`),
* 所以必须当场存入 `newKeyToken` 让用户看得见 —— 这正是 WebUI
* `NewKeyBanner` 那句「全文仅显示这一次」的由来。
*/
async createKey(): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null || this.creatingKey) {
return;
}
this.creatingKey = true;
this.keysError = '';
try {
const key: UserKey = await new AuthApi(client).createKey(this.keyLabel.trim());
this.newKeyToken = key.key_token;
this.keyLabel = '';
await this.loadKeys();
} catch (e) {
const ae = e as ApiError;
this.keysError = ae.message.length > 0 ? ae.message : '创建失败';
} finally {
this.creatingKey = false;
}
}
/** 吹销一把密钥 */
async revokeKey(keyId: string): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null) {
return;
}
this.keysError = '';
try {
await new AuthApi(client).revokeKey(keyId);
await this.loadKeys();
} catch (e) {
const ae = e as ApiError;
this.keysError = ae.message.length > 0 ? ae.message : '吹销失败';
}
}
/**
* 修改密码(`POST /auth/password`)。
*
* 与 WebUI `AccountPage.canSubmit` 同一套前置校验:
* 旧密码非空、新密码至少 8 位、两次一致。
* 服务端在成功后**会清掉会话 Cookie** ⇒ 当前登录失效,必须告诉用户重新登录。
*/
async changePassword(): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null || this.pwBusy) {
return;
}
this.pwError = '';
this.pwMsg = '';
if (this.oldPw.length === 0) {
this.pwError = '请输入当前密码';
return;
}
if (this.newPw.length < 8) {
this.pwError = '新密码至少 8 位';
return;
}
if (this.newPw !== this.confirmPw) {
this.pwError = '两次密码不一致';
return;
}
this.pwBusy = true;
try {
await new AuthApi(client).changePassword(this.oldPw, this.newPw);
this.pwMsg = '密码已修改,请重新登录';
this.oldPw = '';
this.newPw = '';
this.confirmPw = '';
} catch (e) {
const ae = e as ApiError;
this.pwError = ae.message.length > 0 ? ae.message : '修改失败';
} finally {
this.pwBusy = false;
}
}
/**
* 退出登录。
*
* WebUI 把它放在「我的」页底部(`AccountPage` 的「登录状态」section),
* 理由原话:「低频且不可逆的动作,与密码、密钥同属『账号自身』」。
* 鸿蒙侧原来**全 App 没有退出口** —— `AuthApi.logout()` 写了但没人调。
*
* ★ 2026-09-19:流程抽到 `api/Logout.ets` 的 `performLogout()` ——
* 宽屏侧栏底部那一簇也有退出键(WebUI `Sidebar.tsx:205-211`),
* 两个入口必须做的是**同一件事**(尤其是“先注销推送 token”那一步),
* 复制一份到这里就立刻会出现“两个入口行为不一致”。
*/
async doLogout(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
await performLogout(ctx);
}
/**
* ★ 2026-09-15:接入系统通知 = **可配置项**(默认关)。
* 读一次持久化开关回填 @State,顺带看服务端有没有配推送(enabled 字段)。
* 服务端没配/读不到 → 明确告诉用户“服务器未配置推送” —— 但开关本身照常可用。
*/
async loadPushSetting(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.pushEnabled = PushService.isEnabled(ctx);
// 顺带读一次服务端 enabled(有账号时);读不到也是正常态(自部署没配凭证)
const client: ApiClient | null = this.client;
if (client === null) {
this.pushServerStatus = 'n/a';
return;
}
try {
const resp: PushTokenList = await client.get<PushTokenList>('/me/devices/push-token');
this.pushServerStatus = resp.enabled ? 'server-on' : 'server-off';
} catch (e) {
this.pushServerStatus = 'n/a';
}
}
/** 用户拨动开关:写持久化;开了就立刻补一次上报(不开就不碰推送) */
async togglePush(enabled: boolean): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.pushEnabled = enabled;
PushService.setEnabled(ctx, enabled);
if (!enabled) {
return;
}
// 开了:立刻取 token + 申请权限 + 上报(全部静默,失败下次启动再试)
const client: ApiClient | null = this.client;
if (client !== null) {
// 页面里的 `getHostContext()` 返回 `Context`;这里就是在 ability 里(UIAbilityContext 是其子类)
const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext);
push.reportToken(client).catch((err: Object) => {
hilog.info(0x0001, 'SettingsPage', '开后立即上报失败(静默):%{public}s', JSON.stringify(err));
});
}
}
/**
* 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。
*
* 这里只把结果复制进 @State 供渲染 —— 主题的**应用**(系统色彩模式)在 store 里做,
* 因为那不是页面的事:换页时主题也该保持。
*/
async loadAppearance(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
const client: ApiClient | null = this.client;
if (ctx === undefined || client === null) {
return;
}
const store: AppearanceStore = AppearanceStore.getInstance();
// 缓存按账号分:换账号时读的是那一个账号的那一份
store.loadLocal(ctx, this.activeId);
await store.syncFromServer(ctx, client);
// 背景四值一并从 store 复制进来(否则选择器显示的还是默认值,与服务端不一致)
this.adoptSnapshot(store.current(), store.statusText());
const snap: AppearanceSnapshot = store.current();
this.appearanceTheme = snap.theme;
this.appearanceStatus = store.status;
}
/** 换主题:写服务端 + 立刻应用(写失败也要让本机先跟上,并如实标"仅本机") */
async setTheme(theme: string): Promise<void> {
const ctx = this.getUIContext().getHostContext();
const client: ApiClient | null = this.client;
if (ctx === undefined) {
return;
}
const store: AppearanceStore = AppearanceStore.getInstance();
const snap: AppearanceSnapshot = store.current();
snap.theme = theme;
this.appearanceTheme = theme;
store.applyTheme(ctx, theme);
if (client === null) {
this.appearanceStatus = 'local-only';
return;
}
try {
await new AppearanceApi(client).put(snap, store.wallpaper !== null);
store.saveLocal(ctx, snap);
this.appearanceStatus = 'synced';
} catch (e) {
// 服务端没写成:本机已经生效,但状态必须说实话(否则用户以为换设备也带着走)
this.appearanceStatus = 'local-only';
}
}
refreshList(): void {
const manager: AccountManager | null = this.acctMgr;
if (manager === null) {
return;
}
this.accounts = manager.getAccounts();
this.activeId = manager.getActiveId();
}
async switchTo(accountId: string): Promise<void> {
const manager: AccountManager | null = this.acctMgr;
const client: ApiClient | null = this.client;
if (manager === null || client === null) {
return;
}
const switched: boolean = await manager.switchAccount(accountId);
if (!switched) {
return;
}
const account: AccountInfo | null = manager.getActiveAccount();
if (account !== null) {
client.setBase(account.server);
client.setToken(account.token);
this.getUIContext().getPromptAction().showToast({ message: '默认发信账号已设为 ' + account.displayName });
/*
* ★ 换账号必须给**新账号**补报一次 token。
*
* 服务端语义(pi 邮件 `2518e1a3`):同一个 token 换账号登录是**转移**,不是并存 ——
* 也就是"上一件事的登记已被转走",新账号其实**没登记过**。
* `reportToken` 的幂等标记按 `accountKey|token` 存(`PushContract.reportMarker`),
* 所以这里调一次必然真的重发,不会被"我报过"挡掉。
* 不补这一处,链路的症状是:切完账号,通知仍然推给**上一个**账号。
*/
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext);
push.reportToken(client).catch((err: Object) => {
hilog.info(0x0001, 'SettingsPage', '切账号后补报失败(静默):%{public}s', JSON.stringify(err));
});
}
}
this.refreshList();
}
async removeAccount(accountId: string): Promise<void> {
const manager: AccountManager | null = this.acctMgr;
const client: ApiClient | null = this.client;
if (manager === null) {
return;
}
const removed: boolean = await manager.removeAccount(accountId);
if (!removed) {
return;
}
SseService.getInstance().disconnectAccount(accountId);
const active: AccountInfo | null = manager.getActiveAccount();
if (client !== null) {
if (active !== null) {
client.setBase(active.server);
client.setToken(active.token);
} else {
client.setToken('');
}
}
this.refreshList();
this.getUIContext().getPromptAction().showToast({ message: '账号已删除' });
}
async addNewAccount(): Promise<void> {
const manager: AccountManager | null = this.acctMgr;
const ctx = this.getUIContext().getHostContext();
if (manager === null || ctx === undefined || this.adding) {
return;
}
const displayName: string = this.newDisplayName.trim();
const token: string = this.newToken.trim();
const optionalUsername: string = this.newUsername.trim();
if (displayName.length === 0 || token.length === 0) {
this.getUIContext().getPromptAction().showToast({ message: '请填写显示名称和 user_key' });
return;
}
/*
* ★ 地址单独校验(与登录页同一份逻辑):
* - 少了 `/api/v1` 会被补全 —— 这个入口与登录页一样是"用户手填地址"的地方;
* - 不合法就**不往多账号库里存**:那个库会直接喂给 `SseService`(`base + '/events/stream'`),
* 存进去的坏地址会让这条账号的实时通道永久连不上,而界面上看不出来。
*/
const check: ApiBaseCheck = validateApiBase(this.newServer);
if (!check.ok) {
this.getUIContext().getPromptAction().showToast({ message: check.error });
return;
}
const server: string = check.base;
// 回填:让用户看见地址被补成了什么,而不是默默变
this.newServer = server;
this.adding = true;
try {
const validationClient: ApiClient = new ApiClient(ctx);
validationClient.setBase(server);
validationClient.setToken(token);
const user = await new AuthApi(validationClient).me();
const username: string = optionalUsername.length > 0 ? optionalUsername : user.username;
const account: AccountInfo = await manager.addAccount(server, username, token, displayName);
SseService.getInstance().connectForAccount(account.id, account.server, account.token);
this.getUIContext().getPromptAction().showToast({ message: '添加成功:' + account.displayName });
this.showAddDialog = false;
this.newDisplayName = '';
this.newServer = '';
this.newUsername = '';
this.newToken = '';
this.refreshList();
} catch (e) {
const apiError = e as ApiError;
this.getUIContext().getPromptAction().showToast({ message: '验证失败: ' + apiError.message });
} finally {
this.adding = false;
}
}
build() {
Column() {
/*
* ★ 「我的」是**第四个内容窗格**,不是另开的 @Entry 页(2026-09-17 修正)。
*
* 用户:「我的页面完全没有遵守 nav 的导航规则」—— 指的就是原先的形状:
* 点底栏「我的」会 `pushUrl('pages/SettingsPage')` 推一个**独立页面**,
* 于是底部导航整条消失,用户在那页里要先「返回」才能再切换窗格。
* 而 WebUI 里 `account` 只是一个 `viewMode` —— 它和收件箱、日历一样
* 是**同一个外壳里的一个窗格**,底部导航**一直可见**(`NarrowNav` 无条件渲染)。
*
* 所以这里去掉返回键(导航条就是返回路径),标题保留。
*/
Row() {
Text('我的').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1)
Text('+').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddDialog = true; })
}
.width('100%').height(56).padding({ left: 16, right: 8 })
.backgroundColor(Theme.surface)
Divider().color(Theme.border)
/*
* ★ 整页**一个滚动容器**(对齐 WebUI `AccountPage` 的 `.overflow-y-auto`)。
*
* 原来账号列表是 `layoutWeight(1)`(占满剩余高度),后面几块 section 直接排在
* 它下面 —— 父容器高度就那么多,**排在 flex-1 之后的兄弟会被挤出可视区且无法滚到**。
* WebUI 侧把这个坑写在 `AccountPage` 的注释里:「缺了它的后果:这个页的内容
* 比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,
* 没有任何办法滚到」。账号越多 / 屏越矮时越明显,而「退出登录」正好在最下面。
*/
Scroll() {
Column() {
/* 基本资料 / 权限范围(对齐 WebUI 的前两个 section) */
this.ProfileSection()
/*
* ── 多账号(对齐 WebUI 的 AccountList section)──
*/
Column() {
Text('多账号').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.width('100%')
if (this.accounts.length === 0) {
Text('暂无账号')
.fontSize(13).fontColor(Theme.textMuted)
.width('100%').margin({ top: 10 })
Button('添加第一个账号')
.margin({ top: 12 }).backgroundColor(Theme.accent)
.onClick(() => { this.showAddDialog = true; })
} else {
Column() {
ForEach(this.accounts, (account: AccountInfo) => {
this.AccountItem(account)
}, (account: AccountInfo) => account.id)
}
.width('100%').margin({ top: 10 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
/* 外观(主题 / 壁纸) */
this.AppearanceSection()
/* 系统通知 */
this.PushSection()
/* 客户端连接密钥(对齐 WebUI 的 KeyPanel section) */
this.KeySection()
/* 修改密码 + 退出登录(对齐 WebUI 的「修改密码」与「登录状态」两段) */
this.SecuritySection()
/* 管理入口(仅管理员) */
this.AdminSection()
Blank().height(24)
}
.width('100%')
}
.width('100%').layoutWeight(1)
.scrollBar(BarState.Off)
/* WebUI `.overflow-y-auto` 的上下渐隐:内容滑到边缘不硬截断 */
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
/* 末尾让位:最后一段(退出登录)要能滚出悬浮条之下 */
.contentEndOffset(this.navReserve)
// 新增账号弹层(留在最外层,不被滚动容器裁掉)
if (this.showAddDialog) {
Column() {
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay)
.onClick(() => { this.showAddDialog = false; })
Column() {
Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.margin({ bottom: 16 })
TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newDisplayName = value; })
TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newServer = value; })
TextInput({ placeholder: '用户名(可选)', text: this.newUsername })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newUsername = value; })
TextInput({ placeholder: 'user_key(必填)', text: this.newToken })
.width('100%').height(44).margin({ bottom: 16 })
.type(InputType.Password)
.onChange((value: string) => { this.newToken = value; })
Row() {
Button('取消')
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
.onClick(() => { this.showAddDialog = false; })
Blank()
Button(this.adding ? '验证中…' : '添加')
.width(80).height(36).backgroundColor(Theme.accent)
.enabled(!this.adding)
.onClick(() => { this.addNewAccount(); })
}
.width('100%')
}
.width('100%').height('68%')
.padding(16).backgroundColor(Theme.surface)
.borderRadius({ topLeft: 12, topRight: 12 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
}
}
.width('100%').height('100%')
/* 与其它窗格同一约定:背景开着时让出页面底,壁纸才透得过 */
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
/** 分区标题(与 WebUI `AccountPage` 的 `<h3>` 同形:小字、灰、上间距) */
@Builder
SectionTitle(title: string) {
Text(title)
.fontSize(11).fontColor(Theme.textMuted)
.width('100%').margin({ bottom: 8 })
}
/** 资料行「标签 + 值」(对应 WebUI `AccountPage` 的 `<Row>`) */
@Builder
InfoRow(label: string, value: string) {
Row() {
Text(label)
.fontSize(12).fontColor(Theme.textSubtleFor())
.width(80).flexShrink(0)
Text(value)
.fontSize(14).fontColor(Theme.textPrimary)
.layoutWeight(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').margin({ bottom: 6 })
.alignItems(VerticalAlign.Top)
}
/**
* 基本资料 + 权限范围(对齐 WebUI `AccountPage` 的前两个 section)。
*
* 数据来自 `/me`(`loadRole()` 里一起存进 `profile`)——
* 服务端**一直**在返回这些字段,客户端原来只取了 `role`,其余全丢。
*/
@Builder
ProfileSection() {
Column() {
if (this.profile === null) {
Text('加载中…').fontSize(13).fontColor(Theme.textMuted).width('100%')
} else {
this.InfoRow('用户名', this.profile.username)
this.InfoRow('显示名', this.profile.display_name)
this.InfoRow('角色', this.profile.role === 'admin' ? '管理员' : '普通用户')
this.InfoRow('状态', this.profile.status === 'active' ? '启用' : '禁用')
this.InfoRow('创建时间', this.profile.created_at ?? '-')
this.InfoRow('最后登录',
(this.profile.last_login !== undefined && this.profile.last_login.length > 0)
? this.profile.last_login : '从未登录')
/*
* 权限范围:**非管理员才显示**(管理员天然不受限,显示出来只是噪声)——
* 与 WebUI 的 `{user.role !== 'admin' && (<section>…)}` 同一条条件。
*/
if (this.profile.role !== 'admin') {
Divider().color(Theme.border).margin({ top: 10, bottom: 12 })
this.SectionTitle('权限范围')
/* 空数组 = **不限**(不是“一个都不能用”)—— 与 WebUI 同义 */
this.InfoRow('可调用 Agent',
this.profile.allowed_agents.length === 0
? '不限(全部可用)' : this.profile.allowed_agents.join(', '))
this.InfoRow('可访问目录',
this.profile.allowed_paths.length === 0
? '不限(全部可用)' : this.profile.allowed_paths.join(', '))
}
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 10 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
/**
* 外观(主题 / 壁纸)—— 与 WebUI 的 `ThemePicker` + `BackgroundPicker` 两段同构。
*
* 主题只表达"偏好哪一种",深浅两套颜色**由系统给**(`setColorMode`)。
* 状态(已同步 / 仅本机)必须显示:WebUI 侧的教训是"降级不可见"。
*/
@Builder
AppearanceSection() {
Column() {
Row() {
Text('外观').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
Text(statusLabel(this.appearanceStatus))
.fontSize(11)
.fontColor(this.appearanceStatus === 'synced' ? Theme.textMuted : Theme.warnFgFor())
}
.width('100%')
Row() {
ForEach(['system', 'light', 'dark'], (t: string) => {
Text(t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色'))
.fontSize(13)
.fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary)
.backgroundColor(this.appearanceTheme === t ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => { this.setTheme(t); })
}, (t: string) => t)
}
.width('100%').margin({ top: 10 })
Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。')
.fontSize(11).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
Divider().color(Theme.border).margin({ top: 12, bottom: 12 })
/*
* 背景(P4c:不设 / 预设 / 自定义图片)。
*
* 四值用 `@Link` 双向绑(`$` 前缀是 ArkTS 的 @Link 传参语法);
* `onUserChanged` 只在**用户动作**里被调 ⇒ 这里推服务端。
*/
BackgroundPicker({
bgKind: $bgKind,
bgPresetId: $bgPresetId,
bgDim: $bgDim,
bgBlur: $bgBlur,
statusText: statusLabel(this.appearanceStatus),
onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => {
this.setBackground(kind, presetId, dim, blur);
},
onUploaded: (message: string) => { this.onWallpaperUploaded(message); },
onUploadFailed: (reason: string) => {
this.getUIContext().getPromptAction().showToast({ message: reason });
}
})
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
/**
* 系统通知(★ 2026-09-15:**可配置项**,默认关)。
*
* 用户可以自由部署自己的 agentmail:没有服务端推送凭证时,
* 关着 = 不取 token / 不弹权限 / 不打网关,零开销。
*/
@Builder
PushSection() {
Column() {
Row() {
Column() {
Text('系统通知').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Text(this.pushServerStatus === 'server-off'
? '服务器未配置推送'
: (this.pushServerStatus === 'server-on' ? '服务器已开启推送通道' : '推送通道状态未知'))
.fontSize(11)
.fontColor(this.pushServerStatus === 'server-on' ? Theme.textMuted : Theme.warnFgFor())
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.pushEnabled })
.onChange((isOn: boolean) => {
this.togglePush(isOn);
})
}
.width('100%')
Text('新邮件到达时由系统弹通知(点通知直达邮件)。未配置服务端推送时请保持关闭。')
.fontSize(11).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
/**
* 客户端连接密钥(对齐 WebUI `AccountPage` 的 `KeyPanel` section)。
*
* `AuthApi.listKeys/createKey/revokeKey` 在客户端里**一直存在但没人调用** ——
* 这一块就是让它们真的能用。
*/
@Builder
KeySection() {
Column() {
Row() {
Text('客户端连接密钥')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1)
AmIcon({ iconName: 'key', iconSize: 18, iconColor: Theme.textMuted })
}
.width('100%')
Text('第三方客户端用该密钥访问自己的邮箱(Authorization: Bearer)。它不能用于注册 Agent。')
.fontSize(11).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
if (this.keysError.length > 0) {
Text(this.keysError)
.fontSize(12).fontColor(Theme.dangerFor()).margin({ top: 8 })
}
/*
* 新签发密钥的一次性展示条。
*
* 密钥全文只在**创建响应**里出现一次(之后列表只返回 `token_hint`),
* 所以必须明确提示「关掉就再也看不到」—— 与 WebUI `NewKeyBanner` 同一口径。
*/
if (this.newKeyToken.length > 0) {
Column() {
Text('密钥已创建。全文仅显示这一次,关闭后无法再次查看。')
.fontSize(11).fontColor(Theme.warnFgFor())
.width('100%')
Text(this.newKeyToken)
.fontSize(11).fontColor(Theme.textPrimary)
.width('100%').margin({ top: 6 })
Text('关闭')
.fontSize(11).fontColor(Theme.accentFor()).margin({ top: 6 })
.onClick(() => { this.newKeyToken = ''; })
}
.width('100%').margin({ top: 10 }).padding(10)
.backgroundColor(Theme.warnBg)
.borderRadius(Theme.radiusControl)
}
/* 新建:备注 + 创建 */
Row() {
TextInput({ placeholder: '备注(如 我的笔记本 / CI 机器)', text: this.keyLabel })
.layoutWeight(1).height(40)
.onChange((v: string) => { this.keyLabel = v; })
Button(this.creatingKey ? '创建中…' : '新建')
.height(40).margin({ left: 8 })
.backgroundColor(Theme.accent).fontSize(13)
.enabled(!this.creatingKey)
.onClick(() => { this.createKey(); })
}
.width('100%').margin({ top: 10 })
if (this.keysLoading) {
LoadingProgress().width(24).height(24).margin({ top: 12 })
} else if (this.keys.length === 0) {
Text('暂无密钥').fontSize(12).fontColor(Theme.textMuted).margin({ top: 12 })
} else {
Column() {
ForEach(this.keys, (k: UserKey) => {
this.KeyRow(k)
}, (k: UserKey) => k.key_id)
}
.width('100%').margin({ top: 10 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
/** 密钥类型的中文说明(与 WebUI `KEY_TYPE_LABEL` 逐字一致) */
keyTypeLabel(t: string): string {
if (t === 'permanent') {
return '长期';
}
if (t === 'one_time') {
return '一次性';
}
if (t === 'timed') {
return '限时';
}
return t;
}
/**
* 一把密钥的状态:过期 / 已用完 / 可用。
* 与 WebUI `KeyPanel.keyState` 同一套判据(顺序也一样:先看一次性用完,再看限时过期)。
*/
keyState(k: UserKey): string {
if (k.key_type === 'one_time' && k.used_at.length > 0) {
return '已使用';
}
if (k.key_type === 'timed' && k.expires_at.length > 0) {
const exp: number = Date.parse(k.expires_at);
if (!Number.isNaN(exp) && exp < Date.now()) {
return '已过期';
}
}
return '可用';
}
/** 密钥列表里的一行 */
@Builder
KeyRow(k: UserKey) {
Row() {
Column() {
Text(k.token_hint)
.fontSize(11).fontColor(Theme.textPrimary)
Text((k.label.length > 0 ? k.label : '(无备注)') + ' · ' + this.keyTypeLabel(k.key_type))
.fontSize(11).fontColor(Theme.textMuted).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.keyState(k))
.fontSize(11)
.fontColor(k.status === 'active' ? Theme.approveFg : Theme.textSubtle)
.margin({ right: 12 })
Text('吹销')
.fontSize(12).fontColor(Theme.dangerFor())
.onClick(() => { this.revokeKey(k.key_id); })
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
.border({ width: { bottom: 1 }, color: Theme.border })
}
/**
* 修改密码 + 退出登录。
*
* WebUI 把它们分成两段(「修改密码」与「登录状态」),中间隔着密钥与外观;
* 这里合并为一片连续区域,因为两者都是「账号自身」且都是低频动作 ——
* 但**内容与顺序**与 WebUI 一致:先密码、后退出。
*/
@Builder
SecuritySection() {
Column() {
Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
Text('修改密码')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.margin({ left: 6 })
}
.width('100%')
TextInput({ placeholder: '当前密码', text: this.oldPw })
.width('100%').height(44).margin({ top: 10 })
.type(InputType.Password)
.onChange((v: string) => { this.oldPw = v; })
TextInput({ placeholder: '新密码(至少 8 位)', text: this.newPw })
.width('100%').height(44).margin({ top: 8 })
.type(InputType.Password)
.onChange((v: string) => { this.newPw = v; })
TextInput({ placeholder: '确认新密码', text: this.confirmPw })
.width('100%').height(44).margin({ top: 8 })
.type(InputType.Password)
.onChange((v: string) => { this.confirmPw = v; })
/* 两次不一致就地提示(与 WebUI `mismatch` 同一行为,不等提交才发现) */
if (this.confirmPw.length > 0 && this.newPw !== this.confirmPw) {
Text('两次密码不一致')
.fontSize(11).fontColor(Theme.dangerFor()).margin({ top: 6 })
}
if (this.pwError.length > 0) {
Text(this.pwError)
.fontSize(12).fontColor(Theme.dangerFor()).margin({ top: 8 })
}
if (this.pwMsg.length > 0) {
Text(this.pwMsg)
.fontSize(12).fontColor(Theme.approveFg).margin({ top: 8 })
}
Button(this.pwBusy ? '保存中' : '保存')
.height(40).margin({ top: 12 })
.backgroundColor(Theme.accent).fontSize(14)
.enabled(!this.pwBusy)
.onClick(() => { this.changePassword(); })
Divider().color(Theme.border).margin({ top: 16, bottom: 14 })
/*
* 退出登录。
*
* WebUI 把它放在「我的」页底部(`AccountPage` 的「登录状态」section),
* 理由原话:「低频且不可逆的动作,与密码、密钥同属『账号自身』」;
* 「窄屏下这也是唯一的退出口」。
*/
Text('登录状态').fontSize(11).fontColor(Theme.textMuted).width('100%')
Button() {
Row() {
AmIcon({ iconName: 'logout', iconSize: 16, iconColor: Theme.textPrimary })
Text('退出登录').fontSize(14).fontColor(Theme.textPrimary).margin({ left: 6 })
}
}
.height(40).margin({ top: 8 })
.backgroundColor(Theme.surfaceMuted)
.onClick(() => { this.doLogout(); })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
/**
* 管理入口(仅管理员可见)。
*
* WebUI 侧的位置是「"我的"页底部、仅管理员可见」。
* ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示:
* 乐观显示会让每个普通用户都看到一个点进去 403 的入口。
*/
@Builder
AdminSection() {
if (this.isAdmin) {
Column() {
Row() {
Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary)
.layoutWeight(1)
Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtleFor())
}
.width('100%')
Text('用户管理(新建 / 编辑 / 启停 / 重置密码)')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
.width('100%').margin({ top: 4 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' });
})
}
}
@Builder
AccountItem(account: AccountInfo) {
Row() {
Column() {
Row() {
Text(account.displayName)
.fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(account.id === this.activeId ? Theme.accent : Theme.textPrimary)
if (account.id === this.activeId) {
Text(' 默认')
.fontSize(11).fontColor(Theme.accentFor())
.backgroundColor(Theme.accentSoftFor(this.isDarkNow)).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ left: 6 })
}
}
.width('100%')
Text((account.username.length > 0 ? account.username + ' · ' : '') + account.server)
.fontSize(12).fontColor(Theme.textMuted)
.margin({ top: 3 })
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
if (account.id !== this.activeId) {
Text('设为默认')
.fontSize(12).fontColor(Theme.accentFor())
.margin({ right: 12 })
.onClick(() => { this.switchTo(account.id); })
}
Text('删除')
.fontSize(12).fontColor(Theme.dangerFor())
.onClick(() => { this.removeAccount(account.id); })
}
.width('100%').height(72)
.padding({ left: 16, right: 16 })
.alignItems(VerticalAlign.Center)
.backgroundColor(account.id === this.activeId ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.border({ width: { bottom: 1 }, color: Theme.border })
}
}