Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/AdminUsers.ts
JianFeeeee 474cadaf54 跨端: 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"——
凭记忆累加的,错了,已更正。)

**未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的
实际档位观感)。代码齐 ≠ 真机验过。
2026-09-15 11:03:22 +08:00

140 lines
5.9 KiB
TypeScript
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.

/*
* 管理员页的**纯逻辑**:白名单勾选、最后登录文案、异常→文案。
*
* ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。
*
* ── 为什么不在 `pages/AdminUsersPage.ets` 里 ──
*
* 两个原因,第二个是硬的:
* ① 本仓库的页面文件清一色**只导出那个 struct**(`LoginPage`/`MainPage`/`SettingsPage`…
* 没有一个 `export function`)。在页面里导出工具函数是不合流的写法;
* ② 页面是 `.ets`,判据**跑不了**它 —— 只有纯逻辑放在 `.ts` 里,
* `node --experimental-strip-types` 才 import 得动(`Wallpaper.ts`/`Calendar.ts`
* 是同一个模式)。这三条逻辑都有"能悄悄错"的地方,值得被判据钉住。
*/
/**
* 勾选/取消一个项,返回**新数组**(不改原数组)。
*
* ★ 必须返回新数组:ArkUI 的 `@State` 靠**引用变化**触发重渲染,
* 原地 `push`/`splice` 改同一个数组**不会**刷新界面 ——
* 表现是"点了没反应",而数据其实已经改了(最难查的一类)。
* ★ 也不改入参:入参可能是另一个 @State 的当前值,就地改会让两处状态互相污染。
*/
export function toggled(list: string[], item: string): string[] {
const out: string[] = [];
let found: boolean = false;
for (let i = 0; i < list.length; i++) {
if (list[i] === item) {
found = true;
} else {
out.push(list[i]);
}
}
if (!found) {
out.push(item);
}
return out;
}
/**
* 本文件只吃**最小的结构**(而不是 `model/Models.ets` 里的 `AdminUser`):
* 见文件末的说明 —— 本文件不许 import,所以字段就地声明。
* 调用方传 `AdminUser` 靠**结构相容**即可,不需要 `as`。
*/
export interface LoginShape {
last_login?: string;
}
/**
* 最后登录的显示文案。
*
* ★ 服务端那个字段是 `json:"last_login,omitempty"`:**缺席**与**空串**都表示"从未登录",
* 两者都要当成"从未登录"显示。只判 `undefined` 会让空串在界面上留下一块空白,
* 看起来像"读取失败"。
*/
export function lastLoginLabel(user: LoginShape): string {
const v: string | undefined = user.last_login;
if (v === undefined || v.length === 0) {
return '从未登录';
}
return v;
}
/*
* ── 这里**为什么没有** "异常 → 文案" 那个函数 ──
*
* 它要 `ApiError`(`api/ApiClient.ets` 里的类),而 `.ets` **import 不进来** ——
* 本目录下的 `Wallpaper.ts`/`Calendar.ts`/`Appearance.ts` 全都是**一个 import 都没有**,
* 那正是它们能被 `node --experimental-strip-types` 直接跑起来的原因
* (判据 `harmony-*.test.mjs` 靠的就是这条路)。
* 一旦这里 import 了 `.ets`,本文件就**从"能真跑"退化成"只能读源码"**,
* 而它里面这几条都值得真跑。
*
* 所以拆成两半:
* · **取值与显示口径**(本文件,可跑):`lastLoginLabel` / `toggled` / `isAdminRole` / `isRestricted` / `messageOfApiError`;
* · **异常归一**(页面层,要 `instanceof ApiError`):见
* `pages/AdminUsersPage.ets` 里那个 `messageOf` —— 那种写法在本仓库已有先例
* (`ComposePage.ets` / `CalendarPage.ets` 都是 `e as ApiError` 就地取 message)。
*/
/**
* 服务端文案的**兜底口径**(纯函数:只吃基元,不吃异常对象)。
*
* ★ 服务端 400/409 的中文文案("该名称已被用户或 Agent 占用"/"密码至少 8 位"/
* "不能禁用最后一个管理员")**必须原样透出**,不要改写成"操作失败" ——
* 管理页的失败原因几乎都是"人能立刻改的东西",吞掉就只剩反复试。
* ★ 只有**真的没有**文案时才用兜底句,并且要说明"服务端没给原因",
* 否则用户分不清"服务端说不行"和"客户端没收到"。
*/
export function messageOfApiError(isApiError: boolean, message: string): string {
if (isApiError && message.length > 0) {
return message;
}
if (!isApiError && message.length > 0) {
return message; // 本地异常(网络层抛的)也有 message,一样给用户看
}
return '操作失败(服务端没有给原因)';
}
/**
* 该用户是否受白名单限制(卡片上打「受限」徽标的条件)。
*
* ★ 口径与 WebUI 逐字一致:`role !== 'admin' && (allowed_agents.length > 0 || allowed_paths.length > 0)`。
* **空 = 不限**(不是"什么都不许")—— 所以"全空"不叫受限,不该有徽标。
* ★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导。
*
* ⚠️ 这里只吃一个最小的结构(而不是 `AdminUser`):本文件不许有 import
* (见文件头),所以字段就地声明。调用方传 `AdminUser` 靠**结构相容**,
* 不需要 `as`,也不会因此把这个文件从"能真跑"变成"只能读源码"。
*/
export interface RoleShape {
role: string;
}
/**
* 是否应当显示管理入口。
*
* ★ 取值口径与 WebUI 逐字一致(`App.tsx`:`user?.role === 'admin'`):**严格相等**。
* ★ 不要把"读不到 role"也放行 —— 那会让任何一次 `/me` 失败都变成"对所有人显示管理入口",
* 点进去一片 403;也不能反过来当成"不是管理员"来自证:调用方要**分开**表达
* "读不到"(`SettingsPage` 的 `isAdmin` 初值 false + `AdminUsersPage` 的 `roleKnown`)。
* 这个函数只管**判断**,不管"读不到时怎么办"。
*/
export function isAdminRole(role: string | undefined): boolean {
return role === 'admin';
}
export interface RestrictedShape {
role: string;
allowed_agents: string[];
allowed_paths: string[];
}
export function isRestricted(user: RestrictedShape): boolean {
if (user.role === 'admin') {
return false;
}
return user.allowed_agents.length > 0 || user.allowed_paths.length > 0;
}