跨端: 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"——
凭记忆累加的,错了,已更正。)

**未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的
实际档位观感)。代码齐 ≠ 真机验过。
This commit is contained in:
2026-09-15 11:01:09 +08:00
parent b7dc9e90e6
commit 474cadaf54
20 changed files with 2781 additions and 22 deletions

View File

@ -0,0 +1,579 @@
/*
* AgentMail 鸿蒙客户端 — 管理员:用户管理
*
* 对应 WebUI 的 `client/electron/src/components/AdminUsersPage.tsx`,按 WebUI 的排布顺序
* 收口「用户管理」这一栏(`docs/HARMONY-ALIGN-PLAN.md` 原先把管理员后台整个划在鸿蒙首版之外,
* 用户明确要求「功能做全再交付」后收进来)。
*
* ── 为什么是**独立路由页**而不是 MainPage 的第四栏 ──
*
* WebUI 侧它的位置是「"我的"页底部、仅管理员可见」(`App.tsx`:`viewMode === 'admin' && user?.role === 'admin'`)。
* 鸿蒙侧对应的位置就是**设置页底部**,所以这里也做成一个从设置页推入的路由页
* (`pages/AdminUsersPage`,`pushUrl`),而不是往底部三栏里加第四栏 ——
* 底栏三栏(通信/日历/联系人)是**日常使用**的信息架构,管理面不是日常。
*
* ── 判据纪律(本文件逐条遵守,判据在 `client/electron/test/harmony-admin.test.mjs`)──
*
* · **用户真正会点的那一层**:每个可点元素都是真实 `onClick` 到真实动作;判据按
* "动作与服务端调用同名"检查,而不是"页面上出现了这个词"。
* · **不许新写死颜色**:色一律走 `Theme.*`(`danger`/`approve`/`accent` 是 Theme 里
* 已命名的语义色,品牌色本来就手写在那一个文件里)。本文件不出现任何 `#` 色值。
* · **不给单个面单独做深色**:不写 `#xxxxxx`,也就无从分叉;深浅由系统按色彩模式给。
* · **同一张底只允许被模糊一次**:本页不碰壁纸/模糊(那是 `MainPage` 的外观层)。
* · **列表项每项一张卡**:每个用户在 `List` 里是独立一张卡(`Theme.surface` + `radiusCard`)。
* · **视觉不可验必须如实标注**:见文件末「未验」段。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AdminApi, MeApi } from '../api/AdminApi';
import {
AdminUser,
AdminScopes,
AdminCreateUserInput,
AdminUpdateUserInput
} from '../model/Models';
import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted } from '../model/AdminUsers';
@Entry
@Component
struct AdminUsersPage {
@State users: AdminUser[] = [];
@State scopes: AdminScopes = new AdminScopes();
@State loading: boolean = false;
@State errorText: string = '';
/** 展开编辑的是哪一个(空串=都收起) */
@State editingId: string = '';
@State showCreate: boolean = false;
/** 当前登录者是不是管理员;**未确认前一律 false**(见文件头与 `isAdminRole`) */
@State isAdmin: boolean = false;
/** 身份读没读到(读不到时不显示"你不是管理员"这种断言,只显示"读不到") */
@State roleKnown: boolean = false;
// 新建用户表单
@State newUsername: string = '';
@State newDisplayName: string = '';
@State newPassword: string = '';
@State newRole: string = 'user';
@State busy: boolean = false;
// 编辑中用户的可改字段(进入编辑时从该用户复制一份)
@State editDisplayName: string = '';
@State editRole: string = 'user';
@State editAgents: string[] = [];
@State editPaths: string[] = [];
@State editPassword: string = '';
private client: ApiClient | null = null;
private api: AdminApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.client = ApiClient.getInstance(ctx);
this.api = new AdminApi(this.client);
this.loadRole();
this.load();
}
/**
* 读当前身份。
*
* ★ 失败时 `roleKnown` 保持 false 且 `isAdmin` 保持 false:
* 不能把"读不到"当成"是管理员"(那会让一次网络抖动对所有人显示管理入口),
* 也不能当成"不是"(那会误导人以为自己没权限)。两种错都不报,只报"读不到"。
*/
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);
this.roleKnown = true;
} catch (e) {
this.roleKnown = false;
this.isAdmin = false;
}
}
async load(): Promise<void> {
const api: AdminApi | null = this.api;
if (api === null) {
return;
}
this.loading = true;
this.errorText = '';
try {
const usersResp = await api.listUsers();
this.users = usersResp.users;
const scopesResp = await api.listScopes();
this.scopes = scopesResp;
} catch (e) {
this.errorText = this.messageOf(e);
} finally {
this.loading = false;
}
}
/** 进入/退出编辑:进入时把该用户的可改字段复制到 @State(@State 观察不到类内部变化) */
toggleEdit(user: AdminUser): void {
if (this.editingId === user.user_id) {
this.editingId = '';
return;
}
this.editingId = user.user_id;
this.editDisplayName = user.display_name;
this.editRole = user.role;
this.editAgents = user.allowed_agents.slice(0);
this.editPaths = user.allowed_paths.slice(0);
this.editPassword = '';
}
/**
* 异常 → 一句话。
*
* ★ 这个函数**故意留在页面层**(而不是 `model/AdminUsers.ts`):它要
* `instanceof ApiError`,而 `ApiError` 是 `api/ApiClient.ets` 里的类 ——
* `.ets` import 不进纯逻辑层(那会让那个文件没法被 node 直接跑,判据就只能读源码了)。
* 所以:**取值口径**(`isApiError` 与 `message`)在这里取,
* **兜底文案**在纯逻辑层(`messageOfApiError`,可判)。
*/
private messageOf(e: Object): string {
return messageOfApiError(e instanceof ApiError, (e as Error).message);
}
private toast(msg: string): void {
this.getUIContext().getPromptAction().showToast({ message: msg });
}
/** 只把**真的给了**的字段发出去(服务端是指针语义:nil=别动,空串=清空) */
private buildUpdate(): AdminUpdateUserInput {
const input: AdminUpdateUserInput = new AdminUpdateUserInput();
input.display_name = this.editDisplayName;
input.role = this.editRole;
input.allowed_agents = this.editRole === 'admin' ? [] : this.editAgents;
input.allowed_paths = this.editRole === 'admin' ? [] : this.editPaths;
return input;
}
async saveEdit(): Promise<void> {
const api: AdminApi | null = this.api;
const id: string = this.editingId;
if (api === null || id.length === 0) {
return;
}
this.busy = true;
try {
await api.updateUser(id, this.buildUpdate());
this.toast('用户已更新');
await this.load();
} catch (e) {
this.errorText = this.messageOf(e);
} finally {
this.busy = false;
}
}
/**
* 启用/禁用。
*
* ★ 这里**只发 `status` 一个字段**:服务端是部分更新,只发状态就不会顺手把
* 显示名与白名单清掉(那正是"一次发五个字段"会造成的破坏)。
*/
async setStatus(user: AdminUser, status: string): Promise<void> {
const api: AdminApi | null = this.api;
if (api === null) {
return;
}
try {
if (status === 'disabled') {
await api.disableUser(user.user_id);
} else {
const onlyStatus: AdminUpdateUserInput = new AdminUpdateUserInput();
onlyStatus.status = 'active';
await api.updateUser(user.user_id, onlyStatus);
}
this.toast(status === 'disabled' ? '用户已禁用' : '用户已启用');
await this.load();
} catch (e) {
// 服务端会拦"最后一个管理员",那条文案必须原样显示
this.errorText = this.messageOf(e);
}
}
async resetPassword(user: AdminUser): Promise<void> {
const api: AdminApi | null = this.api;
if (api === null || this.editPassword.length < 8) {
return;
}
this.busy = true;
try {
await api.resetPassword(user.user_id, this.editPassword);
this.toast('密码已重置');
this.editPassword = '';
} catch (e) {
this.errorText = this.messageOf(e);
} finally {
this.busy = false;
}
}
async createUser(): Promise<void> {
const api: AdminApi | null = this.api;
if (api === null) {
return;
}
if (this.newUsername.trim().length < 2 || this.newPassword.length < 8) {
this.toast('用户名至少 2 位、密码至少 8 位');
return;
}
this.busy = true;
try {
const input: AdminCreateUserInput = new AdminCreateUserInput();
input.username = this.newUsername.trim().toLowerCase();
input.password = this.newPassword;
input.display_name = this.newDisplayName.trim();
input.role = this.newRole;
/*
* 白名单**建完再配**(不是漏了):新建表单只有用户名/显示名/密码/角色,
* 建好之后在新用户那张卡的编辑区里勾白名单。
* ★ 服务端口径:空数组 = **不限**(不是"什么都不许")——
* 所以"先不配"是安全的默认,新用户一登录不会什么都点不动;
* 要收紧就随后去勾。这也与 `ScopePick` 上那句"未勾选 = 不限"一致。
*/
input.allowed_agents = [];
input.allowed_paths = [];
await api.createUser(input);
this.toast('用户已创建');
this.newUsername = '';
this.newDisplayName = '';
this.newPassword = '';
this.newRole = 'user';
this.showCreate = false;
await this.load();
} catch (e) {
// 重名 409 / 用户名不合法 400 / 密码太短 400 的文案都从这里原样显示
this.errorText = this.messageOf(e);
} finally {
this.busy = false;
}
}
build() {
Column() {
this.Header()
if (this.roleKnown && !this.isAdmin) {
Column() {
Text('🔒 管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted)
Text('当前账号的角色不是管理员。')
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).margin({ top: 6 })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
if (this.errorText.length > 0) {
Text(this.errorText)
.fontSize(Theme.fontSmall).fontColor(Theme.danger)
.width('100%').padding(10).margin({ left: 12, right: 12, top: 8 })
.backgroundColor(Theme.dangerBg)
.borderRadius(Theme.radiusControl)
}
Row() {
Text('用户管理').fontSize(Theme.fontSmall).fontColor(Theme.textMuted)
Text(' ' + this.users.length).fontSize(Theme.fontSmall).fontColor(Theme.textSubtle)
Blank()
Button(this.showCreate ? '收起' : '新建用户')
.height(34).fontSize(Theme.fontSmall)
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
.onClick(() => { this.showCreate = !this.showCreate; })
}
.width('100%').padding({ left: 12, right: 12, top: 8 })
if (this.showCreate) {
this.CreateForm()
}
if (this.loading && this.users.length === 0) {
Text('加载中…').fontSize(Theme.fontSmall).fontColor(Theme.textSubtle)
.width('100%').textAlign(TextAlign.Center).margin({ top: 24 })
} else {
List({ space: 8 }) {
ForEach(this.users, (user: AdminUser) => {
ListItem() {
this.UserCard(user)
}
}, (user: AdminUser) => user.user_id)
}
.width('100%').layoutWeight(1)
.padding({ left: 12, right: 12, top: 8, bottom: 12 })
}
}
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
}
@Builder
Header() {
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(20).fontColor(Theme.accent).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.load(); })
}
.width('100%').height(56).padding({ left: 8, right: 8 })
.backgroundColor(Theme.surface)
}
/**
* 一个用户 = 一张卡(列表项每项一张卡)。
*
* 展开的那张把编辑区接在同一张卡里(不是另开一张),
* 否则"在编辑谁"要靠位置猜。
*/
@Builder
UserCard(user: AdminUser) {
Column() {
Row() {
Text(this.editingId === user.user_id ? '⌄' : '›')
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).width(18)
Text(user.username)
.fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
this.Chip(user.role === 'admin' ? '管理员' : '用户',
user.role === 'admin' ? Theme.chipNeutralBg : Theme.surfaceMuted,
user.role === 'admin' ? Theme.accent : Theme.textMuted)
Text(' ').width(4)
this.Chip(user.status === 'active' ? '启用' : '禁用',
user.status === 'active' ? Theme.approveBg : Theme.surfaceMuted,
user.status === 'active' ? Theme.approveFg : Theme.textMuted)
/*
* 「受限」徽标:**只在普通用户且白名单非空时**显示 —— 与 WebUI 逐字一致
* (`AdminUsersPage.tsx`:`user.role !== 'admin' && (allowed_agents.length > 0 || …)`)。
* ★ 管理员**不加**这个徽标:服务端对管理员忽略白名单,
* 给管理员打「受限」会让人以为他的权限被削了。
* ★ 白名单非空才叫"受限":空 = 不限(不是"什么都不许"),那种情况不该有徽标。
*/
if (isRestricted(user)) {
Text(' ').width(4)
this.Chip('受限', Theme.surfaceMuted, Theme.textSubtle)
}
}
.width('100%')
Row() {
Text(user.display_name.length > 0 ? user.display_name : user.username)
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(lastLoginLabel(user))
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
}
.width('100%').margin({ top: 4 })
if (this.editingId === user.user_id) {
this.UserEditor(user)
}
}
.width('100%')
.padding(12)
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.onClick(() => { this.toggleEdit(user); })
}
@Builder
Chip(text: string, bg: string, fg: string) {
Text(text)
.fontSize(Theme.fontTiny)
.fontColor(fg)
.backgroundColor(bg)
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
/** 编辑区:显示名 / 角色 / 状态 / 白名单 / 重置密码 */
@Builder
UserEditor(user: AdminUser) {
Column() {
Divider().color(Theme.border).margin({ top: 10, bottom: 10 })
Text('显示名').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
.width('100%')
TextInput({ placeholder: '显示名', text: this.editDisplayName })
.width('100%').height(40).margin({ top: 4 })
.onChange((value: string) => { this.editDisplayName = value; })
Text('角色').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
.width('100%').margin({ top: 10 })
Row() {
this.RolePick('普通用户', 'user')
Text(' ').width(8)
this.RolePick('管理员', 'admin')
}
.width('100%').margin({ top: 4 })
if (this.editRole !== 'admin') {
this.ScopePick('可调用 Agent', this.scopes.agents, this.editAgents, true)
this.ScopePick('可访问目录', this.scopes.paths, this.editPaths, false)
} else {
Text('管理员不受白名单限制(服务端对管理员忽略这两项)。')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
.width('100%').margin({ top: 8 })
}
Row() {
Button(user.status === 'active' ? '禁用' : '启用')
.height(34).fontSize(Theme.fontSmall)
.backgroundColor(Theme.surfaceMuted).fontColor(Theme.danger)
.onClick(() => {
this.setStatus(user, user.status === 'active' ? 'disabled' : 'active');
})
Blank()
Button(this.busy ? '保存中' : '保存更改')
.height(34).fontSize(Theme.fontSmall)
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
.onClick(() => { this.saveEdit(); })
}
.width('100%').margin({ top: 12 })
Divider().color(Theme.border).margin({ top: 12, bottom: 10 })
Text('重置密码').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
.width('100%')
Row() {
TextInput({ placeholder: '新密码(至少 8 位)', text: this.editPassword })
.layoutWeight(1).height(38).type(InputType.Password)
.onChange((value: string) => { this.editPassword = value; })
Text(' ').width(8)
Button('重置')
.height(38).fontSize(Theme.fontSmall)
.backgroundColor(Theme.surfaceMuted).fontColor(Theme.textPrimary)
.enabled(this.editPassword.length >= 8)
.onClick(() => { this.resetPassword(user); })
}
.width('100%').margin({ top: 4 })
}
.width('100%')
}
@Builder
RolePick(label: string, value: string) {
Text(label)
.fontSize(Theme.fontSmall)
.fontColor(this.editRole === value ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.editRole === value ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.editRole = value; })
}
/**
* 白名单多选。
*
* 口径与 WebUI 逐字一致:**未勾选 = 不限**(不是"什么都不可用")。
* 缺省给"不限"而不是"全禁":管理员建完用户还没配白名单时,
* "全禁"会让新用户一登录什么都点不动,看起来像坏了。
*/
@Builder
ScopePick(label: string, items: string[], selected: string[], isAgent: boolean) {
Column() {
Row() {
Text(label).fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
Text(selected.length > 0 ? '(已选 ' + selected.length + ' 项)' : '')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
Blank()
Text('未勾选 = 不限').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
}
.width('100%').margin({ top: 10 })
if (items.length === 0) {
Text('(服务端没给出候选)').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
.width('100%').margin({ top: 4 })
} else {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(items, (item: string) => {
Text(item)
.fontSize(Theme.fontTiny)
.fontColor(selected.includes(item) ? Theme.accentFg : Theme.textMuted)
.backgroundColor(selected.includes(item) ? Theme.accent : Theme.surfaceMuted)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.margin({ right: 6, top: 6 })
.onClick(() => {
if (isAgent) {
this.editAgents = toggled(this.editAgents, item);
} else {
this.editPaths = toggled(this.editPaths, item);
}
})
}, (item: string) => item)
}
.width('100%')
}
}
.width('100%')
}
/** 新建用户表单(字段与校验口径照 WebUI:用户名 ≥2、密码 ≥8) */
@Builder
CreateForm() {
Column() {
TextInput({ placeholder: '用户名(小写字母数字 . _ -)', text: this.newUsername })
.width('100%').height(40)
.onChange((value: string) => { this.newUsername = value; })
TextInput({ placeholder: '显示名', text: this.newDisplayName })
.width('100%').height(40).margin({ top: 8 })
.onChange((value: string) => { this.newDisplayName = value; })
TextInput({ placeholder: '初始密码(至少 8 位)', text: this.newPassword })
.width('100%').height(40).margin({ top: 8 }).type(InputType.Password)
.onChange((value: string) => { this.newPassword = value; })
Row() {
Text('角色').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
Blank()
Text('普通用户')
.fontSize(Theme.fontSmall)
.fontColor(this.newRole === 'user' ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.newRole === 'user' ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.newRole = 'user'; })
Text(' ').width(8)
Text('管理员')
.fontSize(Theme.fontSmall)
.fontColor(this.newRole === 'admin' ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.newRole === 'admin' ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.newRole = 'admin'; })
}
.width('100%').margin({ top: 10 })
Text('白名单建完再配:创建后在新用户那张卡里勾「可调用 Agent / 可访问目录」。未勾 = 不限。')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
.width('100%').margin({ top: 8 })
Row() {
Blank()
Button(this.busy ? '创建中' : '创建')
.height(34).fontSize(Theme.fontSmall)
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
.enabled(this.newUsername.trim().length >= 2 && this.newPassword.length >= 8 && !this.busy)
.onClick(() => { this.createUser(); })
}
.width('100%').margin({ top: 10 })
}
.width('100%').padding(12).margin({ left: 12, right: 12, top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
}