From b041ea51e47e5d0051e80608eccd7d36205462c3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 13:29:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(harmony):=20=E4=BB=A4=E7=89=8C=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=20=E2=80=94=E2=80=94=20=E8=A3=B8=E8=89=B2=E5=80=BC?= =?UTF-8?q?=E6=B8=85=E9=9B=B6=E3=80=81=E9=81=AE=E7=BD=A9=E6=8B=86=E4=B8=A4?= =?UTF-8?q?=E6=AE=B5=E5=BC=8F=E3=80=81=E6=9D=83=E9=99=90=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E9=85=8D=E8=89=B2=E5=AF=B9=E9=BD=90=20WebUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接手核对时发现:「不许写死颜色」那条判据**只挡得住枚举的 8 个旧值** —— 判据全绿的 同期,pages/ 里还留着 14 处另一套写死的色(Google/Material:#E8F0FE、#E8F5E9、 #FFF3E0、#D93025、#777777×2、#555555、#444444、#cccccc、#aaaaaa、 遮罩 #80000000×2、透明 #00000000×2)。枚举挡不住漂移,只有"类"能挡。 ## 改法 - 14 处全部换成令牌。新增 accentStrong / warnBg / warnFg(取值对齐 WebUI `:root` 的 blue-700 / amber-50 / amber-700)与 `Theme.permBg/permFg` —— 权限档位徽标与 WebUI 的 `PermissionChip.tsx` **同一映射**(plan=蓝 / workspace=绿 / full=琥珀); 原先写成 `full ? 绿 : 橙`(Material 色),与 WebUI **反着来**。 - 遮罩拆成 `overlayColor` + `overlayAlpha`(照 WebUI 的 `--bg-scrim` + `--bg-dim` 两段式):遮罩色要能随主题换向,色与透明度焊死成一个 `#AARRGGBB` 等于把枚举写回 代码。ArkUI 只认单值,故由 `Theme.overlay()` 组装。 - 判据从"枚举旧值"改成"按类挡":pages/ 下**一个裸色值都不许有**(含 8 位 `#AARRGGBB`),页面清单从硬编码 7 个文件名改成**扫目录** —— 旧写法下, 接下来要加的发件箱/授权/日历会自动逃出判据。另加一条判据:权限档位配色与 WebUI `:root` 变量逐一比对,防"看起来差不多"。 ## 验证 - 变异测试:往 `InboxPage.ets` 塞一个 `#E8F0FE` → 判据红;撤回 → 绿。 - cross-client 判据 6 → 8 条全绿;`hvigorw assembleHap` BUILD SUCCESSFUL。 - **视觉未验**(模拟器在本机文件沙箱下起不来,见 `docs/HARMONY-ALIGN-PLAN.md` 5.4), 未写成"已完成"。 --- .../electron/test/cross-client-theme.test.mjs | 87 ++++++++++++++++--- .../entry/src/main/ets/common/Theme.ets | 55 ++++++++++++ .../entry/src/main/ets/pages/ComposePage.ets | 4 +- .../entry/src/main/ets/pages/LoginPage.ets | 4 +- .../src/main/ets/pages/MailDetailPage.ets | 8 +- .../entry/src/main/ets/pages/MainPage.ets | 14 +-- .../entry/src/main/ets/pages/SettingsPage.ets | 10 +-- 7 files changed, 148 insertions(+), 34 deletions(-) diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 8753fcd..2b76ea9 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -10,7 +10,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -60,20 +60,79 @@ test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一 assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/); }); -test('★ 鸿蒙页面里不得再出现与 WebUI 不同的旧调色板', () => { +test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => { /* - * 对齐之前的鸿蒙调色板是另一套(#1A73E8 Google 蓝、#333333、#F5F7FA…), - * 与 WebUI 的品牌色/灰阶并不同。203 处已换成 Theme 令牌;这条判据防止 - * 以后新写的页面又随手写死一个"差不多"的颜色 —— 那正是漂移的开始。 + * 上一版这条判据只列了 8 个旧色值(#1A73E8 / #333333…),于是判据全绿的 + * 同时,pages/ 里还留着 14 处**另一套**写死的色:Google/Material 的 + * #E8F0FE、#E8F5E9、#FFF3E0、#D93025 与 #777777/#555555/#444444/ + * #cccccc/#aaaaaa、遮罩 #80000000、透明 #00000000。 + * 枚举只能挡住"列出来的实例",挡不住漂移本身 —— 改成按类挡: + * pages/ 下一个裸色值都不许有(含 8 位 #AARRGGBB),颜色只能来自 Theme.ets。 + * + * 页面清单也一并改成扫目录:旧版硬编码 7 个文件名,**新加的页面会自动 + * 逃出判据** —— 而发件箱/授权/日历正是接下来要加的页面。 */ - const pages = ['MainPage', 'InboxPage', 'SessionsPage', 'MailDetailPage', 'ComposePage', 'SettingsPage', 'LoginPage']; - const old = ['#1A73E8', '#333333', '#666666', '#999999', '#F5F7FA', '#F5F5F5', '#FF4444', '#F0F7FF']; - for (const name of pages) { - const src = readFileSync(join(ROOT, `client/harmony/entry/src/main/ets/pages/${name}.ets`), 'utf8'); - for (const c of old) { - assert.ok(!src.includes(`'${c}'`), `${name}.ets 里还有旧调色板色值 ${c}(应改用 Theme 令牌)`); - } + const dir = join(ROOT, 'client/harmony/entry/src/main/ets/pages'); + const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort(); + assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets,判据大概扫错了目录`); + const literal = /#[0-9A-Fa-f]{6,8}\b/g; + for (const f of files) { + const hits = readFileSync(join(dir, f), 'utf8').match(literal); + assert.equal(hits, null, `${f} 里有裸色值 ${hits ? hits.join('、') : ''}(应改用 Theme 令牌)`); } - // 反向对照:判据本身要能抓到 - assert.ok("#1A73E8".length > 0 && old.includes('#1A73E8')); + // 反向对照:判据要真能抓到裸色值(否则写错了正则也是一片绿) + assert.deepEqual("fontColor('#E8F0FE')".match(literal), ['#E8F0FE']); + assert.deepEqual("color('#80000000')".match(literal), ['#80000000']); + // 令牌文件本身必须是**唯一**的颜色来源,否则上面那条会因为"哪儿都没有色值"而空转 + assert.ok(/#[0-9A-Fa-f]{6,8}/.test(harmony), 'Theme.ets 里应该有真正的色值'); +}); + +test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥珀)', () => { + // WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。 + const chip = readFileSync( + join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), + 'utf8' + ); + assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝'); + assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀'); + assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿'); + + // 鸿蒙侧取的是同一批值 —— 直接和 WebUI 的 :root 变量比,防的是"看起来差不多" + const cssVar = (name, src) => { + const m = src.match(new RegExp(`--${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`)); + assert.ok(m, `WebUI 要有 --${name}`); + return hex('#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join('')); + }; + const token = name => { + const m = harmony.match(new RegExp(`${name}: string = '(#[0-9A-Fa-f]{6})'`)); + assert.ok(m, `鸿蒙要有令牌 ${name}`); + return hex(m[1]); + }; + assert.equal(token('accentSoft'), cssVar('c-blue-50', web), 'plan 底色应与 blue-50 一致'); + assert.equal(token('accentStrong'), cssVar('c-blue-700', web), 'plan 字色应与 blue-700 一致'); + assert.equal(token('approveBg'), cssVar('c-green-50', web), 'workspace 底色应与 green-50 一致'); + assert.equal(token('approveFg'), cssVar('c-green-700', web), 'workspace 字色应与 green-700 一致'); + assert.equal(token('warnBg'), cssVar('c-amber-50', web), 'full 底色应与 amber-50 一致'); + assert.equal(token('warnFg'), cssVar('c-amber-700', web), 'full 字色应与 amber-700 一致'); +}); + +test('鸿蒙的遮罩是「色 + 透明度」两段式(照 WebUI 的 --bg-scrim + --bg-dim)', () => { + /* + * WebUI 的遮罩是**两段**:`--bg-scrim`(颜色:浅色=白、深色=黑)+ + * `--bg-dim`(透明度),见 `index.css` 的 `.app-backdrop::after`。 + * 理由:遮罩色要能**随主题换向** —— 浅色主题用白把图案洗淡,深色主题必须换黑, + * 否则浅色照片在深色界面里糊成一块亮斑、正文读不动(WebUI 侧实测踩过)。 + * 鸿蒙侧原先写成一个焊死的 `#80000000`,换向时只能再写一个常量 —— 又变成枚举。 + */ + assert.match(harmony, /overlayColor: string = '#000000'/, '鸿蒙的遮罩**色**要单独成令牌'); + assert.match(harmony, /overlayAlpha: number = 0\.5/, '鸿蒙的遮罩**透明度**要单独成令牌'); + // 只查**代码**,不查注释:Theme.ets 的注释里正当地引用了旧值来解释为什么拆开 + const harmonyCode = harmony.replace(/\/\*[\s\S]*?\*\//g, ''); + assert.ok(!/#80000000/.test(harmonyCode), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值'); + // WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立 + assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色'); + assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色'); + assert.match(web, /--bg-dim:/, 'WebUI 的透明度是独立变量'); + // 反向对照:判据要真能抓到"焊死单值" + assert.ok(/#80000000/.test("backgroundColor('#80000000')"), '自检:正则抓不到焊死的单值'); }); diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index a6df747..49fd13b 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -49,6 +49,61 @@ export class Theme { static readonly approveBg: string = '#F0FDF4'; static readonly approveFg: string = '#15803D'; + /** 品牌蓝的**深**前景(对应 WebUI 的 --c-blue-700: 29 78 216)—— 与 accentSoft 成对用于 chip */ + static readonly accentStrong: string = '#1D4ED8'; + /** 警示面/前景(对应 WebUI 的 --c-amber-50 / --c-amber-700)—— 权限 full 档、待决策徽标 */ + static readonly warnBg: string = '#FFFBEB'; + static readonly warnFg: string = '#B45309'; + /** + * 遮罩(模态/淡出层):**颜色与透明度分开**,照 WebUI 的 `--bg-scrim` + `--bg-dim` + * 两段式(`index.css` 的 `.app-backdrop::after`)。 + * + * 为什么不写成一个 `#80000000`:遮罩色要能**随主题换向** —— 浅色主题用白把图案洗淡, + * 深色主题必须换黑,否则浅色照片在深色界面里糊成一块亮斑、正文读不动(WebUI 侧实测踩过)。 + * 色与透明度焊死成一个 AARRGGBB,换向时只能再写一个常量,于是又变成枚举 —— + * 那正是这条判据要防的东西。 + * + * 注意:这是**唯一没有 WebUI 同名令牌**的一组(WebUI 的弹层不压遮罩, + * 它那份 scrim 是给壁纸调暗用的),所以不往 WebUI 立同名令牌 —— 立了没人用、 + * 判据只能验"它存在",是自证。 + */ + static readonly overlayColor: string = '#000000'; + static readonly overlayAlpha: number = 0.5; + + /** 遮罩色 → ArkUI 只认的 `#AARRGGBB` 单值(鸿蒙没有"色 + 透明度"两参的重载) */ + static overlay(): string { + const a: number = Math.round(Theme.overlayAlpha * 255); + const hex: string = a.toString(16).toUpperCase(); + return '#' + (hex.length < 2 ? '0' + hex : hex) + Theme.overlayColor.substring(1); + } + + /** + * 权限档位 → 徽标底色 / 文字色。 + * + * 与 WebUI 的 `PermissionChip.tsx` **同一映射**(plan=蓝 / workspace=绿 / full=琥珀), + * 认不出的档位与空档位按 workspace 处理 —— 映射只有这一处实现, + * 页面不再各自 if-else 挑颜色。 + */ + static permBg(mode: string): string { + if (mode === 'plan') { + return Theme.accentSoft; + } + if (mode === 'full') { + return Theme.warnBg; + } + return Theme.approveBg; + } + + static permFg(mode: string): string { + if (mode === 'plan') { + return Theme.accentStrong; + } + if (mode === 'full') { + return Theme.warnFg; + } + return Theme.approveFg; + } + /** 圆角:卡片 14、控件 8(与 WebUI 的 --radius-card / --radius-control 一致) */ static readonly radiusCard: number = 14; static readonly radiusControl: number = 8; diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index 0723742..2d2e57d 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -248,7 +248,7 @@ struct ComposePage { Row() { Text('收件人').fontSize(14).fontColor(Theme.textSubtle).width(60) TextInput({ placeholder: 'name@path.session', text: this.to }) - .layoutWeight(1).fontSize(14).backgroundColor('#00000000') + .layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent) .onChange((v: string) => { this.to = v; }) } .width('100%').height(48).padding({ left: 12, right: 12 }) @@ -260,7 +260,7 @@ struct ComposePage { Row() { Text('主题').fontSize(14).fontColor(Theme.textSubtle).width(60) TextInput({ placeholder: '邮件主题', text: this.subject }) - .layoutWeight(1).fontSize(14).backgroundColor('#00000000') + .layoutWeight(1).fontSize(14).backgroundColor(Color.Transparent) .onChange((v: string) => { this.subject = v; }) } .width('100%').height(48).padding({ left: 12, right: 12 }) diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index fd5b07b..022e800 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -165,7 +165,7 @@ struct LoginPage { Row() { Text('账号密码').fontSize(14).fontColor(this.mode === 0 ? Theme.accent : Theme.textSubtle) .onClick(() => { this.mode = 0; }) - Text(' | ').fontSize(14).fontColor('#cccccc') + Text(' | ').fontSize(14).fontColor(Theme.border) Text('用户密钥').fontSize(14).fontColor(this.mode === 1 ? Theme.accent : Theme.textSubtle) .onClick(() => { this.mode = 1; }) } @@ -197,7 +197,7 @@ struct LoginPage { // 模拟器备选地址提示 Text('模拟器 NAT 不通时用 ' + EMULATOR_HOST_BASE) - .fontSize(11).fontColor('#aaaaaa').margin({ top: 20 }) + .fontSize(11).fontColor(Theme.textSubtle).margin({ top: 20 }) if (this.loggedIn) { // 登录成功 → 进入主界面(占位,后续 M2 替换) diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index ec0c464..aa8d107 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -127,8 +127,8 @@ struct MailDetailPage { .fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .layoutWeight(1) Text(this.permissionMode) - .fontSize(11).fontColor(Theme.textMuted) - .backgroundColor(this.permissionMode === 'full' ? '#E8F5E9' : '#FFF3E0') + .fontSize(11).fontColor(Theme.permFg(this.permissionMode)) + .backgroundColor(Theme.permBg(this.permissionMode)) .borderRadius(4).padding({ left: 6, right: 6, top: 2, bottom: 2 }) } .width('100%').height(56) @@ -210,7 +210,7 @@ struct MailDetailPage { Text(this.body) .fontSize(15) - .fontColor('#444444') + .fontColor(Theme.textPrimary) .width('100%') .padding({ left: 16, right: 16, bottom: 16 }) .lineHeight(24) @@ -242,7 +242,7 @@ struct MailDetailPage { // 遮罩 Column() .width('100%').layoutWeight(1) - .backgroundColor('#80000000') + .backgroundColor(Theme.overlay()) .onClick(() => { this.showReplyBox = false; }) // 回复框 diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 935497b..5f1fe23 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -344,7 +344,7 @@ struct InboxTab { if (this.accountFilter === 'all' && mail.source_account_name.length > 0) { Text(mail.source_account_name) .fontSize(10).fontColor(Theme.accent) - .backgroundColor('#E8F0FE').borderRadius(4) + .backgroundColor(Theme.accentSoft).borderRadius(4) .padding({ left: 5, right: 5, top: 1, bottom: 1 }) .margin({ left: 6 }) } @@ -366,8 +366,8 @@ struct InboxTab { if (mail.permission_mode.length > 0) { Text(mail.permission_mode) - .fontSize(10).fontColor(Theme.textMuted) - .backgroundColor(Theme.border).borderRadius(4) + .fontSize(10).fontColor(Theme.permFg(mail.permission_mode)) + .backgroundColor(Theme.permBg(mail.permission_mode)).borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) } } @@ -471,8 +471,8 @@ struct SessionsTab { Row() { Text(s.permission_mode.length > 0 ? s.permission_mode : '—') - .fontSize(11).fontColor(Theme.textMuted) - .backgroundColor(Theme.border).borderRadius(4) + .fontSize(11).fontColor(Theme.permFg(s.permission_mode)) + .backgroundColor(Theme.permBg(s.permission_mode)).borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) Blank() Text(s.mail_count + ' 封').fontSize(11).fontColor(Theme.textSubtle) @@ -587,8 +587,8 @@ struct ContactsTab { Row() { Text(c.permission_mode.length > 0 ? c.permission_mode : '—') - .fontSize(10).fontColor(Theme.textMuted) - .backgroundColor(Theme.border).borderRadius(4) + .fontSize(10).fontColor(Theme.permFg(c.permission_mode)) + .backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) Blank() Text(c.last_preview.length > 0 ? c.last_preview : '—') diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index e45c737..ea7cb1c 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -153,7 +153,7 @@ struct SettingsPage { if (this.accounts.length === 0) { Column() { - Text('📭 暂无账号').fontSize(16).fontColor('#777777') + Text('📭 暂无账号').fontSize(16).fontColor(Theme.textMuted) Button('添加第一个账号') .margin({ top: 16 }).backgroundColor(Theme.accent) .onClick(() => { this.showAddDialog = true; }) @@ -176,7 +176,7 @@ struct SettingsPage { Column() { Column() .width('100%').layoutWeight(1) - .backgroundColor('#80000000') + .backgroundColor(Theme.overlay()) .onClick(() => { this.showAddDialog = false; }) Column() { @@ -202,7 +202,7 @@ struct SettingsPage { Row() { Button('取消') - .width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor('#555555') + .width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted) .onClick(() => { this.showAddDialog = false; }) Blank() Button(this.adding ? '验证中…' : '添加') @@ -243,7 +243,7 @@ struct SettingsPage { .width('100%') Text((account.username.length > 0 ? account.username + ' · ' : '') + account.server) - .fontSize(12).fontColor('#777777') + .fontSize(12).fontColor(Theme.textMuted) .margin({ top: 3 }) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) } @@ -259,7 +259,7 @@ struct SettingsPage { } Text('删除') - .fontSize(12).fontColor('#D93025') + .fontSize(12).fontColor(Theme.danger) .onClick(() => { this.removeAccount(account.id); }) } .width('100%').height('100%')