跨端: 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;
|
||||
}
|
||||
Reference in New Issue
Block a user