## 一、真 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`。
621 lines
25 KiB
Plaintext
621 lines
25 KiB
Plaintext
/*
|
||
* 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)
|
||
}
|
||
}
|