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"—— 凭记忆累加的,错了,已更正。) **未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的 实际档位观感)。代码齐 ≠ 真机验过。
533 lines
21 KiB
Plaintext
533 lines
21 KiB
Plaintext
/*
|
||
* 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)
|
||
}
|
||
}
|