跨端: 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,132 @@
/*
* AgentMail 鸿蒙客户端 — 管理员 API(用户管理)
*
* 端点与 WebUI(`client/electron/src/api/client.ts`)**逐个对应**,服务端实现在
* `server/internal/handler/auth.go`:
*
* GET /admin/users 列用户
* POST /admin/users 建用户
* PUT /admin/users/{id} 改用户(**部分更新**)
* DELETE /admin/users/{id} 禁用用户(**不是物理删除**,保留邮件历史)
* POST /admin/users/{id}/reset 重置密码
* GET /admin/scopes 可授权的 Agent / 目录候选
*
* ★ 路径是**相对基地址**的(base 已含 `/api/v1`)。这里不是多此一举的提醒:
* `AppearanceApi.ets` 的文件头记着 WebUI 踩过的同一个坑 —— 第一版写成
* `/api/v1/me/appearance`,实际请求成了 `/api/v1/api/v1/...`,
* 整套同步"从来没生效过"而单测全绿(只断言了方法与报文、没断言 URL)。
* 所以这个文件的每条路径都有判据钉着。
*/
import { ApiClient } from './ApiClient';
import {
AdminUser,
AdminUsersResponse,
AdminScopes,
AdminCreateUserInput,
AdminUpdateUserInput,
AdminResetPasswordInput,
AdminUserResponse,
AdminStatusResponse
} from '../model/Models';
export class AdminApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/**
* 列全部用户。
*
* 服务端回 `{users: [...]}`。这里**不**把缺失的 `users` 当空数组糊过去 ——
* 直接读字段,读不到就是 `undefined`,由调用方按"没拿到"处理;
* 糊成 `[]` 会让"服务端换了形状"表现得像"一个用户都没有"(最坏的那种静默)。
*/
async listUsers(): Promise<AdminUsersResponse> {
return this.client.get<AdminUsersResponse>('/admin/users');
}
/** 可授权的 Agent 与目录候选。空数组的语义是"没东西可授权",不是"不限"。 */
async listScopes(): Promise<AdminScopes> {
return this.client.get<AdminScopes>('/admin/scopes');
}
/**
* 建用户。
*
* 服务端严格解码(多字段即 400),必填校验在服务端:
* 密码 < 8 位 → 400「密码至少 8 位」;用户名不合法 → 400;重名 → 409。
* 这三条文案**必须原样显示给用户**(它们是唯一能让人立刻改的东西),
* 不要在这里改写成"创建失败"。
*/
async createUser(input: AdminCreateUserInput): Promise<AdminUserResponse> {
return this.client.post<AdminUserResponse>('/admin/users', input);
}
/**
* 改用户(部分更新)。
*
* ★ 只把**非 undefined** 的字段放进报文:服务端那几个字段是指针,
* `nil` = 别动;而把 `display_name` 发成空串 = **把显示名清空**。
* 一次性把五个字段都发(用空串/空数组占位)就会把用户的显示名与白名单清掉,
* 而调用方可能只想改个状态。这个拼报文的地方必须只放"真的要给"的字段。
*
* 具体做法:先造一个 `Record<string, Object>`,只往里塞给了的键。
* (ArkTS 里不能用展开运算符做"条件展开",所以这里显式逐个判。)
*/
async updateUser(userId: string, input: AdminUpdateUserInput): Promise<AdminUserResponse> {
const body: Record<string, Object> = {};
if (input.display_name !== undefined) {
body['display_name'] = input.display_name;
}
if (input.role !== undefined) {
body['role'] = input.role;
}
if (input.status !== undefined) {
body['status'] = input.status;
}
if (input.allowed_agents !== undefined) {
body['allowed_agents'] = input.allowed_agents;
}
if (input.allowed_paths !== undefined) {
body['allowed_paths'] = input.allowed_paths;
}
return this.client.put<AdminUserResponse>('/admin/users/' + encodeURIComponent(userId), body);
}
/** 禁用用户(软禁用,保留历史)。服务端会拦"最后一个管理员"。 */
async disableUser(userId: string): Promise<AdminStatusResponse> {
return this.client.del<AdminStatusResponse>('/admin/users/' + encodeURIComponent(userId));
}
/** 重置密码(服务端要求 ≥ 8 位)。 */
async resetPassword(userId: string, newPassword: string): Promise<AdminStatusResponse> {
const input: AdminResetPasswordInput = new AdminResetPasswordInput();
input.new_password = newPassword;
return this.client.post<AdminStatusResponse>(
'/admin/users/' + encodeURIComponent(userId) + '/reset',
input
);
}
}
/**
* `GET /me` → 当前登录用户。
*
* 放在这里而不是 `AuthApi`:管理页的**可见性**靠它(`role === 'admin'`),
* 而 `AuthApi` 是登录/登出那条链。同名函数分处两个类会让人以为有一处是死的。
*/
export class MeApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/** 当前用户。**失败要抛**,不要吞成"不是管理员"。 */
async get(): Promise<AdminUser> {
const resp: AdminUserResponse = await this.client.get<AdminUserResponse>('/me');
return resp.user;
}
}

View File

@ -312,6 +312,74 @@ export class ApiClient {
}
}
/**
* 上传**内存里**的字节(multipart/form-data,字段名 file)→ 服务端回的 id/文案。
*
* 与 `uploadFile` 的分工:那个用 `filePath` 让系统自己去读磁盘;
* 这个用于"图已经在内存里"的场景(壁纸压完就是 `ArrayBuffer`)——
* 走内存就不必先落一个临时文件、也就不必管它的清理(临时文件泄漏是慢性的,
* 而且"压缩后写盘失败"会变成一个与压缩无关的新失败面)。
*
* ★ `data` 收 `ArrayBuffer`(SDK:`data?: string | Object | ArrayBuffer`,since 11;本工程是 23),
* 且 SDK 注释写明「If data has a value, filePath does not take effect」——
* 所以内存字节直传,**不需要 base64、也不需要临时文件**。
*
* ★ 这里**不猜 mime**:调用方给什么用什么。壁纸端点只收图片,
* 服务端按 `Content-Type` **和**文件名后缀判(`detectContentType`),
* 发成 `application/octet-stream` 会被 415 拒掉,所以默认给 `image/jpeg`
* (压图走的就是 JPEG;这也是调用方唯一会用的格式)。
*/
async uploadBytes(path: string, data: ArrayBuffer, fileName: string, mimeType: string): Promise<string> {
const url: string = this.apiBase + path;
const httpRequest = http.createHttp();
try {
const header: Record<string, string> = {};
if (this.token.length > 0) {
header['Authorization'] = 'Bearer ' + this.token;
}
const multiFormData: http.MultiFormData = {
name: 'file',
contentType: mimeType,
remoteFileName: fileName,
data: data
};
const options: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: header,
multiFormDataList: [multiFormData],
connectTimeout: 30000,
readTimeout: 60000
};
hilog.info(DOMAIN, TAG, '→ UPLOAD(bytes) %{public}s', url);
const response = await httpRequest.request(url, options);
const code: number = response.responseCode;
const rawText: string = response.result as string;
if (code >= 200 && code < 300) {
if (rawText.length === 0) {
return '';
}
try {
const parsed = JSON.parse(rawText) as Record<string, string>;
if (parsed['attachment_id'] !== undefined) {
return parsed['attachment_id'];
}
} catch (e) {
// 可能直接返回纯文本
}
return rawText;
}
throw new ApiError(code, rawText.length > 0 ? rawText : ('HTTP ' + code));
} catch (e) {
if (e instanceof ApiError) {
throw e;
}
const be = e as BusinessError;
throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error');
} finally {
httpRequest.destroy();
}
}
/** 清除本地认证态(401 时调用) */
clearAuth(): void {
this.token = '';

View File

@ -53,6 +53,22 @@ export class AppearanceApi {
return this.client.uploadFile('/me/appearance/image', filePath, fileName);
}
/**
* 上传壁纸(**内存字节**直传,字段名 file)。
*
* 与 `uploadImage(filePath,…)` 的分工:那个走磁盘、由系统去读;
* 这个用于"压完的图已经在内存里"(P4c 的实际路径)。
*
* ★ mime 固定 `image/jpeg`:压图走的就是 JPEG(`model/ImagePrep.ts` 两档都是 jpeg),
* 而服务端按 `Content-Type` **和**文件名后缀判图片(`detectContentType`)——
* 发 `application/octet-stream` 会被 415 拒掉,而这个 415 的文案
* ("壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)")
* 正是"失败必须给原因"里那个原因,所以这里不能发错。
*/
async uploadImageBytes(bytes: ArrayBuffer, fileName: string): Promise<string> {
return this.client.uploadBytes('/me/appearance/image', bytes, fileName, 'image/jpeg');
}
/**
* 取壁纸**本体**。
*