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"—— 凭记忆累加的,错了,已更正。) **未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的 实际档位观感)。代码齐 ≠ 真机验过。
230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
/*
|
||
* 壁纸**上传前**的压图计划 —— 纯逻辑,无 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 再决定要不要自己实现。
|
||
*/
|