跨端: 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:
132
client/harmony/entry/src/main/ets/api/AdminApi.ets
Normal file
132
client/harmony/entry/src/main/ets/api/AdminApi.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 = '';
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取壁纸**本体**。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user