跨端: 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,229 @@
/*
* 壁纸**上传前**的压图计划 —— 纯逻辑,无 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 再决定要不要自己实现。
*/