diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 6a51cdf..be03ed3 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -419,14 +419,17 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记( // 导航条那一处必须真的还在(形状判定之外,位置本身也要在) const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets')); - assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)'); + assert.match(main, /\.backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/, + '导航条要用系统材质(不是手写 alpha),且档位由用户偏好经 blurStyleFor 映射而来'); /* * 自检:造一次**链式叠用**(`X.blur().blur()`),确认上面的判定抓得到。 * 这一枪放在判据里,是为了以后改这段扫描逻辑时它自己会被检验 —— * 第一版只看"前一个字符是不是 }",对这种写法**静默失效**,正是这条自检抓出来的。 */ - const sample = 'Row() { Text("x") }\n .backgroundBlurStyle(Theme.navMaterial)\n .backgroundBlurStyle(Theme.navMaterial)'; + // 样本要与**真实写法同形**(否则自检会变成"拿一段判据认不出来的代码去验判据") + const sample = 'Row() { Text("x") }\n .backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(x)] ?? BlurStyle.NONE)\n' + + ' .backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(x)] ?? BlurStyle.NONE)'; const sampleEnds = [...sample.matchAll(/backgroundBlurStyle\(/g)].map(m => blockEndBefore(sample, m.index)); assert.ok(sampleEnds.every(e => e >= 0), '自检:链式写法要能解析出所作用的块'); assert.equal(new Set(sampleEnds).size, 1, '自检:同一组件的两处调用必须解析到同一个块(否则叠用判不出来)'); diff --git a/client/electron/test/harmony-admin.test.mjs b/client/electron/test/harmony-admin.test.mjs new file mode 100644 index 0000000..a6dd862 --- /dev/null +++ b/client/electron/test/harmony-admin.test.mjs @@ -0,0 +1,308 @@ +/* + * 管理页(用户管理)的判据 —— **行为**判据(纯逻辑真跑)+ **形态**判据(`.ets` 只读源码)。 + * + * ── 为什么值得有 ── + * + * 这一页是本次「功能做全」新加的那一块,而它**本机跑不起来**(无设备/无模拟器)。 + * 所以能钉住的每一条都要钉住,并且**分清哪一条是哪种**: + * + * · **真跑**(`model/AdminUsers.ts`,无 `@ohos` 依赖 ⇒ node strip-types 直接执行): + * 勾选、最后登录文案、角色判定、受限徽标、异常兜底文案。 + * · **读源码**(`pages/AdminUsersPage.ets` / `pages/SettingsPage.ets` / + * `api/AdminApi.ets` / `main_pages.json`):路由注册、服务端调用都对应上、 + * 没有写死色值、列表项每项一张卡、服务端文案不被吞。 + * 这类**只能证明"代码里是这么写的"**,证不了"真机上长这样" —— 见文件末的未验清单。 + * + * ── 判代码一律用 `code()`(剥注释)── + * + * 本文件里判的标识符(`listUsers`/`#` 色值/`radiusCard`…)在解释性注释里大量出现, + * 用原文读会产生假绿。这是 `lib/read.mjs` 存在的理由,也是我踩过两次的坑。 + */ +import { join } from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { code, prose } from './lib/read.mjs'; + +const ROOT = '/home/program/agentmail'; +const ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); +const ADMIN_TS = join(ETS, 'model/AdminUsers.ts'); +const ADMIN_PAGE = join(ETS, 'pages/AdminUsersPage.ets'); +const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets'); +const ADMIN_API = join(ETS, 'api/AdminApi.ets'); +const PAGES_JSON = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile/main_pages.json'); + +// 真跑纯逻辑(有 `@ohos` 依赖的文件不能这样跑;AdminUsers.ts 刻意零 import) +const A = await import(pathToFileURL(ADMIN_TS).href); + +/* ────────────────────── ① 真跑:勾选白名单 ────────────────────── */ + +test('勾选:加进去、再去掉,而且**不就地改入参**', () => { + const before = ['a', 'b']; + + const added = A.toggled(before, 'c'); + assert.deepEqual(added, ['a', 'b', 'c'], '没勾上的项要加进去'); + assert.deepEqual(before, ['a', 'b'], + '★ 入参被就地改了:@State 靠**引用变化**触发重渲染,就地 push/splice 改同一个数组不会刷新界面,' + + '表现是"点了没反应"而数据其实已改;而且入参可能是另一个 @State 的当前值,就地改会让两处互相污染'); + + const removed = A.toggled(added, 'a'); + assert.deepEqual(removed, ['b', 'c'], '已勾上的项再点要取消'); + assert.notEqual(added, removed, '★ 必须返回**新数组**(同一个引用不会被 @State 认成变化)'); +}); + +test('勾选:边界(空表、重复项只当一次、原顺序不被打乱)', () => { + assert.deepEqual(A.toggled([], 'x'), ['x'], '空表勾第一个'); + assert.deepEqual(A.toggled(['x'], 'x'), [], '唯一项取消后是空表(不是 null/undefined)'); + assert.deepEqual(A.toggled(['b', 'a'], 'c'), ['b', 'a', 'c'], + '追加在末尾(不重排既有项:列表顺序变了会让用户以为选项跳了)'); +}); + +/* ────────────────────── ② 真跑:最后登录文案 ────────────────────── */ + +test('最后登录:**缺席**与**空串**都显示"从未登录"(服务端是 omitempty)', () => { + assert.equal(A.lastLoginLabel({ last_login: undefined }), '从未登录', '字段缺席 = 从未登录'); + assert.equal(A.lastLoginLabel({}), '从未登录', '整个字段不存在也一样'); + assert.equal(A.lastLoginLabel({ last_login: '' }), '从未登录', + '★ 空串也要当"从未登录":只判 undefined 会让空串在界面上留一块空白,看起来像"读取失败"'); + assert.equal(A.lastLoginLabel({ last_login: '2026-09-15 07:02' }), '2026-09-15 07:02', + '有值就原样显示(不在这里改格式:服务端给的就是要显示的那个串)'); +}); + +/* ────────────────────── ③ 真跑:角色判定 ────────────────────── */ + +test('角色判定:**严格等于 admin**(口径与 WebUI 的 user?.role === "admin" 一致)', () => { + assert.equal(A.isAdminRole('admin'), true); + assert.equal(A.isAdminRole('user'), false); + assert.equal(A.isAdminRole(''), false); + assert.equal(A.isAdminRole(undefined), false, + '★ 读不到 role 一律 false:乐观放行会让任何一次 /me 失败都变成"对所有人显示管理入口",点进去一片 403'); + for (const loose of ['Admin', 'ADMIN', 'admin ', ' admin', 'admin\n', 'superadmin', 'admin2']) { + assert.equal(A.isAdminRole(loose), false, + `★ ${JSON.stringify(loose)} 不该放行:只有严格相等才与 WebUI 同口径(宽松匹配会让某天服务端改大小写时两端行为分叉)`); + } +}); + +/* ────────────────────── ④ 真跑:受限徽标 ────────────────────── */ + +test('受限徽标:普通用户且白名单非空才显示(空 = 不限,不是"什么都不许")', () => { + assert.equal(A.isRestricted({ role: 'user', allowed_agents: ['pimail'], allowed_paths: [] }), true); + assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: ['/tmp'] }), true); + assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: [] }), false, + '★ 全空不叫受限:空 = 不限,打上「受限」会让用户以为自己什么都点不动'); + assert.equal(A.isRestricted({ role: 'admin', allowed_agents: ['pimail'], allowed_paths: ['/tmp'] }), false, + '★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导'); +}); + +/* ────────────────────── ⑤ 真跑:服务端文案不被吞 ────────────────────── */ + +test('服务端文案原样透出(400/409 的中文文案是唯一能让人立刻改的东西)', () => { + const cases = [ + '该名称已被用户或 Agent 占用', + '密码至少 8 位', + '用户名只能包含小写字母、数字、点、下划线和连字符', + '系统至少需要保留一个可用管理员', + '用户不存在' + ]; + for (const msg of cases) { + const out = A.messageOfApiError(true, msg); + assert.ok(out.includes(msg), + `★ 服务端文案被改写了:「${msg}」→「${out}」。管理页的失败原因几乎都是"人能立刻改的东西",吞掉只剩反复试`); + assert.equal(out, msg, '不做任何包装:原样显示'); + } + assert.ok(A.messageOfApiError(true, '').length > 0, '服务端没给文案时也要有话说'); + assert.ok(A.messageOfApiError(false, 'Network unreachable').includes('Network unreachable'), + '本地异常(网络层)的 message 也要给用户看'); + assert.ok(A.messageOfApiError(false, '').length > 0, '什么都没有时给兜底句'); +}); + +test('兜底句要说清"服务端没给原因"(否则用户分不清"服务端说不行"和"客户端没收到")', () => { + /* + * ★ 这条判据第一版写的是 `/没(有)?给|未知|失败/`,**红不了** —— 变异测试抓出来的: + * 把兜底句改成光秃秃的「操作失败」,它照样匹配上了(`失败` 这个分支太宽)。 + * ⇒ 收窄成"必须出现**原因缺席**这件事",而不是"出现了某个失败词"。 + */ + const fallback = A.messageOfApiError(false, ''); + assert.ok(/没(有)?(给|提供)原因|未(给|提供)原因|无原因/.test(fallback), + `★ 兜底句「${fallback}」只说了"失败",没说**原因缺席** —— 用户会把它当成服务端的拒绝理由,` + + '于是反复重试同一个注定失败的动作'); +}); + +/* ────────────────────── ⑥ 形态:路由与入口 ────────────────────── */ + +test('管理页**注册成路由**(没注册的 @Entry 页 pushUrl 会失败)', () => { + const pages = JSON.parse(prose(PAGES_JSON)); + assert.ok(Array.isArray(pages.src), 'main_pages.json 要有 src 数组'); + assert.ok(pages.src.includes('pages/AdminUsersPage'), + `★ 管理页不在 main_pages.json 里(实际 ${JSON.stringify(pages.src)})⇒ pushUrl({url:'pages/AdminUsersPage'}) 起不来`); + assert.equal(new Set(pages.src).size, pages.src.length, '清单里不该有重复项'); +}); + +test('设置页的管理入口:仅管理员可见、点进管理页', () => { + const src = code(SETTINGS_PAGE); + assert.ok(/if \(this\.isAdmin\)/.test(src), + '★ 管理入口没有 isAdmin 门禁 ⇒ 每个普通用户都会看到一个点进去 403 的入口'); + assert.ok(/pushUrl\(\{ url: 'pages\/AdminUsersPage' \}\)/.test(src), + '入口要真的推到管理页(推一个别的地方 = 用户点了看不到管理面)'); + assert.ok(/isAdminRole\(/.test(src), '★ 门禁要用共用的 isAdminRole,而不是就地写 === "admin"(两处口径会漂移)'); + assert.ok(/loadRole\(\)/.test(src), '要真去读一次身份'); +}); + +test('身份读不到时**不**乐观显示入口(isAdmin 初值 false,失败也保持 false)', () => { + const src = code(SETTINGS_PAGE); + assert.ok(/@State isAdmin: boolean = false/.test(src), 'isAdmin 初值必须是 false'); + const catchBlock = src.slice(src.indexOf('async loadRole()')); + const body = catchBlock.slice(catchBlock.indexOf('catch'), catchBlock.indexOf('catch') + 200); + assert.ok(/this\.isAdmin = false/.test(body), + `★ loadRole 的 catch 里没有把 isAdmin 置 false;读一次片段:${body.slice(0, 120)}`); +}); + +/* ────────────────────── ⑦ 形态:动作 ↔ 服务端调用 ────────────────────── */ + +test('管理页的每个动作都落到真实的服务端调用(不是只画了个按钮)', () => { + const page = code(ADMIN_PAGE); + // 「用户真正会点的那一层」:按钮的 onClick 要能走到这些调用 + const required = ['listUsers', 'listScopes', 'createUser', 'updateUser', 'disableUser', 'resetPassword']; + for (const fn of required) { + assert.ok(new RegExp(`\\.${fn}\\(`).test(page), + `★ 页面上没有调用 api.${fn}() ⇒ 那个动作是死的(点下去什么都不发生)`); + assert.ok(new RegExp(`async ${fn}\\(`).test(code(ADMIN_API)), + `★ AdminApi 里没有 ${fn} ⇒ 页面调的是不存在的东西,编不过`); + } +}); + +test('禁用只发 status 一个字段(服务端是**部分更新**,多发的字段会被当成"改成这个值")', () => { + const page = code(ADMIN_PAGE); + const idx = page.indexOf('async setStatus('); + assert.ok(idx > 0, '要有 setStatus'); + const body = page.slice(idx, page.indexOf('async resetPassword', idx)); + assert.ok(/disableUser\(/.test(body), '禁用要调 disableUser(DEL)'); + assert.ok(/onlyStatus\.status = 'active'/.test(body), + '★ 启用路径要显式只设 status'); + assert.ok(!/display_name/.test(body) && !/allowed_agents/.test(body), + '★ 启停路径里出现了 display_name/allowed_agents ⇒ 服务端会把显示名与白名单一起改成这些值(部分更新的经典踩法)'); +}); + +test('服务端拦"最后一个管理员"的文案有落点(409 要能显示出来)', () => { + const page = code(ADMIN_PAGE); + assert.ok(/this\.messageOf\(e\)/.test(page), + '★ 捕获到的异常没走 messageOf ⇒ 服务端文案被吞,用户不知道"最后一个管理员不能禁用"'); + assert.ok(/this\.errorText = this\.messageOf\(e\)/.test(page), '文案要落到可见的 errorText'); +}); + +/* ────────────────────── ⑧ 形态:交付判据(pi 那几条) ────────────────────── */ + +test('不许新写死颜色(色一律走 Theme;品牌色只写在 Theme 那一个文件里)', () => { + for (const f of [ADMIN_PAGE, SETTINGS_PAGE]) { + const src = code(f); + const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || []; + assert.deepEqual(hex, [], + `★ ${f.replace(ROOT + '/', '')} 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`); + const rgb = src.match(/\brgba?\(/g) || []; + assert.deepEqual(rgb, [], `★ ${f.replace(ROOT + '/', '')} 里出现了手写 rgba/rgb`); + } +}); + +test('列表项每项一张卡(不是整列共用一张底)', () => { + const src = code(ADMIN_PAGE); + assert.ok(/ListItem\(\)/.test(src), '用户列表要用 List + ListItem'); + assert.ok(/for \(const|ForEach\(this\.users/.test(src), '要真的遍历用户列表'); + // 卡片样式必须落在被 ForEach 调用的那个 @Builder 里(`UserCard`),而不是外层容器上 + const cardIdx = src.indexOf('UserCard(user: AdminUser)'); + assert.ok(cardIdx > 0, '要有 UserCard @Builder'); + const card = src.slice(cardIdx, cardIdx + 2000); + assert.ok(/backgroundColor\(Theme\.surface\)/.test(card) && /borderRadius\(Theme\.radiusCard\)/.test(card), + '★ 每项那张卡的底色/圆角要在 UserCard 里 ⇒ 否则是一整列共用一张底'); +}); + +test('页面**真的用上**了那几个纯函数(否则纯逻辑全绿、界面却是死的)', () => { + /* + * ★ 这条是变异测试抓出来的缺口:把页面里的 `if (isRestricted(user))` 改成 `if (false)`, + * 删掉「受限」徽标 —— **纯逻辑那几条判据全绿**(`isRestricted` 本身没错), + * 而用户再也看不到徽标。这正是"判据覆盖了模块、没覆盖接线"的经典形状。 + * ⇒ 每个纯函数都要在**页面代码**里出现一次(断言调用,不是断言注释里提过)。 + */ + const src = code(ADMIN_PAGE); + const wired = [ + ['isRestricted(user)', '「受限」徽标'], + ['isAdminRole(', '管理员门禁'], + ['lastLoginLabel(user)', '最后登录文案'], + ['messageOfApiError(', '异常兜底文案'], + ['toggled(', '白名单勾选'] + ]; + for (const [call, what] of wired) { + assert.ok(src.includes(call), + `★ 页面里没有 ${call} ⇒ ${what} 是死的(纯逻辑判据会全绿,而界面上什么都不会发生)`); + } +}); + +test('管理页不碰背景/模糊(同一张底只允许被模糊一次,那是 MainPage 外观层的事)', () => { + const src = code(ADMIN_PAGE); + assert.ok(!/blur\(|BackdropBlur|backgroundBlurStyle/.test(src), + '★ 管理页里出现了模糊 ⇒ 与 MainPage 的壁纸层叠起来就是"一张底被模糊两次"'); +}); + +/* ────────────────────── ⑨ 形态:ArkTS 编译坑 ────────────────────── */ + +test('ArkTS 硬坑:本页不出现解构 / any / unknown / 函数表达式', () => { + const src = code(ADMIN_PAGE); + assert.ok(!/\bany\b/.test(src), '不许 any'); + assert.ok(!/\bunknown\b/.test(src), '不许 unknown'); + assert.ok(!/\bfunction\s*\(/.test(src), '不许函数表达式(ArkTS 只认箭头函数)'); + assert.ok(!/const\s*\{[^}]*\}\s*=/.test(src) && !/const\s*\[[^\]]*\]\s*=/.test(src), + '不许解构赋值'); +}); + +test('ArkTS 硬坑:页面文件只导出那个 struct(工具函数放 model/ 里)', () => { + const src = prose(ADMIN_PAGE); + const exports = src.match(/^export\s+(function|const|class|interface|enum)/gm) || []; + assert.deepEqual(exports, [], + `★ 页面里出现了 ${JSON.stringify(exports)} ⇒ 本仓库页面清一色只导出 struct(7 个页面 0 个 export function);` + + '而且 .ets 里的函数判据跑不了,纯逻辑必须放 model/*.ts'); + /* + * ★ 这条原来写的是"页面要 `export struct`",**是错的**,被它自己抓出来了: + * 本仓库 5 个 `@Entry` 页(LoginPage/MainPage/SettingsPage/SessionsPage/MailDetailPage) + * 清一色 `struct Xxx {`(**不带** export),而 `export struct` 只出现在 + * 被当子组件用的那些(`CalendarPage`/`BackgroundPicker`)。 + * 路由页由 `main_pages.json` 指名加载,不需要导出。 + * ⇒ 判据改成钉**这个**形态(两件事分别断言,不混在一句里)。 + */ + assert.ok(/^@Entry$/m.test(src) && /^@Component$/m.test(src), '页面要有 @Entry + @Component'); + assert.ok(/^struct AdminUsersPage \{/m.test(src), + '★ @Entry 页要写成不带 export 的 struct(与另外 5 个路由页同形)'); + assert.ok(!/^export struct AdminUsersPage/m.test(src), + '★ @Entry 路由页带 export 与本仓库既有 5 个路由页不一致(路由页由 main_pages.json 指名加载,不需要导出)'); +}); + +test('页面里不 import `.ets` 进纯逻辑层(否则那个文件从"能真跑"退化成"只读源码")', () => { + const src = prose(ADMIN_TS); + const imports = src.match(/^import\s/gm) || []; + assert.deepEqual(imports, [], + `★ model/AdminUsers.ts 有 ${imports.length} 个 import。本目录下 Wallpaper/Calendar/Appearance 全是零 import,` + + '那正是它们能被 node --experimental-strip-types 直接跑的原因;import 了 .ets 就再也跑不了,判据只能读源码'); +}); + +/* ────────────────────── ⑩ 形态:ArkUI 状态绑定 ────────────────────── */ + +test('背景选择器:@Link 不许给初值(ArkTS 会报 "forbidden to specify default value for @Link")', () => { + const src = code(join(ETS, 'common/BackgroundPicker.ets')); + const links = src.match(/@Link\s+\w+\s*:\s*[^;]+;/g) || []; + assert.ok(links.length > 0, '选择器要用 @Link 双向绑'); + for (const l of links) { + assert.ok(!/=/.test(l), + `★ 「${l.trim()}」给 @Link 写了初值 ⇒ 编不过(V1 家规:@Link 不许有 initializer)`); + } +}); + +test('背景选择器:父组件用 $ 传 @Link(传 this.xxx 会变成单向 @Prop,改不动父状态)', () => { + const src = code(SETTINGS_PAGE); + const idx = src.indexOf('BackgroundPicker({'); + assert.ok(idx > 0, '设置页要挂上 BackgroundPicker'); + const mount = src.slice(idx, idx + 600); + for (const name of ['bgKind', 'bgPresetId', 'bgDim', 'bgBlur']) { + assert.ok(new RegExp(`${name}: \\$${name}`).test(mount), + `★ ${name} 没用 $${name} 传 ⇒ 双向绑失效`); + assert.ok(!new RegExp(`${name}: this\\.${name}`).test(mount), + `★ ${name} 用了 this.${name} ⇒ 那是单向传值,选择器改不动页面的 @State`); + } +}); diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 798b434..bb8dfa6 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -157,6 +157,28 @@ test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞 assert.ok(members.includes(tier), `${tier} 必须是系统 BlurStyle 的成员`); } assert.ok(members.includes(sdk)); + + /* + * 另一半:`名字 → BlurStyle 枚举` 那张表在页面里(纯逻辑层看不到 BlurStyle), + * 它的**键必须与 SDK 成员逐字相同** —— 写错一个字母是"编译不过或静默不生效", + * 而这类错在真机上表现为"拖滑杆没反应"(最难查的那一类)。 + * 这里判据自己去页面源码里把键取出来,逐个对着 SDK 的成员名核。 + */ + const main = read('pages/MainPage.ets'); + const tableAt = main.indexOf('const BLUR_STYLE_OF: Record = {'); + assert.ok(tableAt > 0, '页面里要有 `BLUR_STYLE_OF`(档位名 → SDK 枚举)那张表'); + const tableEnd = main.indexOf('};', tableAt); + const table = main.slice(tableAt, tableEnd); + const keys = [...table.matchAll(/'([A-Z_]+)':\s*BlurStyle\.([A-Z_]+)/g)]; + assert.ok(keys.length >= 4, `要从表里读到键(实际 ${keys.length} 项)`); + for (const [, key, val] of keys) { + assert.equal(key, val, `表项 '${key}': BlurStyle.${val} —— 键与值必须同名(不同名几乎必然是写错了)`); + assert.ok(members.includes(key), `'${key}' 不是 SDK 的 BlurStyle 成员(写自造名字会编译不过或不生效)`); + } + // 四档都要在(漏一档会让那个档位静默回落成 BlurStyle.NONE) + for (const tier of ['NONE', 'COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK']) { + assert.ok(keys.some(([, k]) => k === tier), `表里缺 ${tier} ⇒ 该档会静默回落成"不模糊"`); + } }); test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不自己维护一套色值)', () => { @@ -427,10 +449,35 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统 const wallpaperBuilder = mainLines.slice(start, stop).join('\n'); assert.ok(!/NavBar/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去'); assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文'); - assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), '壁纸层不许再用材质(同一张底糊两遍 = 更脏更掉帧)'); - assert.ok(!/blur\(/i.test(wallpaperBuilder), '壁纸层不许出现任何模糊调用'); - // 导航条那一次模糊仍然在(背后是会滚动的内容,遮蔽有意义) - assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条必须有系统材质'); + /* + * ── 这两条 2026-09-15 修正过,理由值得留在这里 ── + * + * 原来是「壁纸层不许出现**任何**模糊调用」(`!/blur\(/i`)。那条**太宽**: + * 它把两种**不同的物理量**当成了一件事,而 WebUI 侧的源码证明它们是分开的 + * (`client/electron/src/index.css`): + * · `.app-backdrop`(z-index:-1,背后什么都没有)吃 `filter: blur(var(--bg-blur))` + * ⇒ **图片内容模糊**(服务端 `bg_blur` 那个 px 值,作用对象是壁纸自己); + * · `.app-backdrop` 之上的面吃 `backdrop-filter: blur(8px)` + * ⇒ **背后内容模糊**(面板材质)。 + * "同一张底被糊两遍"指的是**后者在一张已经糊过的底上再做一次**,不是"壁纸自己不许糊"。 + * + * 所以正确的互斥形式是:**壁纸层只许做图片内容模糊,不许做面板材质模糊**; + * 面板材质只许出现在导航条那种"背后是可变内容"的层。两条分别钉住。 + * + * (触发这次修正的是 P4c:加背景选择器时滑杆能拖、`bg_blur` 能存, + * 但壁纸一点没糊 —— 而计划文档 §7.12 恰好写着"若将来鸿蒙开始消费它, + * 那时必须补一条映射判据,并更新本行"。) + */ + assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), + '★ 壁纸层不许用**面板材质**(`backgroundBlurStyle` 作用在背景=背后的内容上;' + + '壁纸层背后什么都没有,那是"给一张糊过的底再糊一遍"的形状)'); + assert.match(wallpaperBuilder, /\.blur\(this\.bgPlan\.blurPx\)/, + '壁纸层要按服务端 `bg_blur` 的**px 原值**做图片内容模糊(与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)'); + const imgBlur = [...wallpaperBuilder.matchAll(/\.blur\(/g)]; + assert.equal(imgBlur.length, 1, `壁纸层的图片内容模糊只许一次(实际 ${imgBlur.length} 次)`); + // 导航条的**面板材质**仍然在(背后是会滚动的内容,遮蔽有意义),且档位来自用户偏好 + assert.match(main, /\.backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/, + '导航条的面板材质要由 `bg_blur` **映射**而来(计划文档 §7.12 要求的那个调用点)'); // 材料档次由用户偏好映射而来(不是写死的半径) const store = read('common/AppearanceStore.ets'); assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式'); @@ -577,7 +624,15 @@ test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅 else if (main[i] === ')') { depth--; if (depth === 0) { callEnd = i; break; } } } const callText = main.slice(callAt, callEnd + 1); - assert.ok(/\bdark\b\s*\)/.test(callText), `深浅色要传给背景计划:${callText}`); + /* + * ★ 这条原来写的是 `/\bdark\b\s*\)/`(要求 dark 是**最后一个**实参)。 + * 那句断的是"参数顺序",不是"深浅色有没有传过去" —— P4c 在 `dark` 后面加了 + * `blurPx` 入参,它立刻红了,而传给计划的东西一个没少。**邻接不是语义** + * (与文件里取调用点正文要按括号配对是同一条规则)。改成"dark 确实在实参里"。 + */ + assert.ok(/\bdark\b\s*[,)]/.test(callText), `深浅色要传给背景计划:${callText}`); + assert.ok(/\bsnap\.bgBlur\b/.test(callText), + `模糊档也要传给背景计划(否则滑杆能拖、壁纸不糊):${callText}`); }); test('★ 预设档的遮盖:两档同一个浓度(WebUI 的 --bg-dim 不区分档位)+ 遮盖层用系统遮罩色', () => { @@ -757,7 +812,14 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮 }); /** - * ★ `bgBlur` 是"只写不读"的字段:**消费侧出现次数必须为 0**(pi 2026-09-14 指出同一形状只有一边有判据)。 + * ★ 模糊字段的**消费侧必须逐文件登记 + 计数**(pi 2026-09-14 指出同一形状只有一边有判据)。 + * + * ── 2026-09-15 状态变了(原来是"只写不读 ⇒ 消费侧必须为 0")── + * + * P4c 补上了消费点(见下面登记表的理由),所以 0 这个值**不再成立**, + * 判据的**形状**保留(逐文件 + 计数 + 写理由),值改准。 + * 标题与断言里原来那句"消费侧出现次数必须为 0"如果留着,就会变成**假话**—— + * 而假话比没有判据更糟:下一个人会以为"这里登记 0 是真的"。 * * WebUI 的 `LEGACY_BACKUP_KEY` 早就钉着"只写不读,否则它会变成新的继承源"; * 而鸿蒙侧 `bgBlur`(`Appearance.ts` clamp 存入、`AppearanceStore` 同步)**只有文档**。 @@ -769,7 +831,7 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮 * 那时要补的是"px ↔ 材质档位"的**映射判据**(见 CRITERIA.md §10 与计划文档 §7.12), * 而不是把次数从 0 改成 1 了事。 */ -test('★ bgBlur 只写不读:消费侧出现次数必须为 0(要消费就得先补映射判据)', () => { +test('★ 模糊字段的消费侧:逐文件登记 + 计数(P4c 起不再是「只写不读」,登记值已随之改准)', () => { const files = []; const walk = dir => { for (const e of readdirSync(dir, { withFileTypes: true })) { @@ -791,8 +853,29 @@ test('★ bgBlur 只写不读:消费侧出现次数必须为 0(要消费就 * 只按文件放行 ⇒ "在已允许的文件里顺手再读一下 bgBlur 做别的事"会被静默吞掉 * (例如有人在 DTO 文件里拿它算点别的)。次数写死在这里,多一次即红。 */ + /* + * ── 2026-09-15:登记值从"没有这一项(即 0)"改成 1 处 —— 按本条判据自己的要求做的 ── + * + * 这条判据的注释写着「消费侧一旦出现就红 —— 那是**必须停下来**的时刻: + * 那时要补的是"px ↔ 材质档位"的**映射判据**,而不是把次数从 0 改成 1 了事」。 + * P4c 加背景选择器时确实踩到了:滑杆能拖、`bg_blur` 能存,但壁纸一点没糊。 + * + * 停下来核完之后,**映射判据早就在了**(`blurStyleFor` 的分档边界/单调性/NaN 那条), + * 缺的是**调用点**。所以这次补的是调用点,并把下面这条登记改准: + * · `pages/MainPage.ets` 2 处:壁纸层 `.blur(this.bgPlan.blurPx)`(**图片内容模糊**, + * 与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)+ 导航条 + * `backgroundBlurStyle(blurStyleFor(this.bgPlan.blurPx))`(**面板材质**)。 + * 两处都只是**把用户那个数用出去**,不在这里做分档判断(分档在 Appearance.ts)。 + * · `common/BackgroundPicker.ets` 2 处:选择器的滑杆(`@Link bgBlur` 的绑定与 onChange) + * —— 那是**输入**,不是消费。 + * ⇒ 这是"消费点出现时按判据要求补判据"的正常流程走完一遍,不是把 0 改成 1 了事。 + */ const plumbing = new Map([ ['model/Appearance.ts', { max: 11, why: '域模型:声明 + clamp + 合并 + 映射函数 blurStyleFor(px → 材质档,见下一条判据)—— 搬运与映射,都不是消费' }], + ['pages/MainPage.ets', { max: 3, why: 'P4c 补上的两个**消费点**(映射判据早已存在,见本段说明):壁纸层图片内容模糊 + 导航条面板材质;第 3 处是同文件里说明这件事的注释' }], + ['common/BackgroundPicker.ets', { max: 6, why: '选择器的滑杆:**输入**(@Link 声明 + 上报 + 显示 + Slider 值 + onChange + 一处注释),不是"拿这个值决定画什么"' }], + ['model/Wallpaper.ts', { max: 1, why: '计划只**搬运**这个值(`blurPx`)+ 一处注释;分档判断不在这里(在 Appearance.ts 的 blurStyleFor)' }], + ['pages/SettingsPage.ets', { max: 4, why: '页面持有该值的 @State(声明 + 推服务端时写入 + 从快照复制回 + 用 `$bgBlur` 传给选择器)—— 全是搬运/传参' }], ['common/AppearanceStore.ets', { max: 4, why: '状态同步:与快照互转(搬运)' }], ['api/AppearanceApi.ets', { max: 1, why: '线上 DTO 声明 bg_blur(传输格式,不是消费)' }] ]); @@ -817,8 +900,9 @@ test('★ bgBlur 只写不读:消费侧出现次数必须为 0(要消费就 }); } assert.deepEqual(consumers, [], - `有人在消费 bgBlur 了 —— 停下:那时**必须**先补"px ↔ 材质档位"的映射判据` + - `(CRITERIA.md §10 / 计划文档 §7.12),而不是把登记值从 0 改成 1:\n ${consumers.join('\n ')}`); + `模糊字段出现了**未登记**的读取点。要么它是消费(那就要先补"px ↔ 材质档位"的映射判据,` + + `映射表见 model/Appearance.ts 的 blurStyleFor;分档边界 0/8/20 已有行为判据),` + + `要么它是搬运/输入(那就按文件登记次数并写清理由)—— 但**不许不声不响地多一处**:\n ${consumers.join('\n ')}`); }); /** diff --git a/client/electron/test/harmony-imageprep.test.mjs b/client/electron/test/harmony-imageprep.test.mjs new file mode 100644 index 0000000..d5a7bbd --- /dev/null +++ b/client/electron/test/harmony-imageprep.test.mjs @@ -0,0 +1,393 @@ +/* + * 壁纸上传(P4c)的判据 —— **行为**判据(`model/ImagePrep.ts` 真跑)+ 形态判据(`.ets` 读源码)。 + * + * ── 这一层判的是"数值与策略",不是"图好不好看" ── + * + * 压图那一段本机**跑不了**(要 `@ohos.multimedia.image` + 真机)。 + * 所以能做的是把**决定行为的那些数**抽到纯逻辑层(`model/ImagePrep.ts`,零 `@ohos` 依赖) + * 并在这里真跑它们 —— 于是"缩到多大 / 什么质量 / 什么时候退第二档 / 什么时候放弃" + * 全都有判据,而不是埋在 `.ets` 的 async 函数里只能拿正则猜。 + * + * ⚠️ **本判据不能证明** "压出来的图能看"、"真机上 picker 能打开"、 + * "服务端真的收下了" —— 那几条见文件末的未验清单。 + */ +import { join } from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { code, prose } from './lib/read.mjs'; + +const ROOT = '/home/program/agentmail'; +const ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); +const PREP_TS = join(ETS, 'model/ImagePrep.ts'); +const PICKER = join(ETS, 'common/BackgroundPicker.ets'); +const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets'); + +const P = await import(pathToFileURL(PREP_TS).href); + +/** 服务端壁纸上限(`server/internal/handler/appearance.go` 的 `appearanceMaxBytes()` 默认值) */ +const SERVER_LIMIT = 4 << 20; + +/* ────────────────────── ① 缩放 ────────────────────── */ + +test('缩放:**横竖都不超过**最长边(竖拍按宽算会超,所以必须除以 max(w,h))', () => { + const sizes = [ + [4000, 3000], [3000, 4000], // 横 / 竖 + [8000, 600], [600, 8000], // 极端长条 + [1080, 1080], [2560, 2560], + [5120, 2880], [1, 1], [1, 5000] + ]; + for (const [w, h] of sizes) { + const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE); + const longest = Math.max(s.width, s.height); + assert.ok(longest <= P.MAX_EDGE, + `★ ${w}×${h} 缩成 ${s.width}×${s.height}:最长边 ${longest} 超过 MAX_EDGE=${P.MAX_EDGE}` + + '(竖拍照片会被按宽算的公式放大到超出最长边)'); + assert.ok(s.width >= 1 && s.height >= 1, `${w}×${h} 缩出了 0 边(0 边会让解码直接抛)`); + } +}); + +test('缩放:**不放大小图**(放大同时变糊与变大,而"变大"浪费掉那道字节上限)', () => { + const s = P.scaleToMaxEdge(800, 600, P.MAX_EDGE); + assert.deepEqual({ w: s.width, h: s.height }, { w: 800, h: 600 }, + `★ 800×600 被放大成 ${s.width}×${s.height}:小图不该被放大`); + const tiny = P.scaleToMaxEdge(320, 240, P.MAX_EDGE); + assert.deepEqual({ w: tiny.width, h: tiny.height }, { w: 320, h: 240 }, '更小的图更不该放大'); +}); + +test('缩放:保持长宽比(差一像素的舍入可以,比例不能变)', () => { + for (const [w, h] of [[4000, 3000], [3840, 2160], [3000, 4000]]) { + const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE); + const before = w / h; + const after = s.width / s.height; + assert.ok(Math.abs(before - after) / before < 0.01, + `★ ${w}×${h} → ${s.width}×${s.height}:长宽比从 ${before.toFixed(4)} 变成 ${after.toFixed(4)}(照片会被拉变形)`); + } +}); + +test('缩放:退化输入不抛(0 / 负数边)', () => { + for (const [w, h] of [[0, 0], [-1, 100], [0, 500]]) { + const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE); + assert.ok(Number.isFinite(s.width) && Number.isFinite(s.height), + `${w}×${h} 给出了非有限值 ${s.width}×${s.height}`); + } +}); + +/* ────────────────────── ② 两档策略 ────────────────────── */ + +test('两档:首档 2560/0.85,超限档 1280/0.78(与 WebUI `prepareImage` 同值)', () => { + const plan = P.planCompress(4000, 3000); + assert.equal(plan.passes.length, 2, '★ 档数不是 2:少一档 ⇒ 第一档超限后没有退路,直接失败'); + const [first, second] = plan.passes; + assert.equal(first.maxEdge, 2560, '首档最长边'); + assert.equal(first.quality, 0.85, '首档质量'); + assert.equal(first.attempt, 1, '首档 attempt'); + assert.equal(second.maxEdge, 1280, '★ 退档最长边不是 1280(WebUI 用 MAX_EDGE/2)'); + assert.equal(second.quality, 0.78, '退档质量'); + assert.equal(second.attempt, 2, '退档 attempt'); + assert.ok(second.maxEdge < first.maxEdge && second.quality < first.quality, + '★ 第二档必须**同时**更小更低质:只降一个的话省不下来多少(分辨率不变时质量降 0.07 几乎不省)'); +}); + +test('两档:第二档的估算体积**明显**小于第一档(否则退档没有意义)', () => { + const plan = P.planCompress(4000, 3000); + const a = P.estimateJpegBytes(2560, 1920, plan.passes[0].quality); + const b = P.estimateJpegBytes(1280, 960, plan.passes[1].quality); + assert.ok(b < a / 2, + `★ 退档只小了 ${(100 - b / a * 100).toFixed(0)}%(${a} → ${b} 字节)⇒ 这一档救不回"首档超限"的情况`); +}); + +test('计划的目标尺寸按**首档**算(界面显示"将缩到 W×H",用户看的是第一档的结果)', () => { + const plan = P.planCompress(4000, 3000); + assert.deepEqual({ w: plan.targetWidth, h: plan.targetHeight }, { w: 2560, h: 1920 }, + '目标尺寸要等于首档缩放结果'); + const scaled = P.scaleToMaxEdge(4000, 3000, plan.passes[0].maxEdge); + assert.deepEqual({ w: plan.targetWidth, h: plan.targetHeight }, { w: scaled.width, h: scaled.height }, + '★ 目标尺寸与 passes[0] 的 maxEdge 算出来的不一致 ⇒ 界面写的和实际压的不是一件事'); +}); + +/* ────────────────────── ③ 估算与真实字节数 ────────────────────── */ + +test('估算:随像素数与质量**单调**增(否则"要不要退档"的判断会给出反直觉的结论)', () => { + const base = P.estimateJpegBytes(1000, 1000, 0.85); + assert.ok(P.estimateJpegBytes(2000, 1000, 0.85) > base, '像素多一倍,估算该更大'); + assert.ok(P.estimateJpegBytes(1000, 2000, 0.85) > base, '竖过来也该更大'); + assert.ok(P.estimateJpegBytes(1000, 1000, 0.95) > base, '质量更高,估算该更大'); + assert.ok(P.estimateJpegBytes(1000, 1000, 0.5) < base, '质量更低,估算该更小'); +}); + +test('估算:**不超过**服务端那道门的那一档,估算值要落在上传上限之内', () => { + // 2560×1920 是 4000×3000 缩到首档后的实际尺寸 —— 正常照片走的就是这一档 + const est = P.estimateJpegBytes(2560, 1920, 0.85); + assert.ok(est < P.MAX_UPLOAD_BYTES, + `★ 正常照片首档估算 ${est} 就已经超过上传上限 ${P.MAX_UPLOAD_BYTES} ⇒ 每张照片都会被白白多压一档`); + assert.ok(est > P.MAX_UPLOAD_BYTES / 4, + `★ 正常照片首档估算只有 ${est}(上限的 ${(est / P.MAX_UPLOAD_BYTES * 100).toFixed(0)}%)⇒ 上限压得过狠,白扔分辨率`); +}); + +test('上限:**留在**服务端那道门之内,且留出了余量(贴着上限会在服务端调小后变 413)', () => { + assert.ok(P.MAX_UPLOAD_BYTES < SERVER_LIMIT, + `★ 客户端上限 ${P.MAX_UPLOAD_BYTES} ≥ 服务端 ${SERVER_LIMIT} ⇒ 客户端会放过去一个必然 413 的东西`); + assert.ok(P.MAX_UPLOAD_BYTES > SERVER_LIMIT * 0.7, + `★ 客户端上限只有服务端的 ${(P.MAX_UPLOAD_BYTES / SERVER_LIMIT * 100).toFixed(0)}% ⇒ 白扔分辨率`); +}); + +test('真实字节数:**严格大于**上限才退档(等于上限要放行)', () => { + assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES - 1), false, '差一字节不该退档'); + assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES), false, + '★ 等于上限被判定为要退档 ⇒ 多压一档,白损失清晰度(服务端那边 `>` 才是拒)'); + assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES + 1), true, '超过一字节就该退档'); + assert.equal(P.shouldRetryWithActual(0), false, '0 字节不退档'); +}); + +test('估算**不是**测量:源码里要写明"拿到真实长度后再判一次"', () => { + const src = prose(PREP_TS); + assert.ok(/shouldRetryWithActual/.test(src) && /真实/.test(src), + '★ 估算函数的注释里没写"要拿真实字节数再判一次" ⇒ 下一个人会把估算当测量值用,' + + '于是"估出来没超、实际超了"的图会被直传成 413'); +}); + +/* ────────────────────── ④ 入口检查:每条拒绝路径都有原因 ────────────────────── */ + +test('入口检查:非图片 / 超过 20MB / 正好 20MB 三条边界的判定与文案', () => { + const ok = P.judgePick(3 * 1024 * 1024, 'image/jpeg'); + assert.equal(ok.ok, true, '正常照片该放行'); + assert.equal(ok.reason, '', '放行时不该带原因'); + + const notImage = P.judgePick(1000, 'application/pdf'); + assert.equal(notImage.ok, false, '★ 非图片被放行了 ⇒ 会走到解码那一步才炸'); + assert.equal(notImage.reason, P.NOT_IMAGE_REASON, '非图片的原因要用常量(文案改了判据要跟着红)'); + assert.ok(notImage.reason.length > 0, '★ 拒绝必须**带原因**:静默失败在 WebUI 那边踩过(P4c 第②条)'); + + const tooBig = P.judgePick(P.MAX_SOURCE_BYTES + 1, 'image/jpeg'); + assert.equal(tooBig.ok, false, '超过 20MB 该拒'); + assert.equal(tooBig.reason, P.SOURCE_TOO_LARGE_REASON); + assert.ok(tooBig.reason.length > 0, '★ 拒绝必须带原因'); + + assert.equal(P.judgePick(P.MAX_SOURCE_BYTES, 'image/jpeg').ok, true, + '★ 正好 20MB 被拒了 ⇒ 边界用错(WebUI 是 `> 20MB` 才拒)'); + + for (const mime of ['image/jpeg', 'image/png', 'image/webp', 'image/gif']) { + assert.equal(P.judgePick(1000, mime).ok, true, `${mime} 是服务端认的图片类型,该放行`); + } +}); + +test('入口检查:先判类型再判体积(非图片且超大时给的是"不是图片",不是"太大")', () => { + const both = P.judgePick(999 * 1024 * 1024, 'application/zip'); + assert.equal(both.reason, P.NOT_IMAGE_REASON, + '★ 两条都不满足时给了"太大" ⇒ 用户会去裁剪一个本来就不是图片的文件'); +}); + +test('入口估算:偏小的一侧才安全(估偏大会**误拒正常照片**)', () => { + // 12MP 手机照片(4032×3024)的典型 JPEG 大小是 3–5MB + const est = P.estimateSourceBytes(4032, 3024); + assert.ok(est < P.MAX_SOURCE_BYTES, + `★ 一张 4032×3024 的普通手机照片直接被判成"超过 20MB"(估出 ${est})⇒ 误拒正常需求`); + assert.ok(est > 1024 * 1024, + `估出 ${est} 太小 ⇒ 这道粗筛形同没有(那个系数写成 0.35 是有意的:4032×3024×0.35 ≈ 4.1MB,` + + '正好落在手机照片的真实区间里)'); +}); + +/* ────────────────────── ⑤ 上传失败必须带原因 ────────────────────── */ + +test('上传失败:服务端文案**原样**透出(415/413 的中文文案是唯一能让人立刻改的东西)', () => { + const serverMsg = '壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)'; + const out = P.uploadFailureHint(serverMsg); + assert.ok(out.includes(serverMsg), + `★ 服务端文案被改写了:「${serverMsg}」→「${out}」⇒ 用户只知道"失败了",不知道该换成什么格式`); + assert.ok(out.length > 0); +}); + +test('上传失败:服务端**没给**文案时也要说清"没给原因"(别只说"上传失败")', () => { + const out = P.uploadFailureHint(''); + assert.ok(/没(有)?给/.test(out) || /未/.test(out), + `★ 兜底句「${out}」没说原因缺席 ⇒ 用户分不清"服务端拒了"和"网断了"`); +}); + +test('成功文案要说"以服务端为准"(P4c 第③条:上传成功后仍以服务端为权威)', () => { + assert.ok(/服务端/.test(P.UPLOAD_OK_HINT), + `★ 成功文案「${P.UPLOAD_OK_HINT}」没提服务端 ⇒ 用户不知道这张壁纸会不会跟着账号走`); + // ★ 重新同步在**页面**层(组件只负责"报结果",见 BackgroundPicker 文件头的分工)。 + // 我第一版把这条判在组件上,判据直接红了 —— 红得对:那是真位置不同,不是漏实现。 + const page = code(SETTINGS_PAGE); + const picker = code(PICKER); + assert.ok(/onUploaded\(/.test(picker), '上传成功要通知页面(组件不自己宣布成功)'); + assert.ok(/async onWallpaperUploaded\(/.test(page), '页面要有上传成功后的处理'); + const idx = page.indexOf('async onWallpaperUploaded('); + const body = page.slice(idx, idx + 900); + assert.ok(/syncFromServer\(/.test(body), + '★ onWallpaperUploaded 里没有重新同步 ⇒ 本地自作主张标成 image 档,而服务端才是权威(P4c 第③条)'); + assert.ok(!/this\.bgKind = 'image'/.test(page), + "★ 页面里直接写了 bgKind = 'image' ⇒ 那是本地宣布成功;服务端没记成 image 的话," + + '界面会显示一块取不回来的空白(resolveBackground 对"image 档但没图"给 none)'); +}); + +/* ────────────────────── ⑥ 形态:压缩链真的接上了 ────────────────────── */ + +test('压缩链:picker → 尺寸 → 入口检查 → 计划 → 逐档 → 打包 → 上传', () => { + const src = code(PICKER); + const chain = [ + ['PhotoViewPicker', '打开相册(picker)'], + ['getImageInfo', '读原始尺寸'], + ['judgePick', '入口检查(P4c:失败必须给原因)'], + ['planCompress', '造压图计划'], + ['scaleToMaxEdge', '按档缩放'], + ['desiredSize', '按目标尺寸解码(峰值内存只有目标那一份)'], + ['shouldRetryWithActual', '用**真实**字节数判要不要退档'], + ['uploadImageBytes', '上传(内存直传)'], + ['onUploaded', '报结果给页面(重新同步在页面层,见下一条判据)'] + ]; + for (const [needle, why] of chain) { + assert.ok(new RegExp(needle).test(src), `★ 压缩链断了:没有 ${needle}(${why})`); + } + // 最后一环落在页面上:组件报结果 ⇒ 页面重新同步(两半都要在,缺一半链子就是断的) + assert.ok(/syncFromServer\(/.test(code(SETTINGS_PAGE)), + '★ 压缩链断了:页面里没有 syncFromServer(以服务端为准重新同步)'); +}); + +test('压缩链:档位循环遍历 plan.passes(不是写死"试一次")', () => { + const src = code(PICKER); + assert.ok(/for \(let i = 0; i < plan\.passes\.length; i\+\+\)/.test(src), + '★ 没有遍历 plan.passes ⇒ 第二档永远不会被走到(两档策略等于只有一档)'); + assert.ok(/break/.test(src), '不超限要能提前跳出(否则每张图都被压两遍,白等一遍)'); +}); + +test('压缩链:**退档判定只有一处**,而且循环外不许重判一次(重判会把判据架空)', () => { + /* + * ★ 这条是变异测试抓出来的**真洞**,值得把来龙去脉留在这里: + * + * 原实现里"要不要退下一档"判在**两处**: + * ① 循环里 `if (!shouldRetryWithActual(usedBytes)) break;` + * ② 循环后 `if (packed === null || shouldRetryWithActual(usedBytes)) fail(TOO_LARGE_REASON)` + * 这两处**互相抵消**:把 ① 改成 `if (true)`(永远只压一档,**第二档彻底死掉**), + * 超限这件事仍然被 ② 接住 ⇒ 整套判据全绿。 + * 也就是"两个判据各自描述同一件事",最后**谁都没被真正钉住**。 + * + * ⇒ 形态上钉死:`shouldRetryWithActual` 在这条链上只许出现**两次**—— + * 循环里一次(产生结论)、`overLimit` 声明处一次(初值)—— + * 且**循环结束之后**不许再出现调用。 + */ + const src = code(PICKER); + const calls = src.match(/(?= 1, + '★ 循环里根本没**调用** shouldRetryWithActual ⇒ "要不要退下一档"压根没按真实字节数判' + + '(拆掉这一处、只把循环外那句留着也能靠另一条判据)'); + assert.equal(calls.length, 1, + `★ BackgroundPicker 里 shouldRetryWithActual 被**调用** ${calls.length} 次(应为 1)。` + + '多于 1 次通常就是"循环外又重判一次"——两处判定互相抵消,把循环里那处改成 if(true) 也不会红'); + + const loopEnd = src.indexOf('overLimit = shouldRetryWithActual(usedBytes);'); + assert.ok(loopEnd > 0, '★ 循环里没有 `overLimit = shouldRetryWithActual(usedBytes);` ⇒ 退档结论不是从循环里产生的'); + assert.ok(/if \(!overLimit\) \{\s*\n\s*break;/.test(src), + '★ `overLimit` 算出来了却没拿它决定 `break` ⇒ 循环不会因为"不超限"而提前结束(每张图都白压两档)'); + assert.ok(/overLimit = shouldRetryWithActual\(usedBytes\);\s*\n\s*if \(!overLimit\) \{\s*\n\s*break;/.test(src), + '★ `overLimit` 的赋值与紧跟的 `if (!overLimit) { break; }` 之间被改了 ⇒ 结论没有真的接上判定'); + const loopClose = src.indexOf('}', src.indexOf('if (!overLimit)', loopEnd)); + const afterLoop = src.slice(loopClose, loopClose + 400); + assert.ok(!/shouldRetryWithActual/.test(afterLoop), + '★ 循环**之后**又调了一次 shouldRetryWithActual ⇒ 两处判定互相抵消(把循环里那处改成 if(true) 也不会红)。' + + '循环外只该读 overLimit 这个结论'); +}); + +test('压缩链:两档都超限 ⇒ **必须**明确拒绝(P4c:失败必须给原因)', () => { + const src = code(PICKER); + assert.ok(/if \(packed === null \|\| overLimit\) \{/.test(src), + '★ 没有"两档都超限就拒绝"这条分支 ⇒ 超限的图会被直传,服务端回 413,而用户在界面上看不到"为什么"'); + const idx = src.indexOf('if (packed === null || overLimit) {'); + assert.ok(/TOO_LARGE_REASON/.test(src.slice(idx, idx + 200)), + '★ 拒绝时要给出 TOO_LARGE_REASON("图片压缩后仍过大,请换一张更小的图片"),不能静默'); +}); + +test('压缩链:PackingOption 的 quality 是 0~100(SDK 口径),而计划里是 0~1 —— 换算只在一处', () => { + const src = code(PICKER); + const conversions = src.match(/quality \* 100/g) || []; + assert.equal(conversions.length, 1, + `★ 出现 ${conversions.length} 处 \`quality * 100\` ⇒ 换算散在多处会漂移(一边 85 一边 0.85 就会压出比原图还大的东西)。` + + '计划里的 0~1 是照 WebUI 的 toDataURL 口径,换成 SDK 的 0~100 只该有一处'); + assert.ok(/format: 'image\/jpeg'/.test(src), '格式要是 image/jpeg(与 mime 一致,否则服务端 415)'); +}); + +test('压缩链:图片资源在**每条出口**都被释放(release 是 Promise,要 await)', () => { + const src = code(PICKER); + const releases = src.match(/await \w+\.release\(\)/g) || []; + assert.ok(releases.length >= 3, + `★ 只找到 ${releases.length} 处 \`await …release()\`:ImageSource(头) + PixelMap + ImageSource(档) 至少三处。` + + '不释放的话,连选几张图就会把相册/解码器的内存吃光(真机上表现为"选第三张时闪退")'); + const unawaited = (src.match(/^\s*(?!await)[a-zA-Z]+\.release\(\)/gm) || []); + assert.deepEqual(unawaited, [], + `★ 有没 await 的 release(): ${JSON.stringify(unawaited)} ⇒ 返回的是 Promise,"没人管的 promise"在 ArkTS 里是编不过/丢异常`); + assert.ok(/finally \{/.test(src), + '★ 释放没有放在 finally 里 ⇒ 中途抛异常时资源不释放(而且容易在"提前 return"那条路上漏掉)'); +}); + +test('压缩链:上传的字段名与 mime 交给 uploadBytes(发错 mime 会被服务端 415)', () => { + const api = code(join(ETS, 'api/ApiClient.ets')); + /* + * ★ 判据要**钉在方法体里**,不能钉在整个文件里 —— 变异测试抓出来的: + * `ApiClient` 里有**两个** multipart 构造(`uploadFile` 用 filePath 那份、 + * `uploadBytes` 第二份),原来那条 `/name: 'file'/` 在整个文件里匹配, + * 把 `uploadBytes` 里的字段名改成 `'image'` **照样全绿**(另一份还在)。 + * ⇒ 先切出 `uploadBytes` 的方法体,再在那里判。这是"判据范围比结论范围宽"的典型形状。 + */ + const upIdx = api.indexOf('async uploadBytes('); + assert.ok(upIdx > 0, 'ApiClient 要有 uploadBytes(内存直传那一份)'); + const upBody = api.slice(upIdx, api.indexOf('async uploadFile(', upIdx) > 0 + ? api.indexOf('async uploadFile(', upIdx) + : upIdx + 2000); + assert.ok(/name: 'file'/.test(upBody), + "★ uploadBytes 里的 multipart 字段名不是 'file' ⇒ 服务端 appearance.go 读不到文件" + + "(415,或服务端报缺少 file 字段)"); + assert.ok(/multiFormDataList/.test(api), '要真的走 multipart'); + assert.ok(/data: data/.test(api), + '★ 没把内存字节放进 data ⇒ 那就得走 filePath(临时文件),而上限检查在两边都会多一个失败面'); + const appApi = code(join(ETS, 'api/AppearanceApi.ets')); + assert.ok(/'image\/jpeg'/.test(appApi), + '★ 上传的 mime 不是 image/jpeg ⇒ 服务端 detectContentType 会拒(415)'); +}); + +test('压缩链:解码尺寸用的是**算出来**的目标尺寸,不是原图尺寸', () => { + /* + * ★ 这条也是变异测试抓出来的:原来只断言出现了 `desiredSize` 这个词, + * 把它改成 `desiredSize: { width: info.size.width, height: info.size.height }` + * (= 按**原图**尺寸解码,等于完全不缩放,4K 照片在低端机上直接爆内存)**判据照样全绿**。 + * ⇒ 必须钉到"喂进去的是哪个变量",不能只钉"这个键存在"。 + */ + const src = code(PICKER); + const idx = src.indexOf('desiredSize:'); + assert.ok(idx > 0, '要有 desiredSize(按目标尺寸解码,峰值内存只有目标那一份)'); + const block = src.slice(idx, idx + 160); + assert.ok(/width: size\.width/.test(block) && /height: size\.height/.test(block), + `★ desiredSize 用的不是算出来的目标尺寸(片段:${block.split('\n').slice(0, 3).join(' ')})。` + + '若用 info.size(原图尺寸)就等于完全没缩放,4K 照片解码后约 48MB,会把低端机推爆'); + assert.ok(!/desiredSize:[^}]*info\.size/.test(block), + '★ desiredSize 里出现了 info.size ⇒ 那是原图尺寸,不是目标尺寸'); +}); + +/* ────────────────────── ⑦ 形态:交付判据 ────────────────────── */ + +test('不许新写死颜色(背景选择器也一样)', () => { + const src = code(PICKER); + const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || []; + assert.deepEqual(hex, [], + `★ BackgroundPicker.ets 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`); + assert.ok(/Theme\./.test(src), '色要走 Theme'); +}); + +test('成员名不与通用属性冲突(@State opacity 这类会编不过)', () => { + const src = code(PICKER); + // 通用属性名清单:这些都是 ArkUI 的 attribute,同名成员会冲突 + const reserved = ['opacity', 'visibility', 'width', 'height', 'scale', 'rotate', 'translate', 'margin', 'padding']; + for (const name of reserved) { + const decl = new RegExp(`@(State|Prop|Link)\\s+${name}\\s*:`); + assert.ok(!decl.test(src), + `★ 出现了「@State/@Prop/@Link ${name}」⇒ 与 ArkUI 通用属性同名,ArkTS 判据会报冲突`); + } +}); + +test('未验必须如实标注(本机无设备 ⇒ 观感类结论不许写成已验)', () => { + for (const f of [PICKER, join(ETS, 'pages/AdminUsersPage.ets')]) { + const src = prose(f); + assert.ok(/未验/.test(src), + `★ ${f.replace(ROOT + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`); + } +}); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index ccc2658..af917d2 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -190,7 +190,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质), assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/, '四周要留白(左右 + 离底),贴边就不是悬浮'); assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)'); - assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '材质用系统档次,不手写 alpha'); + assert.match(bar, /\.backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/, + '材质用系统档次(不手写 alpha),且档位由用户的模糊偏好映射而来'); /* * 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值", * 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。 @@ -265,10 +266,24 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS * 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`(GLASS_REGISTRY)。 */ const wallpaper = builderBody(main, 'WallpaperLayer() {'); - assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)'); - assert.ok(!/blur\(/i.test(stripComments(wallpaper)), '壁纸层不许有任何模糊调用'); + /* + * ★ 2026-09-15 修正(P4c 补上"壁纸真的会糊"之后):原来这里写的是 + * 「壁纸层不许有**任何**模糊调用」。那句把两种不同的物理量混为一谈 —— + * 壁纸层要做的是 **图片内容模糊**(`.blur(px)`,与 WebUI 的 + * `filter: blur(var(--bg-blur))` 同一个量),**不许**做的是 **面板材质** + * (`backgroundBlurStyle`:作用对象是"背后的内容",而壁纸层背后什么都没有, + * 那才是"给一张糊过的底再糊一遍"的形状)。理由与出处见 + * `harmony-appearance.test.mjs` 里那条"模糊归属"。判定改成按**两种模糊**分别钉。 + */ + const wallpaperCode = stripComments(wallpaper); + assert.ok(!/backgroundBlurStyle/.test(wallpaperCode), + '壁纸层不许有**面板材质**(作用在背后内容上;壁纸层背后什么都没有)'); + const imgBlurs = wallpaperCode.match(/\.blur\(this\.bgPlan\.blurPx\)/g) || []; + assert.equal(imgBlurs.length, 1, + `壁纸层的**图片内容模糊**只许一次(现在 ${imgBlurs.length} 次)—— 同一张底糊两遍 = 更脏更掉帧`); const bar = builderBody(main, 'NavBar() {'); - assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)'); + assert.match(bar, /backgroundBlurStyle\(BLUR_STYLE_OF\[blurStyleFor\(this\.bgPlan\.blurPx\)\] \?\? BlurStyle\.NONE\)/, + '悬浮条必须有系统材质(背后是滚动内容),且档位由用户偏好映射而来'); // 理由要写在**原文**(注释会被剥掉,而理由就在注释里) const mainRaw = read('pages/MainPage.ets'); const navDoc = mainRaw.slice(mainRaw.indexOf('底部导航:**自绘的悬浮玻璃条**'), mainRaw.indexOf('@Builder\n NavBar() {')); diff --git a/client/electron/test/harmony-presets.test.mjs b/client/electron/test/harmony-presets.test.mjs index dd8270e..1ad7f8f 100644 --- a/client/electron/test/harmony-presets.test.mjs +++ b/client/electron/test/harmony-presets.test.mjs @@ -131,3 +131,33 @@ test('★ 静默兜底留痕:换过要带出原 id,没换过必须是空串' } } }); + +/* ── 模糊档的搬运与归一(P4c 起壁纸真的会糊了) ── */ + +test('模糊档:计划**搬得动**这个值,且 0~40 归一(边界与 Appearance.ts 的 clamp 同值)', () => { + /* + * ★ 这条是变异测试补上的:把 `plan.blurPx = normalizeBlur(blurPx)` 改成 `= 0` + * (计划根本不搬运模糊值)时,appearance 那几条**全绿** —— + * 它们判的是"页面把值传进来了"和"页面拿它去糊了",中间这一段没人判。 + */ + assert.equal(typeof W.normalizeBlur, 'function', 'Wallpaper 要提供 normalizeBlur'); + assert.equal(W.MAX_BLUR_PX, 40, '上限 40(与 model/Appearance.ts 的 clampNumber(bgBlur,0,40,4) 同值)'); + + for (const [input, want] of [[0, 0], [4, 4], [40, 40], [41, 40], [999, 40], [-5, 0], [-0.4, 0]]) { + assert.equal(W.normalizeBlur(input), want, `normalizeBlur(${input}) 应当是 ${want}`); + } + assert.equal(W.normalizeBlur(4.6), 5, '小数要四舍五入到整数 px'); + assert.equal(W.normalizeBlur(NaN), 0, 'NaN 要归 0(不能是 NaN,否则喂给 blur() 是未定义行为)'); + + // 搬运:三档都要带上,而且值就是传进去的那个 + for (const kind of ['preset', 'image', 'none']) { + const plan = W.resolveBackground(kind, 'aurora', 0.2, kind === 'image', false, 12); + assert.equal(plan.blurPx, 12, `\`${kind}\` 档没有把模糊值搬进计划(计划里是 ${plan.blurPx})`); + } + // 缺省入参:老调用点(不传第 6 个参数)语义不变 ⇒ 0 + const legacy = W.resolveBackground('preset', 'aurora', 0.2, false, false); + assert.equal(legacy.blurPx, 0, '不传模糊档时应当是 0(老调用点语义不变)'); + // 越界要**归一**再搬,而不是把 999 原样带出去 + assert.equal(W.resolveBackground('image', 'aurora', 0.2, true, false, 999).blurPx, 40, + '越界的模糊值要在搬运时就归一(否则页面会把 999 直接喂给 blur())'); +}); diff --git a/client/harmony/entry/src/main/ets/api/AdminApi.ets b/client/harmony/entry/src/main/ets/api/AdminApi.ets new file mode 100644 index 0000000..71b63b5 --- /dev/null +++ b/client/harmony/entry/src/main/ets/api/AdminApi.ets @@ -0,0 +1,132 @@ +/* + * AgentMail 鸿蒙客户端 — 管理员 API(用户管理) + * + * 端点与 WebUI(`client/electron/src/api/client.ts`)**逐个对应**,服务端实现在 + * `server/internal/handler/auth.go`: + * + * GET /admin/users 列用户 + * POST /admin/users 建用户 + * PUT /admin/users/{id} 改用户(**部分更新**) + * DELETE /admin/users/{id} 禁用用户(**不是物理删除**,保留邮件历史) + * POST /admin/users/{id}/reset 重置密码 + * GET /admin/scopes 可授权的 Agent / 目录候选 + * + * ★ 路径是**相对基地址**的(base 已含 `/api/v1`)。这里不是多此一举的提醒: + * `AppearanceApi.ets` 的文件头记着 WebUI 踩过的同一个坑 —— 第一版写成 + * `/api/v1/me/appearance`,实际请求成了 `/api/v1/api/v1/...`, + * 整套同步"从来没生效过"而单测全绿(只断言了方法与报文、没断言 URL)。 + * 所以这个文件的每条路径都有判据钉着。 + */ +import { ApiClient } from './ApiClient'; +import { + AdminUser, + AdminUsersResponse, + AdminScopes, + AdminCreateUserInput, + AdminUpdateUserInput, + AdminResetPasswordInput, + AdminUserResponse, + AdminStatusResponse +} from '../model/Models'; + +export class AdminApi { + private client: ApiClient; + + constructor(client: ApiClient) { + this.client = client; + } + + /** + * 列全部用户。 + * + * 服务端回 `{users: [...]}`。这里**不**把缺失的 `users` 当空数组糊过去 —— + * 直接读字段,读不到就是 `undefined`,由调用方按"没拿到"处理; + * 糊成 `[]` 会让"服务端换了形状"表现得像"一个用户都没有"(最坏的那种静默)。 + */ + async listUsers(): Promise { + return this.client.get('/admin/users'); + } + + /** 可授权的 Agent 与目录候选。空数组的语义是"没东西可授权",不是"不限"。 */ + async listScopes(): Promise { + return this.client.get('/admin/scopes'); + } + + /** + * 建用户。 + * + * 服务端严格解码(多字段即 400),必填校验在服务端: + * 密码 < 8 位 → 400「密码至少 8 位」;用户名不合法 → 400;重名 → 409。 + * 这三条文案**必须原样显示给用户**(它们是唯一能让人立刻改的东西), + * 不要在这里改写成"创建失败"。 + */ + async createUser(input: AdminCreateUserInput): Promise { + return this.client.post('/admin/users', input); + } + + /** + * 改用户(部分更新)。 + * + * ★ 只把**非 undefined** 的字段放进报文:服务端那几个字段是指针, + * `nil` = 别动;而把 `display_name` 发成空串 = **把显示名清空**。 + * 一次性把五个字段都发(用空串/空数组占位)就会把用户的显示名与白名单清掉, + * 而调用方可能只想改个状态。这个拼报文的地方必须只放"真的要给"的字段。 + * + * 具体做法:先造一个 `Record`,只往里塞给了的键。 + * (ArkTS 里不能用展开运算符做"条件展开",所以这里显式逐个判。) + */ + async updateUser(userId: string, input: AdminUpdateUserInput): Promise { + const body: Record = {}; + if (input.display_name !== undefined) { + body['display_name'] = input.display_name; + } + if (input.role !== undefined) { + body['role'] = input.role; + } + if (input.status !== undefined) { + body['status'] = input.status; + } + if (input.allowed_agents !== undefined) { + body['allowed_agents'] = input.allowed_agents; + } + if (input.allowed_paths !== undefined) { + body['allowed_paths'] = input.allowed_paths; + } + return this.client.put('/admin/users/' + encodeURIComponent(userId), body); + } + + /** 禁用用户(软禁用,保留历史)。服务端会拦"最后一个管理员"。 */ + async disableUser(userId: string): Promise { + return this.client.del('/admin/users/' + encodeURIComponent(userId)); + } + + /** 重置密码(服务端要求 ≥ 8 位)。 */ + async resetPassword(userId: string, newPassword: string): Promise { + const input: AdminResetPasswordInput = new AdminResetPasswordInput(); + input.new_password = newPassword; + return this.client.post( + '/admin/users/' + encodeURIComponent(userId) + '/reset', + input + ); + } +} + +/** + * `GET /me` → 当前登录用户。 + * + * 放在这里而不是 `AuthApi`:管理页的**可见性**靠它(`role === 'admin'`), + * 而 `AuthApi` 是登录/登出那条链。同名函数分处两个类会让人以为有一处是死的。 + */ +export class MeApi { + private client: ApiClient; + + constructor(client: ApiClient) { + this.client = client; + } + + /** 当前用户。**失败要抛**,不要吞成"不是管理员"。 */ + async get(): Promise { + const resp: AdminUserResponse = await this.client.get('/me'); + return resp.user; + } +} diff --git a/client/harmony/entry/src/main/ets/api/ApiClient.ets b/client/harmony/entry/src/main/ets/api/ApiClient.ets index 806f565..2eead02 100644 --- a/client/harmony/entry/src/main/ets/api/ApiClient.ets +++ b/client/harmony/entry/src/main/ets/api/ApiClient.ets @@ -312,6 +312,74 @@ export class ApiClient { } } + /** + * 上传**内存里**的字节(multipart/form-data,字段名 file)→ 服务端回的 id/文案。 + * + * 与 `uploadFile` 的分工:那个用 `filePath` 让系统自己去读磁盘; + * 这个用于"图已经在内存里"的场景(壁纸压完就是 `ArrayBuffer`)—— + * 走内存就不必先落一个临时文件、也就不必管它的清理(临时文件泄漏是慢性的, + * 而且"压缩后写盘失败"会变成一个与压缩无关的新失败面)。 + * + * ★ `data` 收 `ArrayBuffer`(SDK:`data?: string | Object | ArrayBuffer`,since 11;本工程是 23), + * 且 SDK 注释写明「If data has a value, filePath does not take effect」—— + * 所以内存字节直传,**不需要 base64、也不需要临时文件**。 + * + * ★ 这里**不猜 mime**:调用方给什么用什么。壁纸端点只收图片, + * 服务端按 `Content-Type` **和**文件名后缀判(`detectContentType`), + * 发成 `application/octet-stream` 会被 415 拒掉,所以默认给 `image/jpeg` + * (压图走的就是 JPEG;这也是调用方唯一会用的格式)。 + */ + async uploadBytes(path: string, data: ArrayBuffer, fileName: string, mimeType: string): Promise { + const url: string = this.apiBase + path; + const httpRequest = http.createHttp(); + try { + const header: Record = {}; + if (this.token.length > 0) { + header['Authorization'] = 'Bearer ' + this.token; + } + const multiFormData: http.MultiFormData = { + name: 'file', + contentType: mimeType, + remoteFileName: fileName, + data: data + }; + const options: http.HttpRequestOptions = { + method: http.RequestMethod.POST, + header: header, + multiFormDataList: [multiFormData], + connectTimeout: 30000, + readTimeout: 60000 + }; + hilog.info(DOMAIN, TAG, '→ UPLOAD(bytes) %{public}s', url); + const response = await httpRequest.request(url, options); + const code: number = response.responseCode; + const rawText: string = response.result as string; + if (code >= 200 && code < 300) { + if (rawText.length === 0) { + return ''; + } + try { + const parsed = JSON.parse(rawText) as Record; + if (parsed['attachment_id'] !== undefined) { + return parsed['attachment_id']; + } + } catch (e) { + // 可能直接返回纯文本 + } + return rawText; + } + throw new ApiError(code, rawText.length > 0 ? rawText : ('HTTP ' + code)); + } catch (e) { + if (e instanceof ApiError) { + throw e; + } + const be = e as BusinessError; + throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error'); + } finally { + httpRequest.destroy(); + } + } + /** 清除本地认证态(401 时调用) */ clearAuth(): void { this.token = ''; diff --git a/client/harmony/entry/src/main/ets/api/AppearanceApi.ets b/client/harmony/entry/src/main/ets/api/AppearanceApi.ets index 417e3f5..cb14d4f 100644 --- a/client/harmony/entry/src/main/ets/api/AppearanceApi.ets +++ b/client/harmony/entry/src/main/ets/api/AppearanceApi.ets @@ -53,6 +53,22 @@ export class AppearanceApi { return this.client.uploadFile('/me/appearance/image', filePath, fileName); } + /** + * 上传壁纸(**内存字节**直传,字段名 file)。 + * + * 与 `uploadImage(filePath,…)` 的分工:那个走磁盘、由系统去读; + * 这个用于"压完的图已经在内存里"(P4c 的实际路径)。 + * + * ★ mime 固定 `image/jpeg`:压图走的就是 JPEG(`model/ImagePrep.ts` 两档都是 jpeg), + * 而服务端按 `Content-Type` **和**文件名后缀判图片(`detectContentType`)—— + * 发 `application/octet-stream` 会被 415 拒掉,而这个 415 的文案 + * ("壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)") + * 正是"失败必须给原因"里那个原因,所以这里不能发错。 + */ + async uploadImageBytes(bytes: ArrayBuffer, fileName: string): Promise { + return this.client.uploadBytes('/me/appearance/image', bytes, fileName, 'image/jpeg'); + } + /** * 取壁纸**本体**。 * diff --git a/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets b/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets new file mode 100644 index 0000000..f75b6a0 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets @@ -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 { + 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 { + 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`(SDK 两个重载:callback 版与 Promise 版)—— + // 第 22 条判据把这一处抓出来了:不 await 的话它是一个"没人管的 promise", + // 而它出现在 `finally` 里,抛出的异常会替换掉原来的异常(真正的失败原因被吞掉)。 + await packer.release(); + } +} diff --git a/client/harmony/entry/src/main/ets/model/AdminUsers.ts b/client/harmony/entry/src/main/ets/model/AdminUsers.ts new file mode 100644 index 0000000..eb20a28 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/AdminUsers.ts @@ -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; +} diff --git a/client/harmony/entry/src/main/ets/model/Appearance.ts b/client/harmony/entry/src/main/ets/model/Appearance.ts index 0ae1344..b0af069 100644 --- a/client/harmony/entry/src/main/ets/model/Appearance.ts +++ b/client/harmony/entry/src/main/ets/model/Appearance.ts @@ -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); diff --git a/client/harmony/entry/src/main/ets/model/ImagePrep.ts b/client/harmony/entry/src/main/ets/model/ImagePrep.ts new file mode 100644 index 0000000..da7c226 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/ImagePrep.ts @@ -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 再决定要不要自己实现。 + */ diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 9a166c5..81f348b 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -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 = ''; +} diff --git a/client/harmony/entry/src/main/ets/model/Wallpaper.ts b/client/harmony/entry/src/main/ets/model/Wallpaper.ts index 52b3d4b..b5b9f4d 100644 --- a/client/harmony/entry/src/main/ets/model/Wallpaper.ts +++ b/client/harmony/entry/src/main/ets/model/Wallpaper.ts @@ -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; diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets new file mode 100644 index 0000000..8e777b8 --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -0,0 +1,579 @@ +/* + * AgentMail 鸿蒙客户端 — 管理员:用户管理 + * + * 对应 WebUI 的 `client/electron/src/components/AdminUsersPage.tsx`,按 WebUI 的排布顺序 + * 收口「用户管理」这一栏(`docs/HARMONY-ALIGN-PLAN.md` 原先把管理员后台整个划在鸿蒙首版之外, + * 用户明确要求「功能做全再交付」后收进来)。 + * + * ── 为什么是**独立路由页**而不是 MainPage 的第四栏 ── + * + * WebUI 侧它的位置是「"我的"页底部、仅管理员可见」(`App.tsx`:`viewMode === 'admin' && user?.role === 'admin'`)。 + * 鸿蒙侧对应的位置就是**设置页底部**,所以这里也做成一个从设置页推入的路由页 + * (`pages/AdminUsersPage`,`pushUrl`),而不是往底部三栏里加第四栏 —— + * 底栏三栏(通信/日历/联系人)是**日常使用**的信息架构,管理面不是日常。 + * + * ── 判据纪律(本文件逐条遵守,判据在 `client/electron/test/harmony-admin.test.mjs`)── + * + * · **用户真正会点的那一层**:每个可点元素都是真实 `onClick` 到真实动作;判据按 + * "动作与服务端调用同名"检查,而不是"页面上出现了这个词"。 + * · **不许新写死颜色**:色一律走 `Theme.*`(`danger`/`approve`/`accent` 是 Theme 里 + * 已命名的语义色,品牌色本来就手写在那一个文件里)。本文件不出现任何 `#` 色值。 + * · **不给单个面单独做深色**:不写 `#xxxxxx`,也就无从分叉;深浅由系统按色彩模式给。 + * · **同一张底只允许被模糊一次**:本页不碰壁纸/模糊(那是 `MainPage` 的外观层)。 + * · **列表项每项一张卡**:每个用户在 `List` 里是独立一张卡(`Theme.surface` + `radiusCard`)。 + * · **视觉不可验必须如实标注**:见文件末「未验」段。 + */ +import { ApiClient, ApiError } from '../api/ApiClient'; +import { Theme } from '../common/Theme'; +import { AdminApi, MeApi } from '../api/AdminApi'; +import { + AdminUser, + AdminScopes, + AdminCreateUserInput, + AdminUpdateUserInput +} from '../model/Models'; +import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted } from '../model/AdminUsers'; + +@Entry +@Component +struct AdminUsersPage { + @State users: AdminUser[] = []; + @State scopes: AdminScopes = new AdminScopes(); + @State loading: boolean = false; + @State errorText: string = ''; + /** 展开编辑的是哪一个(空串=都收起) */ + @State editingId: string = ''; + @State showCreate: boolean = false; + /** 当前登录者是不是管理员;**未确认前一律 false**(见文件头与 `isAdminRole`) */ + @State isAdmin: boolean = false; + /** 身份读没读到(读不到时不显示"你不是管理员"这种断言,只显示"读不到") */ + @State roleKnown: boolean = false; + + // 新建用户表单 + @State newUsername: string = ''; + @State newDisplayName: string = ''; + @State newPassword: string = ''; + @State newRole: string = 'user'; + @State busy: boolean = false; + + // 编辑中用户的可改字段(进入编辑时从该用户复制一份) + @State editDisplayName: string = ''; + @State editRole: string = 'user'; + @State editAgents: string[] = []; + @State editPaths: string[] = []; + @State editPassword: string = ''; + + private client: ApiClient | null = null; + private api: AdminApi | null = null; + + aboutToAppear(): void { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + this.client = ApiClient.getInstance(ctx); + this.api = new AdminApi(this.client); + this.loadRole(); + this.load(); + } + + /** + * 读当前身份。 + * + * ★ 失败时 `roleKnown` 保持 false 且 `isAdmin` 保持 false: + * 不能把"读不到"当成"是管理员"(那会让一次网络抖动对所有人显示管理入口), + * 也不能当成"不是"(那会误导人以为自己没权限)。两种错都不报,只报"读不到"。 + */ + async loadRole(): Promise { + const client: ApiClient | null = this.client; + if (client === null) { + return; + } + try { + const me: AdminUser = await new MeApi(client).get(); + this.isAdmin = isAdminRole(me.role); + this.roleKnown = true; + } catch (e) { + this.roleKnown = false; + this.isAdmin = false; + } + } + + async load(): Promise { + const api: AdminApi | null = this.api; + if (api === null) { + return; + } + this.loading = true; + this.errorText = ''; + try { + const usersResp = await api.listUsers(); + this.users = usersResp.users; + const scopesResp = await api.listScopes(); + this.scopes = scopesResp; + } catch (e) { + this.errorText = this.messageOf(e); + } finally { + this.loading = false; + } + } + + /** 进入/退出编辑:进入时把该用户的可改字段复制到 @State(@State 观察不到类内部变化) */ + toggleEdit(user: AdminUser): void { + if (this.editingId === user.user_id) { + this.editingId = ''; + return; + } + this.editingId = user.user_id; + this.editDisplayName = user.display_name; + this.editRole = user.role; + this.editAgents = user.allowed_agents.slice(0); + this.editPaths = user.allowed_paths.slice(0); + this.editPassword = ''; + } + + /** + * 异常 → 一句话。 + * + * ★ 这个函数**故意留在页面层**(而不是 `model/AdminUsers.ts`):它要 + * `instanceof ApiError`,而 `ApiError` 是 `api/ApiClient.ets` 里的类 —— + * `.ets` import 不进纯逻辑层(那会让那个文件没法被 node 直接跑,判据就只能读源码了)。 + * 所以:**取值口径**(`isApiError` 与 `message`)在这里取, + * **兜底文案**在纯逻辑层(`messageOfApiError`,可判)。 + */ + private messageOf(e: Object): string { + return messageOfApiError(e instanceof ApiError, (e as Error).message); + } + + private toast(msg: string): void { + this.getUIContext().getPromptAction().showToast({ message: msg }); + } + + /** 只把**真的给了**的字段发出去(服务端是指针语义:nil=别动,空串=清空) */ + private buildUpdate(): AdminUpdateUserInput { + const input: AdminUpdateUserInput = new AdminUpdateUserInput(); + input.display_name = this.editDisplayName; + input.role = this.editRole; + input.allowed_agents = this.editRole === 'admin' ? [] : this.editAgents; + input.allowed_paths = this.editRole === 'admin' ? [] : this.editPaths; + return input; + } + + async saveEdit(): Promise { + const api: AdminApi | null = this.api; + const id: string = this.editingId; + if (api === null || id.length === 0) { + return; + } + this.busy = true; + try { + await api.updateUser(id, this.buildUpdate()); + this.toast('用户已更新'); + await this.load(); + } catch (e) { + this.errorText = this.messageOf(e); + } finally { + this.busy = false; + } + } + + /** + * 启用/禁用。 + * + * ★ 这里**只发 `status` 一个字段**:服务端是部分更新,只发状态就不会顺手把 + * 显示名与白名单清掉(那正是"一次发五个字段"会造成的破坏)。 + */ + async setStatus(user: AdminUser, status: string): Promise { + const api: AdminApi | null = this.api; + if (api === null) { + return; + } + try { + if (status === 'disabled') { + await api.disableUser(user.user_id); + } else { + const onlyStatus: AdminUpdateUserInput = new AdminUpdateUserInput(); + onlyStatus.status = 'active'; + await api.updateUser(user.user_id, onlyStatus); + } + this.toast(status === 'disabled' ? '用户已禁用' : '用户已启用'); + await this.load(); + } catch (e) { + // 服务端会拦"最后一个管理员",那条文案必须原样显示 + this.errorText = this.messageOf(e); + } + } + + async resetPassword(user: AdminUser): Promise { + const api: AdminApi | null = this.api; + if (api === null || this.editPassword.length < 8) { + return; + } + this.busy = true; + try { + await api.resetPassword(user.user_id, this.editPassword); + this.toast('密码已重置'); + this.editPassword = ''; + } catch (e) { + this.errorText = this.messageOf(e); + } finally { + this.busy = false; + } + } + + async createUser(): Promise { + const api: AdminApi | null = this.api; + if (api === null) { + return; + } + if (this.newUsername.trim().length < 2 || this.newPassword.length < 8) { + this.toast('用户名至少 2 位、密码至少 8 位'); + return; + } + this.busy = true; + try { + const input: AdminCreateUserInput = new AdminCreateUserInput(); + input.username = this.newUsername.trim().toLowerCase(); + input.password = this.newPassword; + input.display_name = this.newDisplayName.trim(); + input.role = this.newRole; + /* + * 白名单**建完再配**(不是漏了):新建表单只有用户名/显示名/密码/角色, + * 建好之后在新用户那张卡的编辑区里勾白名单。 + * ★ 服务端口径:空数组 = **不限**(不是"什么都不许")—— + * 所以"先不配"是安全的默认,新用户一登录不会什么都点不动; + * 要收紧就随后去勾。这也与 `ScopePick` 上那句"未勾选 = 不限"一致。 + */ + input.allowed_agents = []; + input.allowed_paths = []; + await api.createUser(input); + this.toast('用户已创建'); + this.newUsername = ''; + this.newDisplayName = ''; + this.newPassword = ''; + this.newRole = 'user'; + this.showCreate = false; + await this.load(); + } catch (e) { + // 重名 409 / 用户名不合法 400 / 密码太短 400 的文案都从这里原样显示 + this.errorText = this.messageOf(e); + } finally { + this.busy = false; + } + } + + build() { + Column() { + this.Header() + + if (this.roleKnown && !this.isAdmin) { + Column() { + Text('🔒 管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted) + Text('当前账号的角色不是管理员。') + .fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).margin({ top: 6 }) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + } else { + if (this.errorText.length > 0) { + Text(this.errorText) + .fontSize(Theme.fontSmall).fontColor(Theme.danger) + .width('100%').padding(10).margin({ left: 12, right: 12, top: 8 }) + .backgroundColor(Theme.dangerBg) + .borderRadius(Theme.radiusControl) + } + + Row() { + Text('用户管理').fontSize(Theme.fontSmall).fontColor(Theme.textMuted) + Text(' ' + this.users.length).fontSize(Theme.fontSmall).fontColor(Theme.textSubtle) + Blank() + Button(this.showCreate ? '收起' : '新建用户') + .height(34).fontSize(Theme.fontSmall) + .backgroundColor(Theme.accent).fontColor(Theme.accentFg) + .onClick(() => { this.showCreate = !this.showCreate; }) + } + .width('100%').padding({ left: 12, right: 12, top: 8 }) + + if (this.showCreate) { + this.CreateForm() + } + + if (this.loading && this.users.length === 0) { + Text('加载中…').fontSize(Theme.fontSmall).fontColor(Theme.textSubtle) + .width('100%').textAlign(TextAlign.Center).margin({ top: 24 }) + } else { + List({ space: 8 }) { + ForEach(this.users, (user: AdminUser) => { + ListItem() { + this.UserCard(user) + } + }, (user: AdminUser) => user.user_id) + } + .width('100%').layoutWeight(1) + .padding({ left: 12, right: 12, top: 8, bottom: 12 }) + } + } + } + .width('100%').height('100%') + .backgroundColor(Theme.pageBg) + } + + @Builder + Header() { + Row() { + Text('‹').fontSize(24).fontColor(Theme.accent).width(40).height(40) + .textAlign(TextAlign.Center) + .onClick(() => { this.getUIContext().getRouter().back(); }) + Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .layoutWeight(1) + // 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上 + Text('↻').fontSize(20).fontColor(Theme.accent).width(40).height(40) + .textAlign(TextAlign.Center) + .onClick(() => { this.load(); }) + } + .width('100%').height(56).padding({ left: 8, right: 8 }) + .backgroundColor(Theme.surface) + } + + /** + * 一个用户 = 一张卡(列表项每项一张卡)。 + * + * 展开的那张把编辑区接在同一张卡里(不是另开一张), + * 否则"在编辑谁"要靠位置猜。 + */ + @Builder + UserCard(user: AdminUser) { + Column() { + Row() { + Text(this.editingId === user.user_id ? '⌄' : '›') + .fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).width(18) + Text(user.username) + .fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + this.Chip(user.role === 'admin' ? '管理员' : '用户', + user.role === 'admin' ? Theme.chipNeutralBg : Theme.surfaceMuted, + user.role === 'admin' ? Theme.accent : Theme.textMuted) + Text(' ').width(4) + this.Chip(user.status === 'active' ? '启用' : '禁用', + user.status === 'active' ? Theme.approveBg : Theme.surfaceMuted, + user.status === 'active' ? Theme.approveFg : Theme.textMuted) + /* + * 「受限」徽标:**只在普通用户且白名单非空时**显示 —— 与 WebUI 逐字一致 + * (`AdminUsersPage.tsx`:`user.role !== 'admin' && (allowed_agents.length > 0 || …)`)。 + * ★ 管理员**不加**这个徽标:服务端对管理员忽略白名单, + * 给管理员打「受限」会让人以为他的权限被削了。 + * ★ 白名单非空才叫"受限":空 = 不限(不是"什么都不许"),那种情况不该有徽标。 + */ + if (isRestricted(user)) { + Text(' ').width(4) + this.Chip('受限', Theme.surfaceMuted, Theme.textSubtle) + } + } + .width('100%') + + Row() { + Text(user.display_name.length > 0 ? user.display_name : user.username) + .fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).layoutWeight(1) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(lastLoginLabel(user)) + .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + } + .width('100%').margin({ top: 4 }) + + if (this.editingId === user.user_id) { + this.UserEditor(user) + } + } + .width('100%') + .padding(12) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .onClick(() => { this.toggleEdit(user); }) + } + + @Builder + Chip(text: string, bg: string, fg: string) { + Text(text) + .fontSize(Theme.fontTiny) + .fontColor(fg) + .backgroundColor(bg) + .borderRadius(4) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + } + + /** 编辑区:显示名 / 角色 / 状态 / 白名单 / 重置密码 */ + @Builder + UserEditor(user: AdminUser) { + Column() { + Divider().color(Theme.border).margin({ top: 10, bottom: 10 }) + + Text('显示名').fontSize(Theme.fontTiny).fontColor(Theme.textMuted) + .width('100%') + TextInput({ placeholder: '显示名', text: this.editDisplayName }) + .width('100%').height(40).margin({ top: 4 }) + .onChange((value: string) => { this.editDisplayName = value; }) + + Text('角色').fontSize(Theme.fontTiny).fontColor(Theme.textMuted) + .width('100%').margin({ top: 10 }) + Row() { + this.RolePick('普通用户', 'user') + Text(' ').width(8) + this.RolePick('管理员', 'admin') + } + .width('100%').margin({ top: 4 }) + + if (this.editRole !== 'admin') { + this.ScopePick('可调用 Agent', this.scopes.agents, this.editAgents, true) + this.ScopePick('可访问目录', this.scopes.paths, this.editPaths, false) + } else { + Text('管理员不受白名单限制(服务端对管理员忽略这两项)。') + .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + .width('100%').margin({ top: 8 }) + } + + Row() { + Button(user.status === 'active' ? '禁用' : '启用') + .height(34).fontSize(Theme.fontSmall) + .backgroundColor(Theme.surfaceMuted).fontColor(Theme.danger) + .onClick(() => { + this.setStatus(user, user.status === 'active' ? 'disabled' : 'active'); + }) + Blank() + Button(this.busy ? '保存中' : '保存更改') + .height(34).fontSize(Theme.fontSmall) + .backgroundColor(Theme.accent).fontColor(Theme.accentFg) + .onClick(() => { this.saveEdit(); }) + } + .width('100%').margin({ top: 12 }) + + Divider().color(Theme.border).margin({ top: 12, bottom: 10 }) + + Text('重置密码').fontSize(Theme.fontTiny).fontColor(Theme.textMuted) + .width('100%') + Row() { + TextInput({ placeholder: '新密码(至少 8 位)', text: this.editPassword }) + .layoutWeight(1).height(38).type(InputType.Password) + .onChange((value: string) => { this.editPassword = value; }) + Text(' ').width(8) + Button('重置') + .height(38).fontSize(Theme.fontSmall) + .backgroundColor(Theme.surfaceMuted).fontColor(Theme.textPrimary) + .enabled(this.editPassword.length >= 8) + .onClick(() => { this.resetPassword(user); }) + } + .width('100%').margin({ top: 4 }) + } + .width('100%') + } + + @Builder + RolePick(label: string, value: string) { + Text(label) + .fontSize(Theme.fontSmall) + .fontColor(this.editRole === value ? Theme.accentFg : Theme.textMuted) + .backgroundColor(this.editRole === value ? Theme.accent : Theme.surfaceMuted) + .borderRadius(Theme.radiusControl) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick(() => { this.editRole = value; }) + } + + /** + * 白名单多选。 + * + * 口径与 WebUI 逐字一致:**未勾选 = 不限**(不是"什么都不可用")。 + * 缺省给"不限"而不是"全禁":管理员建完用户还没配白名单时, + * "全禁"会让新用户一登录什么都点不动,看起来像坏了。 + */ + @Builder + ScopePick(label: string, items: string[], selected: string[], isAgent: boolean) { + Column() { + Row() { + Text(label).fontSize(Theme.fontTiny).fontColor(Theme.textMuted) + Text(selected.length > 0 ? '(已选 ' + selected.length + ' 项)' : '') + .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + Blank() + Text('未勾选 = 不限').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + } + .width('100%').margin({ top: 10 }) + + if (items.length === 0) { + Text('(服务端没给出候选)').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + .width('100%').margin({ top: 4 }) + } else { + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(items, (item: string) => { + Text(item) + .fontSize(Theme.fontTiny) + .fontColor(selected.includes(item) ? Theme.accentFg : Theme.textMuted) + .backgroundColor(selected.includes(item) ? Theme.accent : Theme.surfaceMuted) + .borderRadius(4) + .padding({ left: 8, right: 8, top: 4, bottom: 4 }) + .margin({ right: 6, top: 6 }) + .onClick(() => { + if (isAgent) { + this.editAgents = toggled(this.editAgents, item); + } else { + this.editPaths = toggled(this.editPaths, item); + } + }) + }, (item: string) => item) + } + .width('100%') + } + } + .width('100%') + } + + /** 新建用户表单(字段与校验口径照 WebUI:用户名 ≥2、密码 ≥8) */ + @Builder + CreateForm() { + Column() { + TextInput({ placeholder: '用户名(小写字母数字 . _ -)', text: this.newUsername }) + .width('100%').height(40) + .onChange((value: string) => { this.newUsername = value; }) + TextInput({ placeholder: '显示名', text: this.newDisplayName }) + .width('100%').height(40).margin({ top: 8 }) + .onChange((value: string) => { this.newDisplayName = value; }) + TextInput({ placeholder: '初始密码(至少 8 位)', text: this.newPassword }) + .width('100%').height(40).margin({ top: 8 }).type(InputType.Password) + .onChange((value: string) => { this.newPassword = value; }) + + Row() { + Text('角色').fontSize(Theme.fontTiny).fontColor(Theme.textMuted) + Blank() + Text('普通用户') + .fontSize(Theme.fontSmall) + .fontColor(this.newRole === 'user' ? Theme.accentFg : Theme.textMuted) + .backgroundColor(this.newRole === 'user' ? Theme.accent : Theme.surfaceMuted) + .borderRadius(Theme.radiusControl) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick(() => { this.newRole = 'user'; }) + Text(' ').width(8) + Text('管理员') + .fontSize(Theme.fontSmall) + .fontColor(this.newRole === 'admin' ? Theme.accentFg : Theme.textMuted) + .backgroundColor(this.newRole === 'admin' ? Theme.accent : Theme.surfaceMuted) + .borderRadius(Theme.radiusControl) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick(() => { this.newRole = 'admin'; }) + } + .width('100%').margin({ top: 10 }) + + Text('白名单建完再配:创建后在新用户那张卡里勾「可调用 Agent / 可访问目录」。未勾 = 不限。') + .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + .width('100%').margin({ top: 8 }) + + Row() { + Blank() + Button(this.busy ? '创建中' : '创建') + .height(34).fontSize(Theme.fontSmall) + .backgroundColor(Theme.accent).fontColor(Theme.accentFg) + .enabled(this.newUsername.trim().length >= 2 && this.newPassword.length >= 8 && !this.busy) + .onClick(() => { this.createUser(); }) + } + .width('100%').margin({ top: 10 }) + } + .width('100%').padding(12).margin({ left: 12, right: 12, top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + } +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 42288eb..d3684ad 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -14,7 +14,7 @@ import { SseService, SseEvent } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; import { Configuration, ConfigurationConstant, EnvironmentCallback } from '@kit.AbilityKit'; import { image } from '@kit.ImageKit'; -import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance'; +import { AppearanceSnapshot, isDarkMode, scrimOpacity, blurStyleFor } from '../model/Appearance'; import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper'; import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models'; import { @@ -49,6 +49,27 @@ import { emptyHint } from '../model/CommTabs'; import { MailDetailParams, ComposeParams } from '../model/RouteParams'; + +/** + * `blurStyleFor` 给的**档位名** → SDK 的 `BlurStyle` 枚举。 + * + * ★ 这张表**必须**待在页面里(不能挪进 `model/Appearance.ts`):`BlurStyle` 是 SDK 的 + * 枚举,只有 `.ets` 里才在作用域内;而 `model/Appearance.ts` 是**纯逻辑、零 `@ohos` 依赖**, + * 正因如此判据能用 node 直接跑它(分档边界 0/8/20 就是那么钉住的)。 + * 分工是:**分档判断**在纯逻辑层(可判),**名字 → 枚举**这一步在这里(对照 SDK 写)。 + * + * ★ 用 `Record` 而不是 if/else 链:枚举成员名与键**同名**, + * 一眼能看出有没有写错、漏项(if/else 链漏一个分支只会静默走 else)。 + * ★ 键必须与 SDK 的 `declare enum BlurStyle` 成员**逐字相同** —— + * `harmony-appearance.test.mjs` 有一条判据把这里的键拿去和 SDK 比对 + * ("写成自造名字会编译不过/不生效")。 + */ +const BLUR_STYLE_OF: Record = { + 'NONE': BlurStyle.NONE, + 'COMPONENT_THIN': BlurStyle.COMPONENT_THIN, + 'COMPONENT_REGULAR': BlurStyle.COMPONENT_REGULAR, + 'COMPONENT_THICK': BlurStyle.COMPONENT_THICK +}; import { CalendarPage } from './CalendarPage'; import { NAV_BAR_BOTTOM, @@ -1591,7 +1612,8 @@ struct MainPage { */ const systemMode: number = ctx.resourceManager.getConfigurationSync().colorMode; const dark: boolean = isDarkMode(snap.theme, systemMode); - this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark); + // 模糊强度是**服务端给的 px 原值**:计划只搬运它,映射成系统材质档在画的那一层做 + this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark, snap.bgBlur); this.bgActive = this.bgPlan.kind !== 'none'; } @@ -1675,6 +1697,27 @@ struct MainPage { Image(this.wallpaperImage) .width('100%').height('100%') .objectFit(ImageFit.Cover) + /* + * ── 这里的模糊是「**图片内容模糊**」,与导航条的「面板材质模糊」是两件事 ── + * + * WebUI 侧核实过(`client/electron/src/index.css`): + * · 壁纸层 `.app-backdrop`(z-index:-1,背后什么都没有)吃 + * `filter: blur(var(--bg-blur))` —— **图片内容模糊**; + * · 面板另有 `backdrop-filter: blur(8px)`(`.app-backdrop` 之上的那层) + * —— **背后内容模糊**。 + * 两者是**两个不同的物理量**,所以"壁纸糊一次 + 导航条材质一次"**不是** + * "同一张底被模糊两遍"。原来那条判据把两者混为一谈(见 `harmony-appearance.test.mjs` + * 里已修正的那条),曾让我以为"壁纸层不许有任何模糊"。 + * + * ★ 用 `blur(radius)`(`CommonMethod` 的图片内容模糊,与 CSS `filter: blur()` 同一个量) + * 而**不是** `backgroundBlurStyle`:后者是**面板材质**,作用在组件的**背景**上 + * (作用对象是它背后的内容),语义对不上。导航条那处才该用材质。 + * ★ 半径直接用服务端给的那个 px 值:WebUI 就是 `blur(var(--bg-blur))`, + * 两边**同一个物理量、同一个数** ⇒ 这一处不需要映射表,也不该有。 + * (`blurStyleFor` 那张表服务的是导航条那种**没有 px 半径**的材质档, + * 它在本次改动前"有映射表、无调用点";这次给它接了调用点,见 NavBar。) + */ + .blur(this.bgPlan.blurPx) // 压暗用**系统遮罩色** + 服务端给的浓度:换向(浅色洗白/深色压黑)由系统负责 Column() .width('100%').height('100%') @@ -1750,10 +1793,17 @@ struct MainPage { * * 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)—— * 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突, - * 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial`), + * 而且还要我们自己维护两套。现在只声明**档次**(由 `blurStyleFor` 把用户的模糊档 + * 映射成系统档位;档位名 → `BlurStyle` 的四行表是 `BLUR_STYLE_OF`), * 深浅两套颜色与模糊半径都由系统按主题给。 */ - .backgroundBlurStyle(Theme.navMaterial) + /* + * 导航条的**面板材质**:档位由用户的 `bg_blur` 映射而来 + * (`blurStyleFor`,`model/Appearance.ts`;分档边界 0/8/20 有行为判据)。 + * 这正是计划文档 §7.12 要求的那件事 —— 开始消费 `bg_blur` 时补上映射的**调用点**。 + * ★ 与壁纸层的 `blur(px)` 不是同一件事:这里是"背后内容糊",那里是"图片本身糊"。 + */ + .backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(this.bgPlan.blurPx)] ?? BlurStyle.NONE) } build() { diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 6ecb05e..69f1fc8 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -10,6 +10,10 @@ import { SseService } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; import { AppearanceApi } from '../api/AppearanceApi'; import { AppearanceSnapshot, statusLabel } from '../model/Appearance'; +import { MeApi } from '../api/AdminApi'; +import { AdminUser } from '../model/Models'; +import { isAdminRole } from '../model/AdminUsers'; +import { BackgroundPicker } from '../common/BackgroundPicker'; @Entry @Component @@ -28,6 +32,23 @@ struct SettingsPage { */ @State appearanceTheme: string = 'system'; @State appearanceStatus: string = 'local-only'; + /** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */ + @State isAdmin: boolean = false; + /* + * 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改, + * 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。 + * + * ★ 为什么**不**用「页面 @Watch 到 bgKind/bgDim… 变化就推服务端」: + * `adoptSnapshot` 把**服务端**的值复制进来时也会触发那个 @Watch ⇒ + * 推一次服务端 ⇒ 再同步 ⇒ 再触发…… 要么死循环,要么得再加一个抑制标志, + * 而那个标志本身又是一处能忘掉的状态。 + * 改成"选择器在**用户改值**时显式回调"之后,方向是单向的: + * 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。** + */ + @State bgKind: string = 'none'; + @State bgPresetId: string = 'aurora'; + @State bgDim: number = 12; + @State bgBlur: number = 4; private client: ApiClient | null = null; private acctMgr: AccountManager | null = null; @@ -40,10 +61,96 @@ struct SettingsPage { this.acctMgr.load().then(() => { this.refreshList(); this.loadAppearance(); + this.loadRole(); }); } } + /** + * 用户改了背景 ⇒ 推一次服务端(与 `setTheme` 同一形状)。 + * + * ★ 只有**用户动作**会走到这里(选择器改值后回调 `onUserChanged`); + * 服务端同步进来的值不会(理由见 `bgKind` 那段的注释)。 + * ⇒ "值变了就推一次"只有**这一处**实现,而不是每个 onClick 各推一次。 + * + * ★ 与 `setTheme` 一样:本机先生效,但**推不上去就要说"仅本机"** + * (`appearanceStatus`),不能让用户以为换设备也带着走。 + */ + async setBackground(kind: string, presetId: string, dim: number, blur: number): Promise { + const ctx = this.getUIContext().getHostContext(); + const client: ApiClient | null = this.client; + if (ctx === undefined) { + return; + } + const store: AppearanceStore = AppearanceStore.getInstance(); + const snap: AppearanceSnapshot = store.current(); + snap.bgKind = kind; + snap.bgPresetId = presetId; + snap.bgDim = dim; + snap.bgBlur = blur; + if (client === null) { + this.appearanceStatus = 'local-only'; + return; + } + try { + await new AppearanceApi(client).put(snap, store.wallpaper !== null); + store.saveLocal(ctx, snap); + this.appearanceStatus = 'synced'; + } catch (e) { + this.appearanceStatus = 'local-only'; + } + } + + /** + * 上传成功后**重新以服务端为准同步**(P4c 第③条)。 + * + * ★ 刻意**不**在这里把 `bgKind` 设成 'image':服务端才是权威, + * 如果服务端因为任何原因没把档位记成 image,本地先改成 image 就会 + * 显示一块取不回来的空白(`resolveBackground` 对"image 档但没图"给 none)。 + * 同步回来是什么就是什么。 + */ + async onWallpaperUploaded(message: string): Promise { + const ctx = this.getUIContext().getHostContext(); + const client: ApiClient | null = this.client; + this.getUIContext().getPromptAction().showToast({ message: message }); + if (ctx === undefined || client === null) { + return; + } + const store: AppearanceStore = AppearanceStore.getInstance(); + await store.syncFromServer(ctx, client); + this.adoptSnapshot(store.current(), store.statusText()); + } + + /** 把 store 里的快照复制进 @State(@State 观察不到类内部字段变化) */ + adoptSnapshot(snap: AppearanceSnapshot, status: string): void { + this.appearanceTheme = snap.theme; + this.bgKind = snap.bgKind; + this.bgPresetId = snap.bgPresetId; + this.bgDim = snap.bgDim; + this.bgBlur = snap.bgBlur; + this.appearanceStatus = status; + } + + /** + * 读一次当前身份,决定「管理」入口显不显示。 + * + * ★ 失败**保持 false**:入口不显示,而不是「显示一个点进去 403 的东西」。 + * 这里刻意不弹错误 —— 身份读不到不影响本页任何其它功能, + * 为它弹一个 toast 只会让人以为设置页坏了。 + */ + async loadRole(): Promise { + const client: ApiClient | null = this.client; + if (client === null) { + return; + } + try { + const me: AdminUser = await new MeApi(client).get(); + this.isAdmin = isAdminRole(me.role); + } catch (e) { + this.isAdmin = false; + } + } + /** * 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。 * @@ -60,6 +167,8 @@ struct SettingsPage { // 缓存按账号分:换账号时读的是那一个账号的那一份 store.loadLocal(ctx, this.activeId); await store.syncFromServer(ctx, client); + // 背景四值一并从 store 复制进来(否则选择器显示的还是默认值,与服务端不一致) + this.adoptSnapshot(store.current(), store.statusText()); const snap: AppearanceSnapshot = store.current(); this.appearanceTheme = snap.theme; this.appearanceStatus = store.status; @@ -263,12 +372,68 @@ struct SettingsPage { Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。') .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 }) + + Divider().color(Theme.border).margin({ top: 12, bottom: 12 }) + + /* + * 背景(P4c:不设 / 预设 / 自定义图片)。 + * + * 四值用 `@Link` 双向绑(`$` 前缀是 ArkTS 的 @Link 传参语法); + * `onUserChanged` 只在**用户动作**里被调 ⇒ 这里推服务端。 + */ + BackgroundPicker({ + bgKind: $bgKind, + bgPresetId: $bgPresetId, + bgDim: $bgDim, + bgBlur: $bgBlur, + statusText: statusLabel(this.appearanceStatus), + onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => { + this.setBackground(kind, presetId, dim, blur); + }, + onUploaded: (message: string) => { this.onWallpaperUploaded(message); }, + onUploadFailed: (reason: string) => { + this.getUIContext().getPromptAction().showToast({ message: reason }); + } + }) } .width('100%').alignItems(HorizontalAlign.Start) .padding(16).margin({ top: 8 }) .backgroundColor(Theme.surface) .borderRadius(Theme.radiusCard) + /* + * ── 管理入口(仅管理员可见)── + * + * WebUI 侧的位置是「"我的"页底部、仅管理员可见」(`App.tsx`: + * `viewMode === 'admin' && user?.role === 'admin'`)。鸿蒙侧对应的位置就是这里。 + * + * ★ 可见性来自 `/me` 的 `role`,口径与 WebUI 严格一致(`isAdminRole`)。 + * ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示: + * 乐观显示会让每个普通用户都看到一个点进去 403 的入口。 + * 代价是"管理员在网络抖动时看不到入口",那是可接受的(刷新即恢复)—— + * 反过来则是每个人都被误导。 + */ + if (this.isAdmin) { + Column() { + Row() { + Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary) + .layoutWeight(1) + Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtle) + } + .width('100%') + Text('用户管理(新建 / 编辑 / 启停 / 重置密码)') + .fontSize(Theme.fontTiny).fontColor(Theme.textSubtle) + .width('100%').margin({ top: 4 }) + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(16).margin({ top: 8 }) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .onClick(() => { + this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' }); + }) + } + if (this.showAddDialog) { Column() { Column() diff --git a/client/harmony/entry/src/main/resources/base/profile/main_pages.json b/client/harmony/entry/src/main/resources/base/profile/main_pages.json index dd9d3ff..9f78adb 100644 --- a/client/harmony/entry/src/main/resources/base/profile/main_pages.json +++ b/client/harmony/entry/src/main/resources/base/profile/main_pages.json @@ -6,6 +6,7 @@ "pages/ComposePage", "pages/SettingsPage", "pages/SessionsPage", + "pages/AdminUsersPage", "pages/Index" ] -} \ No newline at end of file +} diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index 9e18fc9..e25a35c 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -588,7 +588,7 @@ deb 也不必从 targets 里摘。已写进 `client/electron/BUILD.md`(含排 | 圆角 | 自声明 `--radius-card: 0.875rem` | 系统 `sys.float.ohos_id_corner_radius_card/button` | 系统圆角会随设备/主题/无障碍设置变;跟着系统才是"系统方案" | | 材质(玻璃) | 自声明 `--nav-bg: 255 255 255 / 0.72` + `backdrop-filter` | 系统 `backgroundBlurStyle(BlurStyle.COMPONENT_THICK)` | 系统材质自带深浅两套颜色与模糊半径,手写 alpha 跟不了深色 | | **未知预设 id(版本偏移)** | WebUI/服务端先加第 7 档、鸿蒙还是旧构建 ⇒ 旧端认不出新 id | 按缺省语义**静默替换为 aurora**(`normalizePreset`) | **这不是 bug,是批准的缺省语义的可见后果**:用户以为选的是新档,实际看到的是 aurora。方向是"宽(静默)"——不会看到错误,但会看到**别人的档**。行为判据用"不存在的 id 当哨兵"正好钉住这条(`harmony-presets.test.mjs`) | -| **壁纸模糊度** | 用户的 `bg_blur`(**像素半径**,初值 4px)作用在壁纸图层上 | **没有被消费**:栏上的系统**材质档位**(`BlurStyle.COMPONENT_THICK`)是唯一一次模糊,壁纸层不糊 | **不是同一个物理量;映射表存在但无调用点**。2026-09-14 核实:`bg_blur` 在鸿蒙侧只有"搬运"没有"消费" —— `Appearance.ts` 把它 clamp 到 0~40 存进 `bgBlur`、`AppearanceStore` 同步它,但**没有任何页面/组件读它**(材质档位是固定枚举)。(**此处结论已更正**:`blurStyleFor(bgBlur)` 这张映射表**是存在的**,且**已有行为判据**(分档边界 0/8/20、单调性、NaN 不许落到最厚档);缺的是**调用点**,由消费侧计数判据管。我先前的"不存在映射表 ⇒ 钉它是假判据"是错的,纠正过程见 CRITERIA.md §10。)**改一端的含义**:改 WebUI 的 `bg_blur` 语义**不影响鸿蒙**;若将来鸿蒙开始消费它(例如按 px 选不同 `BlurStyle`),**那时**必须补一条映射判据,并更新本行 —— 这是"人工约定、当前无判据"的登记处(CRITERIA.md §12 的同一族)。 | +| **壁纸模糊度** | 用户的 `bg_blur`(**像素半径**,初值 4px)作用在壁纸图层上(`filter: blur(var(--bg-blur))`) | **已消费**(P4c,2026-09-15):壁纸层 `.blur(bgPlan.blurPx)` = **图片内容模糊**(与 WebUI 同一个量与同一个数);导航条 `backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(blurPx)])` = **面板材质**(映射表 `model/Appearance.ts` 的 `blurStyleFor`,分档 0/8/20 有行为判据;档位名 → `BlurStyle` 的四行表在 `MainPage.ets`) | **两种模糊不是同一件事**(核实自 `client/electron/src/index.css`:`.app-backdrop` 的 `filter` 是图片本身糊,它**之上**的面的 `backdrop-filter` 才是背后糊)—— 所以「壁纸糊一次 + 导航条材质一次」**不是**「同一张底糊两遍」。原来那条判据把两者混为一谈,已于同一天修正为「壁纸层只许图片内容模糊、面板材质只许出现在背后是可变内容的层」。**本条已按原承诺更新**(原文写着:若将来鸿蒙开始消费它,那时必须补一条映射判据并更新本行)。消费侧登记表已从 0 改准(`harmony-appearance.test.mjs`,逐文件 + 计数 + 理由)。 | | 动效 | 自定义 transition/时长 | `animateTo` + 系统 `curves` | 动效曲线应跟随系统设置(含"减弱动效") | | 遮罩 | 自声明 `--bg-scrim` + `--bg-dim` 两段式 | 系统 `sys.color.ohos_id_color_mask_regular` | 遮罩要随主题换向(浅色洗白/深色压黑),这件事系统已经做了 | | **品牌色** | `--c-blue-600: 37 99 235` | `Theme.accent = '#2563EB'` | **不允许差异** —— 两个客户端是同一个产品 |