/* * 外观(主题 + 壁纸)在**服务端**与本地之间的搬运 —— 纯逻辑,无 UI 依赖。 * * 参考实现:WebUI 的 `src/lib/appearance.ts` + `src/stores/appearanceSync.ts`。 * 那边的由来值得记一句(用户 2026-09-13 的质问):「为什么背景是保存在本地而不是服务器!」 * —— 主题与壁纸原先只写客户端存储:换设备就没了,而且**多账号共用一份**。 * 现在服务端是权威(账号级 `/me/appearance`),本地只是缓存(秒开、离线降级)。 * * 两条最容易写错的规则(WebUI 侧都踩过,判据盯着它们): * * ① **服务端"没有记录"时必须以本地为准**(`saved === false`)。服务端在没有记录时 * 回的是一份**默认值**,拿它覆盖本地等于把用户已有的外观抹掉 —— * 首次启用这套同步时每个老用户都会中招。正确动作是把本地那份**推上去**。 * ② 降级**必须可见**(`local-only`):服务端不可达时界面照样能用, * 但得能说出"现在这份只在本地",否则用户以为换设备也能带走。 * * ⚠️ 本文件必须保持**类型可擦除**(无 enum / namespace / 构造器参数属性), * 否则 node 的 strip-types 跑不起来,判据就断了。 */ /** * 服务端回包(字段名与 `server/internal/handler/appearance.go` 的 JSON 一致)。 * * ⚠️ **字段默认值不是 0/空串,而是"缺字段时的合理默认"**:ArkTS 的反序列化会把 * 缺失字段留成类里写的默认值,于是"字段不在"与"字段是 0"分不开。 * 若这里写 `bg_dim: number = 0`,一次没带 `bg_dim` 的响应就会把压暗设成 0(无压暗), * 而 WebUI 那边(可选字段 = `undefined`)会退回 12。两边行为必须一致, * 所以默认值在这里对齐 WebUI 的 `clamp(..., dflt)` 语义。 * 这条是**判据逼出来的**:`snapshotFromResponse(resp())` 原本返回 bgDim 0。 * * `saved` 默认 `false` 也是有意为之:缺字段时按"服务端没有记录"处理, * 即**以本地为准**(见文件头 ①)—— 这是安全的那一侧。 */ export class AppearanceResponse { theme: string = 'system'; bg_kind: string = 'none'; bg_preset_id: string = 'aurora'; bg_dim: number = 12; bg_blur: number = 4; has_image: boolean = false; image_bytes: number = 0; /** 服务端**有没有这份记录** —— 与"值是什么"是两件事,别混(见文件头 ①) */ saved: boolean = false; } /** 可直接用来渲染的快照(认不出的值已退回默认) */ export class AppearanceSnapshot { theme: string = 'system'; // light | dark | system bgKind: string = 'none'; // none | preset | image bgPresetId: string = 'aurora'; bgDim: number = 12; bgBlur: number = 4; } /** 同步结果:动作 + 状态(状态是要**显示给人看**的,不是内部细节) */ export class AppearanceSync { snapshot: AppearanceSnapshot = new AppearanceSnapshot(); /** 'apply-remote' | 'push-local' —— 谁覆盖谁 */ action: string = 'apply-remote'; /** 'synced' | 'pending' | 'local-only' */ status: string = 'synced'; /** 本地那份要不要上传(push-local 时为 true) */ shouldPush: boolean = false; } const THEMES: string[] = ['light', 'dark', 'system']; const KINDS: string[] = ['none', 'preset', 'image']; function clampNumber(v: number, lo: number, hi: number, dflt: number): number { const n: number = Number.isFinite(v) ? v : dflt; const r: number = Math.round(n); if (r < lo) { return lo; } if (r > hi) { return hi; } return r; } /** 服务端回包 → 快照。认不出的值退回默认,不抛错(老数据/新字段/脏值都会走到这里)。 */ export function snapshotFromResponse(resp: AppearanceResponse): AppearanceSnapshot { const out: AppearanceSnapshot = new AppearanceSnapshot(); out.theme = THEMES.indexOf(resp.theme) >= 0 ? resp.theme : 'system'; out.bgKind = KINDS.indexOf(resp.bg_kind) >= 0 ? resp.bg_kind : 'none'; out.bgPresetId = resp.bg_preset_id.length > 0 ? resp.bg_preset_id : 'aurora'; out.bgDim = clampNumber(resp.bg_dim, 0, 90, 12); out.bgBlur = clampNumber(resp.bg_blur, 0, 40, 4); return out; } /** * **PUT 上去**的 JSON 形状 —— 注意它与 `AppearanceResponse`(GET 回来的)**不是**同一个类型。 * * ★★ 2026-09-19 修(真 bug,设备实测撞出来的):原先这里复用 `AppearanceResponse`, * 而那个类型带 `has_image` / `image_bytes` / `saved` —— **服务端不认识这三个字段**, * 且服务端的 `Decode()` 是 `DisallowUnknownFields()`(严格,有意为之)。 * 于是每一次外观保存都 400,症状是"点了预设档、色板出来了、但壁纸没画上去": * 界面看着切了(本地 @State 变了),服务端其实拒收(`PUT /me/appearance` → 400)。 * * 为什么能潜伏这么久:**本地状态立刻变**,所以肉眼看着像成功了; * 只有看 hilog 的 HTTP 状态码才发现 400。而所有静态判据判的都是 * "payload 的字段对不对" —— 它们读的是同一份类型定义,**看不见'多带了字段'**。 * 这正是"两端共用同一个类型"的代价:请求与响应本来就不该同形。 * * 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。 */ export class AppearancePayload { theme: string = 'system'; bg_kind: string = 'none'; bg_preset_id: string = 'aurora'; bg_dim: number = 12; bg_blur: number = 4; } /** 本地快照 → 要 PUT 上去的 JSON 形状(服务端也做同样的归一,两边都要做) */ export function payloadFromLocal(local: AppearanceSnapshot, hasLocalImage: boolean): AppearancePayload { const out: AppearancePayload = new AppearancePayload(); out.theme = THEMES.indexOf(local.theme) >= 0 ? local.theme : 'system'; let kind: string = KINDS.indexOf(local.bgKind) >= 0 ? local.bgKind : 'none'; // 选了 image 档却没有图 → 退回 none(否则服务端会存一个指向空图的记录) if (kind === 'image' && !hasLocalImage) { kind = 'none'; } out.bg_kind = kind; out.bg_preset_id = local.bgPresetId.length > 0 ? local.bgPresetId : 'aurora'; out.bg_dim = clampNumber(local.bgDim, 0, 90, 12); out.bg_blur = clampNumber(local.bgBlur, 0, 40, 4); return out; } /** * 合并决策:**这是这一期的验收核心**(换账号后外观跟随、服务端无记录时以本地为准)。 * * @param local 本地缓存的那份(可能来自上一个账号的缓存,也可能是默认值) * @param resp 服务端回包 * @param serverHasImage 服务端是否有壁纸本体(`has_image`) */ export function mergeAppearance(local: AppearanceSnapshot, resp: AppearanceResponse, serverHasImage: boolean): AppearanceSync { const out: AppearanceSync = new AppearanceSync(); // ① 服务端没有记录:**以本地为准**,并把本地推上去作为这个账号的初始外观 if (!resp.saved) { out.snapshot = local; out.action = 'push-local'; out.status = 'pending'; out.shouldPush = true; return out; } // ② 服务端有记录:以服务端为准,但**图片档要小心** const remote: AppearanceSnapshot = snapshotFromResponse(resp); const merged: AppearanceSnapshot = new AppearanceSnapshot(); merged.theme = remote.theme; merged.bgPresetId = remote.bgPresetId; merged.bgDim = remote.bgDim; merged.bgBlur = remote.bgBlur; if (remote.bgKind === 'image' && !serverHasImage) { // 服务端记着 image 档但**本体不在**(本地还没推上去,或图被清过): // 不能照着 image 档渲染一块空地,也不能把本地那张图擦掉 —— 先按本地算。 if (local.bgKind === 'image') { merged.bgKind = 'image'; } else { merged.bgKind = 'none'; } } else { merged.bgKind = remote.bgKind; } out.snapshot = merged; out.action = 'apply-remote'; out.status = 'synced'; out.shouldPush = false; return out; } /** 没登录 / 服务端不可达:本地就是全部,**而且要让用户知道**(状态可见,不是内部细节) */ export function localOnly(local: AppearanceSnapshot): AppearanceSync { const out: AppearanceSync = new AppearanceSync(); out.snapshot = local; out.action = 'apply-remote'; out.status = 'local-only'; out.shouldPush = false; return out; } /** * 壁纸模糊档 → **系统材质档次名**(不是像素半径)。 * * ── ★ 已删除(2026-09-15,pi 的裁定)── * * 它曾是"`bg_blur` → `BlurStyle` 档位"的唯一映射函数。删除的理由是**消费者不存在,而且不该存在**: * · 页面上**没有任何调用点** —— 它只被自己的判据调用; * · 唯一的计划消费者是"导航条档位跟随 `bg_blur`",而 pi 2026-09-15 **否决了那个方案** * (导航条是 chrome,材质固定;见 `MainPage.ets` 的 `NavBar` 注释)。 * 所以它不是"暂时还没有消费者",是**不该有**。 * · 这正是本仓刚立的规矩:**"有测试"不等于"有人用"**。一个只靠自己的判据活着的函数, * 与 `Theme.navMaterial` 那个"只被不可达 `??` 分支引用"的死令牌是**同一个形状**, * 只是换了个层级。当时我在 `harmony-appearance.test.mjs` 里诚实地标了它是"孤岛", * 但**"孤岛"写在注释里等于没有机制** —— 注释拦不住下一个读者。 * * **想法若真回来**:`git log --all -- <本文件>` 里有实现,而 (a) 那次提交(`426caf9`) * 本身就是"它为什么不该回来"的记录。**不要为了"万一有用"把它留在代码里。** * * 注意与它**不是**一回事的:壁纸层的 `.blur(px)` 用的是**像素半径** * (服务端 `bg_blur` 原值,0~40,与 WebUI 同一个数)—— 那个是**活的**,没有删。 * "px 半径"与"系统材质档"是两个量,别再合到一起。 */ /** * 主题偏好 → 系统色彩模式。 * * 用系统色彩模式而不是自己切一套深色色值:这正是"用系统方案"要的效果 —— * 深浅两套颜色由系统给,我们只表达"偏好哪一种"。 * 返回值与 `ConfigurationConstant.ColorMode` 的成员同名(页面那边照着映射)。 */ export function colorModeFor(theme: string): string { if (theme === 'light') { return 'COLOR_MODE_LIGHT'; } if (theme === 'dark') { return 'COLOR_MODE_DARK'; } return 'COLOR_MODE_NOT_SET'; } /** * 主题偏好 → `setColorMode` 要的**数字**。 * * ⚠️ 数值必须与 SDK 的 `ConfigurationConstant.ColorMode` 一致,而这里的顺序**容易记反**: * `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`、`COLOR_MODE_NOT_SET = -1` * (`@ohos.app.ability.ConfigurationConstant.d.ts`)。 * 我第一版就是按"0=浅色、1=深色"写的 —— 正好**反了**:选深色会切成浅色。 * 之所以能发现,是因为判据把这三个数字与 SDK 里的枚举逐一比对(不是凭印象写)。 * * 映射放在纯逻辑里而不是页面里:这样它是**可判据的行为**, * 而不是"某处有个 setColorMode 调用"。 */ export function colorModeValue(theme: string): number { if (theme === 'light') { return 1; // COLOR_MODE_LIGHT } if (theme === 'dark') { return 0; // COLOR_MODE_DARK } return -1; // COLOR_MODE_NOT_SET(跟随系统) } /** * 当前主题**实际**算不算深色 —— 预设色板要不要用深色那套由它决定。 * * 为什么不直接看 `theme` 就完事:`theme === 'system'` 时我们并不知道系统当时是深是浅, * 要把系统反馈进来的 `colorMode` 一起看。数值同样与 SDK 的 * `ConfigurationConstant.ColorMode` 对齐(0=深、1=浅、-1=未设置)。 * * **未设置/读不到时按浅色算**:与 WebUI 的默认(`:root` 那套)一致 —— * 宁可"系统深浅还没定下来时先用浅色那套",也不要拿深色色板去配浅色系统表面。 * * ⚠️ 已知边界:用户在**应用运行期间**改系统深浅色,这里不会自动重算 * (要重新进一次页面/重进应用)。系统侧的正确做法是订阅 * `applicationContext.on('environment', ...)` 的配置变化回调 —— 记在文档里,没做。 */ export function isDarkMode(theme: string, systemColorMode: number): boolean { if (theme === 'dark') { return true; } if (theme === 'light') { return false; } return systemColorMode === 0; // COLOR_MODE_DARK } /** 遮罩浓度:0~90 的"压暗"值 → 0~1(系统遮罩色 + 这个不透明度) */ export function scrimOpacity(bgDim: number): number { const d: number = clampNumber(bgDim, 0, 90, 12); return d / 100; } /** 状态文案:降级必须看得见(WebUI 侧的原话:「否则用户以为换设备也能带走」) */ export function statusLabel(status: string): string { if (status === 'local-only') { return '仅本机'; } if (status === 'pending') { return '正在同步到账号'; } return '已同步'; }