跨端: 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,401 @@
/*
* 背景选择器(不设 / 预设 / 自定义图片)+ **P4c 壁纸上传**。
*
* 对应 WebUI 的 `client/electron/src/components/BackgroundPicker.tsx`(三选一 + 预设网格 +
* 图片上传 + 浓度滑杆),照它踩过的三条做(`docs/HARMONY-ALIGN-PLAN.md` §7.17 的 P4c 段):
*
* ① **先压缩再上传**(手机直出照片 4–8MB,服务端上限 4MB ⇒ 直传必然 413);
* ② **失败必须给原因**(别静默失败);
* ③ **上传成功后仍以服务端为权威**(`saved` 那套规则对图片同样适用)。
*
* ── 这个组件的边界(与设置页的分工)──
*
* 本组件**只管画与选**:四个值用 `@Link` 双向绑到设置页的 `@State`,
* 设置页那边 `@Watch` 到变化就推服务端(`setBackground`,与既有的 `setTheme` 同一个形状)。
* 组件里**不写**服务端调用 —— 两个理由:
* ① 本仓库既有的父子通信只有 `@Prop`/`@State`,没有"传回调函数"的先例
* (`CalendarPage` 被 `MainPage` 用时只传 `bgActive`/`visible`),
* 凭空引入一种新接法会让下一个人看不懂数据从哪来;
* ② 推服务端要 `ApiClient` + `AppearanceStore` + 本地缓存,那是**页面**的职责。
*
* **唯一一处例外**是上传:它必须自己走 `picker`/`image`/`upload` 那一条链
* (设置页拿不到 uri),所以上传完成后它调用 `onUploaded` 请页面重新同步。
*
* 压缩的**数值**不在这个文件里 —— 在 `model/ImagePrep.ts`(纯逻辑、判据直接跑):
* 这里只负责"拿到路径 → 解码 → 按计划压 → 上传 → 报结果"。
*
* ⚠️ **视觉未验**:本机无设备/无模拟器,配色与观感一律未验(只保证机制与数值)。
*/
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ApiClient } from '../api/ApiClient';
import { AppearanceApi } from '../api/AppearanceApi';
import { Theme } from '../common/Theme';
import { PRESET_IDS, presetLabel, normalizePreset } from '../model/Wallpaper';
import {
CompressPlan,
CompressPass,
MAX_EDGE,
PickJudgement,
TOO_LARGE_REASON,
UPLOAD_OK_HINT,
estimateSourceBytes,
judgePick,
planCompress,
scaleToMaxEdge,
shouldRetryWithActual,
uploadFailureHint
} from '../model/ImagePrep';
/** 三选一的选项(与 WebUI 的 `options` 逐项对应) */
class KindOption {
value: string = 'none';
label: string = '';
}
function kindOption(value: string, label: string): KindOption {
const o: KindOption = new KindOption();
o.value = value;
o.label = label;
return o;
}
const KIND_OPTIONS: KindOption[] = [
kindOption('none', '不设'),
kindOption('preset', '预设'),
kindOption('image', '自定义图片')
];
@Component
export struct BackgroundPicker {
/*
* 背景四值:`@Link` 双向绑(V1 家规:**@Link 不许给初值**,父组件用 `$bgKind` 传)。
* 组件改它们 = 立刻改到页面的 @State(界面即时反馈),随后由 `onUserChanged` 通知页面推服务端。
*/
/** 背景档:none | preset | image */
@Link bgKind: string;
/** 预设 id */
@Link bgPresetId: string;
/** 浓度(口径与 clamp 在 model/Appearance.ts) */
@Link bgDim: number;
/** 模糊 */
@Link bgBlur: number;
/** 服务端状态文案(只读透传:组件不解释它) */
@Prop statusText: string = '';
/**
* **用户**改了背景四值 ⇒ 通知页面推服务端。
*
* ★ 只在**用户动作**里调(onClick / onChange),**不在** `@Link` 值被页面
* 复制进来时调 —— 否则"服务端同步进来"会被当成"用户改的"再推一次。
* 组件自己不知道值是谁改的,所以这个区分只能靠**调用点**(见各处 onClick)。
*/
onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => void = () => {};
/** 上传成功后请页面**重新以服务端为准同步**(P4c 第③条:组件不自己宣布成功) */
onUploaded: (message: string) => void = () => {};
/** 上传失败的原因(页面负责显示;空串=没事) */
onUploadFailed: (reason: string) => void = () => {};
private client: ApiClient | null = null;
@State uploading: boolean = false;
/** 进度文案(**必须显示**:这一步可能几秒,没有反馈就会被当成卡死) */
@State progress: string = '';
/** 上传失败的原因(**不吞**,就地也显示一份) */
@State uploadError: string = '';
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.client = ApiClient.getInstance(ctx);
}
}
/**
* P4c 主流程:选图 → 判可不可以 → 逐档解码压缩 → 上传 → 请页面重新同步。
*
* ★ 每一步失败都**带原因**(第②条),而且**两条路都给**:就地显示(`uploadError`)
* 与回调页面(`onUploadFailed`)。只给一处的话,换一个父组件就会静默。
*/
async pickAndUpload(): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null) {
this.fail('还没拿到网络客户端(页面没初始化完?)');
return;
}
this.uploadError = '';
this.progress = '';
// ① 选图:用**照片选择器(picker)**,**不需要媒体权限**(自己去读媒体库才需要)。
// 走 `photoAccessHelper` 而不是 `@kit.CoreFileKit` 的 `picker`:
// SDK 里 `@ohos.file.picker` 的这几个类都标了 `@deprecated`
// (`@useinstead @ohos.file.photoAccessHelper:...`),两边 API 形状相同。
// 用废弃入口的后果不是"编不过",而是**某天构建开始报警告、下一个人不知道该换哪个**。
let uri: string = '';
try {
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const photoPicker = new photoAccessHelper.PhotoViewPicker();
const result: photoAccessHelper.PhotoSelectResult = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return; // 用户取消:不是错误,什么都不说(说"失败"会让人以为自己点错了)
}
uri = result.photoUris[0];
} catch (e) {
this.fail('打开相册失败:' + businessMessage(e));
return;
}
let packed: ArrayBuffer | null = null;
let usedBytes: number = 0;
/**
* 最后一档压完**是否仍超限**。
*
* ★ 这个变量存在的理由是"把判定收成一处":见下面 for 循环里那段说明 ——
* 循环里判一次(决定 break 还是试下一档),循环外只读这个**结论**,不重算。
*/
let overLimit: boolean = false;
this.uploading = true;
try {
// ② 读原始尺寸,先过入口检查。
//
// ★ `head` 用 try/finally 收口**只释放一次**:这条路径上有三处出口
// (入口检查不过 / 压完仍超限 / 正常走完),每处各写一次 release
// 就会漏一处或重一处 —— 重一处是"对一个已释放对象再 release",
// 在真机上是难查的原生层异常。**释放写在 finally 里就不会有第二个答案。**
const head: image.ImageSource = image.createImageSource(uri);
try {
const info: image.ImageInfo = await head.getImageInfo();
const sourceBytes: number = estimateSourceBytes(info.size.width, info.size.height);
const verdict: PickJudgement = judgePick(sourceBytes, 'image/jpeg');
if (!verdict.ok) {
this.fail(verdict.reason);
return;
}
// ③ 逐档压。每档都**从原 uri 重新解码到目标尺寸**(`DecodingOptions.desiredSize`):
// 峰值内存只有"目标尺寸"那一份,而不是"原图 + 缩放副本"两份
// (4K 照片解码后约 48MB,两份会把低端机推爆)。
// 代价是解码两次 —— 只在第一档超限时才发生,正常照片一次都不多。
const plan: CompressPlan = planCompress(info.size.width, info.size.height);
for (let i = 0; i < plan.passes.length; i++) {
const pass: CompressPass = plan.passes[i];
this.progress = '压缩中(第 ' + pass.attempt + ' 档,最长边 ' + pass.maxEdge + ')…';
const size = scaleToMaxEdge(info.size.width, info.size.height, pass.maxEdge);
const passSource: image.ImageSource = image.createImageSource(uri);
// 同样收口:解码或压缩中途抛异常时,这个 ImageSource 也必须释放
try {
const decodeOptions: image.DecodingOptions = {
desiredSize: { width: size.width, height: size.height }
};
const scaled: image.PixelMap = await passSource.createPixelMap(decodeOptions);
try {
packed = await packJpeg(scaled, pass.quality);
usedBytes = packed.byteLength;
} finally {
await scaled.release();
}
} finally {
await passSource.release();
}
/*
* ④ 用**真实**字节数判要不要退下一档(估算只用来决定"值不值得先试第一档")。
*
* ★ 这里就是**唯一**的"要不要再压一档"判定 —— 循环结束后**不再重判一次**。
* 原来循环外面还有一句
* 循环后一句「若(没压出来 或 仍超限)则 fail(TOO_LARGE_REASON)」,
* 而那两句是**互相抵消**的,把真正的判据架空了:
* · 超限 ⇒ 循环不 break(去试第二档);两档都超限 ⇒ 循环自然走完、`usedBytes` 仍超限 ⇒ 那句红;
* · 第二档压完不超限 ⇒ 循环 break ⇒ 那句也不红。
* 结果:把循环里的 `break` 改成 `if (true)`(**永远只压一档,第二档彻底死掉**),
* 整套判据照样全绿 —— 因为超限这件事被循环外那句接住了。
* 而循环外那句自己也有个洞:它不区分"第一档超了就认定失败"和"两档都超了"。
*
* ⇒ 收成一处:**循环里判**(决定 break / 试下一档),循环外只看 `overLimit` 这个结论。
*/
overLimit = shouldRetryWithActual(usedBytes);
if (!overLimit) {
break;
}
}
if (packed === null || overLimit) {
this.fail(TOO_LARGE_REASON);
return;
}
} finally {
await head.release();
}
// ⑤ 上传(内存直传,不落临时文件)
this.progress = '上传中(' + Math.round(usedBytes / 1024) + ' KB)…';
await new AppearanceApi(client).uploadImageBytes(packed, 'wallpaper.jpg');
// ⑥ 服务端是权威:**请页面**重新同步,而不是组件自己宣布成功并改档位
this.progress = '已上传,正在以服务端为准重新同步…';
this.onUploaded(UPLOAD_OK_HINT);
} catch (e) {
// 服务端的 415「壁纸必须是图片…」/413「超过上限…」文案在这里原样透出
this.fail(uploadFailureHint(businessMessage(e)));
} finally {
this.uploading = false;
this.progress = '';
}
}
/** 一处收口:把"用户改了"这件事报给页面(调用点只有一个形状,免得漏掉某个入口) */
private emitUserChange(): void {
this.onUserChanged(this.bgKind, this.bgPresetId, this.bgDim, this.bgBlur);
}
/** 一处收口:就地显示 + 通知页面(两条路都给,换父组件也不会静默) */
private fail(reason: string): void {
this.uploadError = reason;
this.onUploadFailed(reason);
}
build() {
Column() {
Row() {
Text('背景').fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
Text(this.statusText).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
}
.width('100%')
Row() {
ForEach(KIND_OPTIONS, (opt: KindOption) => {
Text(opt.label)
.fontSize(Theme.fontSmall)
.fontColor(this.bgKind === opt.value ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.bgKind === opt.value ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => { this.bgKind = opt.value; this.emitUserChange(); })
}, (opt: KindOption) => opt.value)
}
.width('100%').margin({ top: 10 })
if (this.bgKind === 'preset') {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(PRESET_IDS, (pid: string) => {
Text(presetLabel(pid))
.fontSize(Theme.fontTiny)
.fontColor(this.bgPresetId === pid ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.bgPresetId === pid ? Theme.accent : Theme.surfaceMuted)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.margin({ right: 6, top: 6 })
.onClick(() => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); })
}, (pid: string) => pid)
}
.width('100%').margin({ top: 8 })
}
if (this.bgKind === 'image') {
Column() {
Button(this.uploading ? '处理中…' : '选择图片并上传')
.height(38).fontSize(Theme.fontSmall)
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
.enabled(!this.uploading)
.onClick(() => { this.pickAndUpload(); })
if (this.progress.length > 0) {
Text(this.progress).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
.width('100%').margin({ top: 6 })
}
if (this.uploadError.length > 0) {
Text(this.uploadError)
.fontSize(Theme.fontTiny).fontColor(Theme.danger)
.width('100%').padding(8).margin({ top: 6 })
.backgroundColor(Theme.dangerBg).borderRadius(Theme.radiusControl)
}
Text('上传前会先压到最长边 ' + MAX_EDGE + ' 像素(手机直出照片 4–8MB,服务端上限 4MB)。')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
.width('100%').margin({ top: 6 })
}
.width('100%').alignItems(HorizontalAlign.Start).margin({ top: 8 })
}
if (this.bgKind !== 'none') {
this.DimSlider()
this.BlurSlider()
}
}
.width('100%').alignItems(HorizontalAlign.Start)
}
/**
* 浓度滑杆。
*
* ★ 成员名是 `bgDim`/`bgBlur` 这种领域名,**不叫** `opacity`:
* `@State opacity` 会与通用属性重名(ArkTS 那条"成员名不得与通用属性冲突")。
*/
@Builder
DimSlider() {
Column() {
Row() {
Text('浓度').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
Blank()
Text('' + this.bgDim).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
}
.width('100%')
Slider({ value: this.bgDim, min: 0, max: 80, step: 1 })
.width('100%')
.onChange((v: number) => { this.bgDim = Math.round(v); this.emitUserChange(); })
}
.width('100%').margin({ top: 10 })
}
@Builder
BlurSlider() {
Column() {
Row() {
Text('模糊').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
Blank()
Text('' + this.bgBlur).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
}
.width('100%')
Slider({ value: this.bgBlur, min: 0, max: 40, step: 1 })
.width('100%')
.onChange((v: number) => { this.bgBlur = Math.round(v); this.emitUserChange(); })
}
.width('100%').margin({ top: 10 })
}
}
/* ── 模块级小工具(组件文件只导出 struct,所以这些都不导出) ── */
/** 异常 → 一句话。`ApiError.message` 已经是服务端中文文案(`ApiClient` 从 `{"error":…}` 取的)。 */
function businessMessage(e: Object): string {
const be = e as BusinessError;
if (be.message !== undefined && be.message.length > 0) {
return be.message;
}
return '未知原因';
}
/**
* 按给定质量压成 JPEG,返回内存字节。
*
* `quality` 是 **0~100 的整数**(SDK:`PackingOption.quality`,[0,100]),
* 而 `model/ImagePrep.ts` 里的质量是 0~1 的小数(照 WebUI 的 `toDataURL` 口径)——
* 换算只在这一处做。别在调用方各写一遍 `* 100`:两处一漂移
* (一边 85、一边 0.85)就会得到"压完比原图还大"这种看不懂的结果。
*/
async function packJpeg(pm: image.PixelMap, quality: number): Promise<ArrayBuffer> {
const packer: image.ImagePacker = image.createImagePacker();
try {
const opt: image.PackingOption = {
format: 'image/jpeg',
quality: Math.round(quality * 100)
};
return await packer.packing(pm, opt);
} finally {
// ★ `release()` 也是 `Promise<void>`(SDK 两个重载:callback 版与 Promise 版)——
// 第 22 条判据把这一处抓出来了:不 await 的话它是一个"没人管的 promise",
// 而它出现在 `finally` 里,抛出的异常会替换掉原来的异常(真正的失败原因被吞掉)。
await packer.release();
}
}