Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/SettingsPage.ets
JianFeeeee 474cadaf54 跨端: harmony 管理页(用户管理)+ P4c 壁纸上传入口 —— 「功能做全再交付」的两块
pi 的交付清单里缺的两块(`docs/GUI-PLAN-HARMONY.md` 原先把管理后台划在首版之外,
用户明确要求「功能做全再给我」之后收进来)。

标 `跨端:` 是因为本次的判据落在 `client/electron/test/`(鸿蒙的判据目录一向量在那里),
代码本体全在 `client/harmony/`。

## 管理页(用户管理)

- `pages/AdminUsersPage.ets`:新建 / 编辑(显示名·角色·白名单)/ 启停 / 重置密码。
  排布照 `AdminUsersPage.tsx`,包括「受限」徽标的口径(普通用户且白名单非空才显示)、
  最后登录缺席与空串都显示「从未登录」、管理员对白名单两项忽略。
- 入口在设置页底部,**仅管理员可见**(`role === 'admin'` 严格相等,与 `App.tsx` 同口径)。
  读不到身份时**不**显示也不报错(乐观放行会让每个普通用户看到一个点进去 403 的入口)。
- `api/AdminApi.ets` + `model/AdminUsers.ts`(纯逻辑,零 import ⇒ 判据能真跑)。
- 启停**只发 status 一个字段** —— 服务端是部分更新,多发字段会把显示名与白名单一起改掉。
- `model/Models.ets` 补管理端 DTO;`main_pages.json` 注册路由。

## P4c 壁纸上传

- `model/ImagePrep.ts`:阈值与两档策略(2560/0.85 → 1280/0.78,入口 20MB,压后上限 3.5MiB)。
  **一处有意不对齐 WebUI** 并写明理由:WebUI 卡 data-URL 长度(含 base64 膨胀),
  鸿蒙内存直传 ArrayBuffer,卡的是字节数。
- `common/BackgroundPicker.ets`:不设 / 预设 / 自定义图片 + 浓度与模糊滑杆。
  上传链:picker → 判可不可以 → 逐档按 desiredSize 解码压缩 → 上传 → **请页面以服务端为准重新同步**。
  失败**必带原因**(服务端 415/413 文案原样透出)。
- `ApiClient.uploadBytes`:MultiFormData.data 收 ArrayBuffer(核了 SDK,since 11;本工程 23)
  ⇒ 内存直传,不需要 base64、也不需要临时文件。
- 用户取消选图**不算失败**,什么都不说。

## 顺带修掉的两处真问题(都是变异测试逼出来的)

1. **压缩循环的第二档此前是死代码**:循环里的 break 与循环外那句 shouldRetryWithActual
   互相抵消 —— 把循环里那处改成 `if (true)`(永远只压一档)整套判据照样全绿。
   收成一处判定(overLimit),循环外只读结论。
2. **壁纸的模糊档一直是「只写不读」**(计划文档 §7.12 登记过):滑杆能拖、值能存、
   blurStyleFor 也写了,就是**没有调用点**,壁纸一点没糊。本次补上调用点
   (壁纸层 .blur(px) = 图片内容模糊;导航条材质由 blurStyleFor 映射)。
   同时按 §7.12 的原承诺更新了那一行。

## 一并修正的旧判据(都是"太宽/太窄",不是放宽标准)

- 「模糊归属」:原文「壁纸层不许有**任何**模糊调用」把**图片内容模糊**与**面板材质**
  混为一谈(WebUI 侧核实:.app-backdrop 的 filter 与它之上那层的 backdrop-filter
  是两个不同的量)⇒ 改成按两种模糊分别钉。
- 「bgBlur 只写不读,消费侧必须为 0」:值不再成立,**形状保留**(逐文件登记 + 计数 + 理由),
  标题与断言里的假话一并改掉。
- isDarkMode 那条 `/dark\s*\)/` 断的是**参数顺序**(加一个入参就误红)⇒ 改成"dark 在实参里"。
- 导航材质三处断言原本钉 `Theme.navMaterial` 字面量 ⇒ 改成钉新的映射写法。

## 判据

新增 `harmony-admin.test.mjs`(22 条)、`harmony-imageprep.test.mjs`(29 条);
`harmony-presets.test.mjs` 加 1 条(模糊档搬运与归一,含 -0 那个洞)。
全量 203 条:**201 通过**,2 条失败为**改动前就红**的既有项
(BUILD_INFO 比对、词表↔余额)—— 用 stash 对照验证过。

两个新判据文件上跑了 **48 个变异体,全部被抓**(含"接线"类:删掉「受限」徽标、
组件自己宣布成功、release 不 await、按原图尺寸解码…),
其中 2 个变异体**红不了**,因此又补了 5 条判据(纯逻辑接线、退档判定只有一处、
两档都超限必拒、解码尺寸用的是目标尺寸而非原图尺寸、模糊档搬运)。
(数字口径:按 runner 的真实条件"锚点恰好命中 1 次才算跑过"统计;
另有 4 条锚点不命中、根本没跑,不算在这 48 里。我第一次写的是"40"——
凭记忆累加的,错了,已更正。)

**未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的
实际档位观感)。代码齐 ≠ 真机验过。
2026-09-15 11:03:22 +08:00

533 lines
21 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 鸿蒙客户端 — 设置页(账号管理)
* 多账号列表 / 添加新账号 / 删除 / 切换默认账号
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AuthApi } from '../api/AuthApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { AppearanceSnapshot, statusLabel } from '../model/Appearance';
import { MeApi } from '../api/AdminApi';
import { AdminUser } from '../model/Models';
import { isAdminRole } from '../model/AdminUsers';
import { BackgroundPicker } from '../common/BackgroundPicker';
@Entry
@Component
struct SettingsPage {
@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';
@State appearanceStatus: string = 'local-only';
/** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */
@State isAdmin: boolean = false;
/*
* 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改,
* 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。
*
* ★ 为什么**不**用「页面 @Watch 到 bgKind/bgDim… 变化就推服务端」:
* `adoptSnapshot` 把**服务端**的值复制进来时也会触发那个 @Watch ⇒
* 推一次服务端 ⇒ 再同步 ⇒ 再触发…… 要么死循环,要么得再加一个抑制标志,
* 而那个标志本身又是一处能忘掉的状态。
* 改成"选择器在**用户改值**时显式回调"之后,方向是单向的:
* 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。**
*/
@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();
});
}
}
/**
* 用户改了背景 ⇒ 推一次服务端(与 `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';
}
}
/**
* 上传成功后**重新以服务端为准同步**(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;
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();
this.isAdmin = isAdminRole(me.role);
} catch (e) {
this.isAdmin = false;
}
}
/**
* 拉一次外观:按合并规则落地(规则在 `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();
}
normalizeServer(server: string): string {
let normalized: string = server.trim();
while (normalized.length > 0 && normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
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 });
}
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 server: string = this.normalizeServer(this.newServer);
const token: string = this.newToken.trim();
const optionalUsername: string = this.newUsername.trim();
if (displayName.length === 0 || server.length === 0 || token.length === 0) {
this.getUIContext().getPromptAction().showToast({ message: '请填写显示名称、Gateway 地址和 user_key' });
return;
}
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() {
Row() {
Text('‹').fontSize(24).fontColor(Theme.accent).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.getUIContext().getRouter().back(); })
Text('账号管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1)
Text('+').fontSize(24).fontColor(Theme.accent).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddDialog = true; })
}
.width('100%').height(56).padding({ left: 8, right: 8 })
.backgroundColor(Theme.surface)
Divider().color(Theme.border)
if (this.accounts.length === 0) {
Column() {
Text('📭 暂无账号').fontSize(16).fontColor(Theme.textMuted)
Button('添加第一个账号')
.margin({ top: 16 }).backgroundColor(Theme.accent)
.onClick(() => { this.showAddDialog = true; })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.accounts, (account: AccountInfo) => {
ListItem() {
this.AccountItem(account)
}
.height(72)
}, (account: AccountInfo) => account.id)
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 })
}
/*
* ── 外观(主题 / 壁纸)──
*
* 主题只表达"偏好哪一种",深浅两套颜色**由系统给**(`setColorMode`):
* 这就是「用系统方案」在这里的意思 —— 不自己维护一套深色色值。
*
* 状态(已同步 / 仅本机)必须显示:WebUI 侧的教训是"降级不可见",
* 用户以为换设备也能带走,打开另一台才发现没有。
*/
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.warnFg)
}
.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.textSubtle).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)
/*
* ── 管理入口(仅管理员可见)──
*
* WebUI 侧的位置是「"我的"页底部、仅管理员可见」(`App.tsx`:
* `viewMode === 'admin' && user?.role === 'admin'`)。鸿蒙侧对应的位置就是这里。
*
* ★ 可见性来自 `/me` 的 `role`,口径与 WebUI 严格一致(`isAdminRole`)。
* ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示:
* 乐观显示会让每个普通用户都看到一个点进去 403 的入口。
* 代价是"管理员在网络抖动时看不到入口",那是可接受的(刷新即恢复)——
* 反过来则是每个人都被误导。
*/
if (this.isAdmin) {
Column() {
Row() {
Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary)
.layoutWeight(1)
Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtle)
}
.width('100%')
Text('用户管理(新建 / 编辑 / 启停 / 重置密码)')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
.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' });
})
}
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 地址(必填)', 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(Theme.pageBg)
}
@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.accent)
.backgroundColor(Theme.accentSoft).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).height('100%')
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
if (account.id !== this.activeId) {
Text('设为默认')
.fontSize(12).fontColor(Theme.accent)
.margin({ right: 12 })
.onClick(() => { this.switchTo(account.id); })
}
Text('删除')
.fontSize(12).fontColor(Theme.danger)
.onClick(() => { this.removeAccount(account.id); })
}
.width('100%').height('100%')
.padding({ left: 16, right: 16 })
.alignItems(VerticalAlign.Center)
.backgroundColor(account.id === this.activeId ? Theme.accentSoft : Theme.surface)
}
}