Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/ImagePrep.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

230 lines
10 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.

/*
* 壁纸**上传前**的压图计划 —— 纯逻辑,无 UI / 无 SDK 依赖。
*
* ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。
*
* ── 为什么单独一层(P4c)──
*
* 手机直出照片是 4–8MB,而服务端壁纸上限 **4MB**(`appearanceMaxBytes()`,可配)——
* 直传必然 413。WebUI 那边的做法是「先压缩再上传」,这里把**同一套阈值与两档策略**
* 搬成纯逻辑,于是"缩到多大、什么质量、什么时候放弃"这些**数值**能被判据钉住,
* 而不是埋在一个 `.ets` 的 async 函数里(那种地方判据只能拿正则去猜)。
*
* ── 与 WebUI 的对齐(取值出处,不是凭印象)──
*
* | 这一层 | WebUI 出处 |
* |---|---|
* | `MAX_EDGE = 2560` | `stores/backgroundStore.ts:135` |
* | 首档质量 0.85 + 最长边 2560 | 同文件 `prepareImage`:`drawScaled(bitmap, MAX_EDGE)` + `toDataURL('image/jpeg', 0.85)` |
* | 超限后缩到 1280 + 质量 0.78 | 同处:`drawScaled(bitmap, MAX_EDGE / 2)` + `toDataURL('image/jpeg', 0.78)` |
* | 原图 > 20MB 直接拒 | 同处:`file.size > 20 * 1024 * 1024` |
* | 压缩后仍超限 ⇒ 明确拒绝并说原因 | 同处:『图片压缩后仍过大,请换一张更小的图片』 |
*
* ── 一处**故意不对齐**,以及为什么 ──
*
* WebUI 判的是 **data URL 的长度**(`MAX_DATA_URL_BYTES = 2_400_000`,含 base64 膨胀
* 与 `data:image/jpeg;base64,` 前缀),因为它必须把 data URL 存进 localStorage 并渲染。
* 鸿蒙侧**不经过 data URL**:`packing()` 直接给 `ArrayBuffer` 交给 multipart 上传,
* 既没有 base64 的 4/3 膨胀也没有前缀。拿 WebUI 那个数当**字节**上限会平白少收 1/4 的图。
*
* 所以这里判的是**估算后的字节数**,上限取服务端那道真正的门(4MB)留出余量后的值。
* ⇒ 这条差异是**有意的**,不是抄漏;判据同时钉住"两个数都在"和"别把它当成同一件事"。
*/
/** 缩放的最长边(px)。与 WebUI `MAX_EDGE` 相等 —— 观感一致的来源。 */
export const MAX_EDGE: number = 2560;
/** 首档 JPEG 质量 */
export const FIRST_QUALITY: number = 0.85;
/** 首档失败后的第二档最长边(= MAX_EDGE / 2,与 WebUI 同) */
export const RETRY_EDGE: number = 1280;
/** 第二档 JPEG 质量 */
export const RETRY_QUALITY: number = 0.78;
/** 原图超过这个大小就不读了(解码本身会卡住主线程)。与 WebUI 的 20MB 同。 */
export const MAX_SOURCE_BYTES: number = 20 * 1024 * 1024;
/**
* 压完之后的**字节**上限。
*
* 服务端那道门是 `appearanceMaxBytes()`(默认 4MiB,可配),它卡的是**文件内容**,
* 而且外层还有一个 `max+1MiB` 的请求体限制(multipart 边界也占地方)。
* 取 3.5MiB:贴着 4MiB 会在"服务端把上限调小"时变成 413,
* 而离得太远又白扔分辨率。
*/
export const MAX_UPLOAD_BYTES: number = 3_670_016;
/** 一档压缩的参数 */
export class CompressPass {
/** 最长边(px) */
maxEdge: number = MAX_EDGE;
/** JPEG 质量(0~1) */
quality: number = FIRST_QUALITY;
/** 这是第几档(1 起;界面文案与判据都认它) */
attempt: number = 1;
}
/** 压图计划 */
export class CompressPlan {
/** 按顺序要试的档(第一档超限才试第二档) */
passes: CompressPass[] = [];
/** 等比缩放后的目标尺寸(与 pass 无关:两档都从同一张原位图缩,见下) */
targetWidth: number = 0;
targetHeight: number = 0;
}
/**
* 等比缩放到最长边不超过 `maxEdge`。
*
* ★ **不放大小图**(`scale > 1` 时取 1):把小图放大会同时变糊和变大,
* 而"变大"会浪费掉那道字节上限。WebUI 的 `drawScaled` 同样 `Math.min(1, …)`。
*
* ★ 除以 `Math.max(w, h)` 而不是 `w`:竖拍照片(h > w)按宽算会**超出**最长边。
*/
export function scaleToMaxEdge(width: number, height: number, maxEdge: number):
{ width: number; height: number } {
const longest: number = Math.max(width, height);
if (longest <= 0) {
return { width: 0, height: 0 };
}
const scale: number = Math.min(1, maxEdge / longest);
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale))
};
}
/**
* 估算 JPEG 压完的字节数。
*
* JPEG 是变长编码,**没有**能算准的公式;这里要的不是准,而是"够用来判要不要试下一档"。
* 用 `宽 × 高 × 每像素字节` 的上界估计(0.5 B/px 对高质量照片偏保守),
* 偏保守的方向是**对的**:宁可多试一档,也别上传一个必然 413 的东西。
*
* ★ 这个函数的估算**误差写在这里**,别让读的人以为它是测量值:
* 真实字节数只有 `packing()` 之后才知道;所以要**在拿到真实长度后再判一次**
* (见 `shouldRetryWithActual`),估算只用来决定"是否值得先试第一档"。
*/
export function estimateJpegBytes(width: number, height: number, quality: number): number {
const pixels: number = Math.max(0, width) * Math.max(0, height);
// 质量越高,每像素位越多;0.85 → 约 0.5 B/px,0.78 → 约 0.4 B/px
const bytesPerPixel: number = 0.2 + 0.35 * Math.max(0, Math.min(1, quality));
return Math.round(pixels * bytesPerPixel);
}
/**
* 真实字节数出来后,是否该退到下一档。
*
* 单独一个函数是为了让"两档都超限"这条路径**能被判据跑**:
* 界面在那种情况下必须**明确说原因**(P4c 三条里的第二条),
* 而不是静默什么都没发生 —— 静默失败在 WebUI 那边踩过。
*/
export function shouldRetryWithActual(actualBytes: number): boolean {
return actualBytes > MAX_UPLOAD_BYTES;
}
/** 两档都超限时给用户看的话(与 WebUI 的文案同义) */
export const TOO_LARGE_REASON: string = '图片压缩后仍过大,请换一张更小的图片';
/** 原图就过大时给用户看的话 */
export const SOURCE_TOO_LARGE_REASON: string = '图片过大(超过 20MB),请先裁剪';
/** 选到非图片时给用户看的话 */
export const NOT_IMAGE_REASON: string = '请选择图片文件';
/**
* 原件体积的**估算**(picker 只给 uri,拿不到文件大小)。
*
* ★ 这里刻意**不**去 `fileIo.stat` 拿真实大小:那要多一次 IO,而这一步只用于
* "20MB 以上就别解码了"这一道**粗筛**(真正的门是压完之后的字节数与服务端上限)。
* ★ 系数取 **偏小**(0.35 B/px,手机 JPEG 直出的典型值):两个方向的代价不对称 ——
* 估偏小 = "极大图可能走到解码那一步才会卡",可接受;
* 估偏大 = **误拒正常照片**,那是把好需求挡在门外。
*/
export function estimateSourceBytes(width: number, height: number): number {
return Math.round(Math.max(0, width) * Math.max(0, height) * 0.35);
}
/**
* 造压图计划:按原图尺寸决定**缩放目标**,并给出要试的档。
*
* 两档都用**同一张原位图**缩放(不是把第一档的结果再缩一次)——
* 二次缩放会累加两次重采样损失,而重新从原图缩只损失一次。
* WebUI 的 `prepareImage` 也是两次都从 `bitmap` 缩。
*/
export function planCompress(sourceWidth: number, sourceHeight: number): CompressPlan {
const plan: CompressPlan = new CompressPlan();
const first: CompressPass = new CompressPass();
first.maxEdge = MAX_EDGE;
first.quality = FIRST_QUALITY;
first.attempt = 1;
const second: CompressPass = new CompressPass();
second.maxEdge = RETRY_EDGE;
second.quality = RETRY_QUALITY;
second.attempt = 2;
plan.passes = [first, second];
// 目标尺寸按**首档**算(界面显示"将缩到 W×H",用户看的是第一档的结果)
const scaled = scaleToMaxEdge(sourceWidth, sourceHeight, MAX_EDGE);
plan.targetWidth = scaled.width;
plan.targetHeight = scaled.height;
return plan;
}
/** `judgePick` 的结果 */
export class PickJudgement {
ok: boolean = true;
/** 不 ok 时的原因(要给用户看,原样显示) */
reason: string = '';
}
/**
* 选图之后的**入口检查**:先判能不能处理,再判要不要压。
*
* 返回 `{ ok, reason }`:`ok === false` 时 `reason` **必须**被显示出来。
* 这一条是 P4c 三条里的第二条("失败必须给原因,别静默失败")的落点 ——
* 单独成函数而不是写在 `.ets` 的 async 里,就是为了让每条拒绝路径都有判据。
*/
export function judgePick(sizeBytes: number, mimeType: string): PickJudgement {
const j: PickJudgement = new PickJudgement();
if (!mimeType.startsWith('image/')) {
j.ok = false;
j.reason = NOT_IMAGE_REASON;
return j;
}
if (sizeBytes > MAX_SOURCE_BYTES) {
j.ok = false;
j.reason = SOURCE_TOO_LARGE_REASON;
return j;
}
j.ok = true;
j.reason = '';
return j;
}
/** 上传后的文案:服务端是权威,所以成功后要重新拉一次(P4c 第三条) */
export const UPLOAD_OK_HINT: string =
'壁纸已上传;正在以服务端那份为准重新同步(换设备也会跟着走)。';
/** 上传失败时**不要把原因吞掉**:服务端的文案(如"壁纸必须是图片…")是唯一能让人立刻改的东西 */
export function uploadFailureHint(serverMessage: string): string {
if (serverMessage.length === 0) {
return '上传失败(服务端没有给原因)';
}
return '上传失败:' + serverMessage;
}
/*
* ★ 这里原本还写了一个手写 `bytesToBase64`(含一个对应的解码函数),
* 理由是"压完的图在内存里、而上传要文件,所以内存转 base64 上传"。
* **核了 SDK 之后删掉了**:`@ohos.net.http.d.ts` 的 `MultiFormData` 里
* `data?: string | Object | ArrayBuffer`(since 11,本工程 target/compatible 是 6.1.0(23)),
* 而紧邻的注释写明「If data has a value, filePath does not take effect」——
* **内存字节可以直传**,不需要 base64,也不需要临时文件。
*
* 记这一笔是因为它是个典型形状:**在假设 SDK 能力不足的前提下写了一层,
* 而那一层自己又会成为新的错源**(手写 base64 的移位与补齐错了是静默错:
* 语法合法、字节不对)。结论:先核 SDK 再决定要不要自己实现。
*/