跨端: 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,139 @@
/*
* 管理员页的**纯逻辑**:白名单勾选、最后登录文案、异常→文案。
*
* ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。
*
* ── 为什么不在 `pages/AdminUsersPage.ets` 里 ──
*
* 两个原因,第二个是硬的:
* ① 本仓库的页面文件清一色**只导出那个 struct**(`LoginPage`/`MainPage`/`SettingsPage`…
* 没有一个 `export function`)。在页面里导出工具函数是不合流的写法;
* ② 页面是 `.ets`,判据**跑不了**它 —— 只有纯逻辑放在 `.ts` 里,
* `node --experimental-strip-types` 才 import 得动(`Wallpaper.ts`/`Calendar.ts`
* 是同一个模式)。这三条逻辑都有"能悄悄错"的地方,值得被判据钉住。
*/
/**
* 勾选/取消一个项,返回**新数组**(不改原数组)。
*
* ★ 必须返回新数组:ArkUI 的 `@State` 靠**引用变化**触发重渲染,
* 原地 `push`/`splice` 改同一个数组**不会**刷新界面 ——
* 表现是"点了没反应",而数据其实已经改了(最难查的一类)。
* ★ 也不改入参:入参可能是另一个 @State 的当前值,就地改会让两处状态互相污染。
*/
export function toggled(list: string[], item: string): string[] {
const out: string[] = [];
let found: boolean = false;
for (let i = 0; i < list.length; i++) {
if (list[i] === item) {
found = true;
} else {
out.push(list[i]);
}
}
if (!found) {
out.push(item);
}
return out;
}
/**
* 本文件只吃**最小的结构**(而不是 `model/Models.ets` 里的 `AdminUser`):
* 见文件末的说明 —— 本文件不许 import,所以字段就地声明。
* 调用方传 `AdminUser` 靠**结构相容**即可,不需要 `as`。
*/
export interface LoginShape {
last_login?: string;
}
/**
* 最后登录的显示文案。
*
* ★ 服务端那个字段是 `json:"last_login,omitempty"`:**缺席**与**空串**都表示"从未登录",
* 两者都要当成"从未登录"显示。只判 `undefined` 会让空串在界面上留下一块空白,
* 看起来像"读取失败"。
*/
export function lastLoginLabel(user: LoginShape): string {
const v: string | undefined = user.last_login;
if (v === undefined || v.length === 0) {
return '从未登录';
}
return v;
}
/*
* ── 这里**为什么没有** "异常 → 文案" 那个函数 ──
*
* 它要 `ApiError`(`api/ApiClient.ets` 里的类),而 `.ets` **import 不进来** ——
* 本目录下的 `Wallpaper.ts`/`Calendar.ts`/`Appearance.ts` 全都是**一个 import 都没有**,
* 那正是它们能被 `node --experimental-strip-types` 直接跑起来的原因
* (判据 `harmony-*.test.mjs` 靠的就是这条路)。
* 一旦这里 import 了 `.ets`,本文件就**从"能真跑"退化成"只能读源码"**,
* 而它里面这几条都值得真跑。
*
* 所以拆成两半:
* · **取值与显示口径**(本文件,可跑):`lastLoginLabel` / `toggled` / `isAdminRole` / `isRestricted` / `messageOfApiError`;
* · **异常归一**(页面层,要 `instanceof ApiError`):见
* `pages/AdminUsersPage.ets` 里那个 `messageOf` —— 那种写法在本仓库已有先例
* (`ComposePage.ets` / `CalendarPage.ets` 都是 `e as ApiError` 就地取 message)。
*/
/**
* 服务端文案的**兜底口径**(纯函数:只吃基元,不吃异常对象)。
*
* ★ 服务端 400/409 的中文文案("该名称已被用户或 Agent 占用"/"密码至少 8 位"/
* "不能禁用最后一个管理员")**必须原样透出**,不要改写成"操作失败" ——
* 管理页的失败原因几乎都是"人能立刻改的东西",吞掉就只剩反复试。
* ★ 只有**真的没有**文案时才用兜底句,并且要说明"服务端没给原因",
* 否则用户分不清"服务端说不行"和"客户端没收到"。
*/
export function messageOfApiError(isApiError: boolean, message: string): string {
if (isApiError && message.length > 0) {
return message;
}
if (!isApiError && message.length > 0) {
return message; // 本地异常(网络层抛的)也有 message,一样给用户看
}
return '操作失败(服务端没有给原因)';
}
/**
* 该用户是否受白名单限制(卡片上打「受限」徽标的条件)。
*
* ★ 口径与 WebUI 逐字一致:`role !== 'admin' && (allowed_agents.length > 0 || allowed_paths.length > 0)`。
* **空 = 不限**(不是"什么都不许")—— 所以"全空"不叫受限,不该有徽标。
* ★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导。
*
* ⚠️ 这里只吃一个最小的结构(而不是 `AdminUser`):本文件不许有 import
* (见文件头),所以字段就地声明。调用方传 `AdminUser` 靠**结构相容**,
* 不需要 `as`,也不会因此把这个文件从"能真跑"变成"只能读源码"。
*/
export interface RoleShape {
role: string;
}
/**
* 是否应当显示管理入口。
*
* ★ 取值口径与 WebUI 逐字一致(`App.tsx`:`user?.role === 'admin'`):**严格相等**。
* ★ 不要把"读不到 role"也放行 —— 那会让任何一次 `/me` 失败都变成"对所有人显示管理入口",
* 点进去一片 403;也不能反过来当成"不是管理员"来自证:调用方要**分开**表达
* "读不到"(`SettingsPage` 的 `isAdmin` 初值 false + `AdminUsersPage` 的 `roleKnown`)。
* 这个函数只管**判断**,不管"读不到时怎么办"。
*/
export function isAdminRole(role: string | undefined): boolean {
return role === 'admin';
}
export interface RestrictedShape {
role: string;
allowed_agents: string[];
allowed_paths: string[];
}
export function isRestricted(user: RestrictedShape): boolean {
if (user.role === 'admin') {
return false;
}
return user.allowed_agents.length > 0 || user.allowed_paths.length > 0;
}

View File

@ -168,6 +168,22 @@ export function localOnly(local: AppearanceSnapshot): AppearanceSync {
* (`BlurStyle`)—— 这是"用系统方案"的直接结果:同一个数字在两边含义不同,
* 所以要**显式映射**,而不是把 40 当半径塞进某个 API。映射关系写在这里,
* 判据可以直接跑它(哪个数字落到哪一档,是行为不是注释)。
*
* ── 为什么返回的是**档位名**(字符串)而不是 SDK 的枚举数值 ──
*
* 与同一个文件里的 `colorModeFor`(`'COLOR_MODE_DARK' | …`)**同一种形状**:
* 这一层是**纯逻辑**(零 `@ohos` 依赖 ⇒ 判据能用 node 直接跑它),
* 而 `BlurStyle` 是 SDK 的枚举、只有 `.ets` 里才在作用域内。
* 返回档位名 ⇒ 映射的**分档判断**留在这层可判,`名字 → BlurStyle` 那一步在页面里
* 用一张**四行长**的表做掉(`MainPage.ets` 的 `BLUR_STYLE_OF`)。
*
* ★ 我一度把它改成"直接返回 SDK 数值(0/9/10/11)"想省掉那张表 —— **被判据挡回来了**,
* 而且挡得对:`harmony-appearance.test.mjs` 有一条判据把**返回值拿去和 SDK 的
* `declare enum BlurStyle` 成员名比对**("档次必须来自系统枚举,写成自造名字会
* 编译不过/不生效")。返回数值就永远对不上成员名,那条判据会一直红 ——
* 它保护的正是"别自己发明档位"这件事。
* ⇒ 回到档位名。那张四行的表不是负担,它是"哪个名字对应哪个枚举"的**唯一**落点,
* 而且页面里能对着 SDK 写(纯逻辑层看不到 BlurStyle)。
*/
export function blurStyleFor(bgBlur: number): string {
const b: number = clampNumber(bgBlur, 0, 40, 4);

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 再决定要不要自己实现。
*/

View File

@ -296,3 +296,84 @@ export class CalendarEventInput {
export class CalendarDeleteResponse {
status: string = '';
}
/*
* ── 管理员:用户管理 ──
*
* 字段与服务端 `handler.userOut`(`server/internal/handler/auth.go`)**一一对应**,
* 也与 WebUI 的 `types/index.ts` 的 `User` 同形 —— 三处是同一个契约。
* ★ 这里用**类字段默认值**而不是 `?:` 可选:ArkTS 收 JSON 后取字段时,
* 可选字段会让每一处使用都要先窄化一次(`strict` 下就是一片 `可能为 undefined`)。
* 服务端那两个真的可能缺的字段(`last_login`/`created_at`)用显式 `undefined` 联合类型标出来。
*/
export class AdminUser {
user_id: string = '';
username: string = '';
display_name: string = '';
/** 'admin' | 'user'(服务端是自由字符串,这里不假设取值一定合法) */
role: string = '';
/** 'active' | 'disabled' */
status: string = '';
allowed_agents: string[] = [];
allowed_paths: string[] = [];
last_login: string | undefined = undefined;
created_at: string | undefined = undefined;
}
/** `GET /admin/users` 的响应 */
export class AdminUsersResponse {
users: AdminUser[] = [];
}
/** `GET /admin/scopes` 的响应:可授权的 Agent 与目录候选 */
export class AdminScopes {
agents: string[] = [];
paths: string[] = [];
}
/**
* `POST /admin/users` 的请求体。
*
* ★ 服务端用**严格解码**(`DecodeBody`):多一个未知字段就 400。
* 所以这里只放服务端 `createUserRequest` 里真实存在的字段,**不要**顺手把
* `user_id`/`status` 也塞进来(新建时服务端自己定,塞了就是 400)。
*/
export class AdminCreateUserInput {
username: string = '';
password: string = '';
display_name: string = '';
role: string = 'user';
allowed_agents: string[] = [];
allowed_paths: string[] = [];
}
/**
* `PUT /admin/users/{id}` 的请求体。
*
* **部分更新**:服务端 `updateUserRequest` 全是**指针**字段,只有给了的才改
* (`nil` = "别动这个字段")。所以这里每个字段都要能表达"没给" ——
* 用 `undefined` 联合类型,而不是空串:空串会被当成"把它改成空",
* 那会把显示名/角色/白名单**清掉**,而调用方只是想改状态。
*/
export class AdminUpdateUserInput {
display_name: string | undefined = undefined;
role: string | undefined = undefined;
status: string | undefined = undefined;
allowed_agents: string[] | undefined = undefined;
allowed_paths: string[] | undefined = undefined;
}
/** `POST /admin/users/{id}/reset` 的请求体 */
export class AdminResetPasswordInput {
new_password: string = '';
}
/** 单个用户包装(`{user: …}`)—— 创建与更新都返回这个形状 */
export class AdminUserResponse {
user: AdminUser = new AdminUser();
}
/** 只有 `{status}` 的响应(禁用用户) */
export class AdminStatusResponse {
status: string = '';
}

View File

@ -238,6 +238,23 @@ export class BackgroundPlan {
*/
presetSubstitutedFrom: string = '';
layers: PresetLayer[] = [];
/**
* 模糊强度(**服务端给的 px 原值**,0~40;由 `model/Appearance.ts` 的 `blurStyleFor`
* 映射成系统材质档)。
*
* ★ 为什么这里放的是 px 而不是"材质档字符串":这一层是**纯逻辑**(零 `@ohos` 依赖),
* 而"px → 材质档"那张表的**唯一**权威在 `Appearance.ts`(它连同边界 0/8/20
* 一起被判据钉住)。在这里再抄一份分档,就会有两张表 —— 而两张表迟早会分叉。
* ⇒ 计划只**搬运**这个值,页面拿它去问 `blurStyleFor`。
*
* ★ 为什么现在才有人消费它:服务端那个模糊字段一直是"**只写不读**"——
* 服务端存、两端同步、`blurStyleFor` 也写了、就是**没有任何调用点**
* (`harmony-appearance.test.mjs` 有一条判据把"消费侧出现次数为 0"钉住,
* 就是为了让"有人开始消费"这一刻**必须停下来**补映射判据而不是偷偷把 0 改成 1)。
* P4c 加背景选择器时踩到了这条线:滑杆能拖、值能存,但壁纸**一点没糊**。
* 所以这次把映射的**调用点**补上(映射表与它的判据早就在了)。
*/
blurPx: number = 0;
/**
* 遮盖浓度(0~1):**两档都用**(预设档也压),用系统遮罩色刷一层。
*
@ -262,9 +279,11 @@ export class BackgroundPlan {
* 这里决定**画成什么**。`image` 档但图没取回来 → `none`:
* 宁可什么都不画,也不要画一块空白(用户会以为壁纸坏了)。
*/
export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean, dark: boolean): BackgroundPlan {
export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean, dark: boolean, blurPx: number = 0): BackgroundPlan {
const plan: BackgroundPlan = new BackgroundPlan();
plan.dark = dark;
// 模糊是**全档通用**的(WebUI 的 `--bg-blur` 也不区分档位),所以先搬运、各档都带上
plan.blurPx = normalizeBlur(blurPx);
if (bgKind === 'preset') {
plan.kind = 'preset';
plan.presetId = normalizePreset(presetId);
@ -284,6 +303,36 @@ export function resolveBackground(bgKind: string, presetId: string, scrim: numbe
return plan;
}
/**
* 模糊 px 归一:0~40 的整数。
*
* ★ 边界 40 与 `model/Appearance.ts` 的 `clampNumber(bgBlur, 0, 40, 4)` **必须同值** ——
* 两边不一致的话,服务端存 40、这边按 80 画,判定与显示就对不上了
* (`blurStyleFor` 里面也有一次 clamp,那是它自己的防线,不是这里可以放松的理由)。
*/
export function normalizeBlur(px: number): number {
if (!Number.isFinite(px)) {
return 0;
}
const r: number = Math.round(px);
/*
* ★ 判 `!(r > 0)` 而不是 `r < 0` —— 因为 `Math.round(-0.4)` 是 **-0**,
* 而 `-0 < 0` 是 **false**(`-0 === 0`)。用 `r < 0` 会让 `-0` 漏过去,
* 于是 `blur(-0)` 被喂进 ArkUI —— 这是个**静默**的怪值(判据把它抓出来了:
* `assert.equal(normalizeBlur(-0.4), 0)` 报的是 `+ -0`,一眼看不出问题在哪)。
*/
if (!(r > 0)) {
return 0;
}
if (r > MAX_BLUR_PX) {
return MAX_BLUR_PX;
}
return r;
}
/** 模糊上限(px)。与 `model/Appearance.ts` 的 clamp 上界同值。 */
export const MAX_BLUR_PX: number = 40;
/** 预设缩略图/选择器要显示的清单(id + 标签) */
export function presetChoices(): string[] {
return PRESET_IDS;