/* * 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 { 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 { 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 { 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 { 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 { 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 { 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) } }