Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
JianFeeeee 13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00

621 lines
25 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 的 `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';
import { AmIcon } from '../common/Icons';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { LengthMetrics } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
@Entry
@Component
struct AdminUsersPage {
@State users: AdminUser[] = [];
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
* 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
* 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
* (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
* "顶栏被状态栏压住",同属只做一半。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@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() {
Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
Text('管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted).margin({ left: 6 })
}
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)
/* 上下边缘渐隐(2026-09-17 补:原先漏了,同一次滚动里只有部分是渐隐) */
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.padding({ left: 12, right: 12, top: 8, bottom: 12 })
}
}
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
}
@Builder
Header() {
Row() {
Text('‹').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.getUIContext().getRouter().back(); })
Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1)
// 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上
/*
* ★ 40×40 的命中区,图标居中 —— 尺寸加在**外层 Stack** 上。
*
* 原来写的是 `AmIcon({...}).width(40).height(40)`:内层容器固定 20vp 且靠左上,
* 于是 20vp 的图标贴在 40×40 命中区的左上角(同 `LoginPage` 品牌卡那处,
* 2026-09-18 一起修)。命中区本身是好的(40vp ≥ 44? 不 —— 见下),
* 但视觉上"图标没在按钮中央"是对的观感洁癖,也说明这个盒子才是按钮。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(40)
.height(40)
.onClick(() => { this.load(); })
}
/* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */
.width('100%').height(56 + topInset(this.windowInsets)).padding({ left: 8, right: 8, top: topInset(this.windowInsets) })
.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); })
}
/*
* 参数类型是 `ResourceColor` 而不是 `string`:底色/文字色可能来自**系统语义色**
* (`Theme.surfaceMuted` / `Theme.textSubtle` 是 `$r('sys.color.*')` ⇒ `Resource`),
* 也可能来自自写色值(`Theme.chipNeutralBg` ⇒ `string`)。写成 `string` 时
* `user.role === 'admin' ? Theme.chipNeutralBg : Theme.surfaceMuted` 这种三目
* 就变成 `string | Resource` 而**编译不过**(`ArkTS Compiler Error`,不是警告)。
* `fontColor`/`backgroundColor` 本来就收 `ResourceColor`,所以放宽参数类型即可,
* 不需要把系统色抄成字符串——那正是这个文件要避免的事。
*/
@Builder
Chip(text: string, bg: ResourceColor, fg: ResourceColor) {
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)
}
}