跨端: 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:
139
client/harmony/entry/src/main/ets/model/AdminUsers.ts
Normal file
139
client/harmony/entry/src/main/ets/model/AdminUsers.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
229
client/harmony/entry/src/main/ets/model/ImagePrep.ts
Normal file
229
client/harmony/entry/src/main/ets/model/ImagePrep.ts
Normal 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 再决定要不要自己实现。
|
||||
*/
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user