fix(harmony): 令牌收尾 —— 裸色值清零、遮罩拆两段式、权限档位配色对齐 WebUI

接手核对时发现:「不许写死颜色」那条判据**只挡得住枚举的 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),
  未写成"已完成"。
This commit is contained in:
2026-09-14 13:29:50 +08:00
parent fac30eb09c
commit b041ea51e4
7 changed files with 148 additions and 34 deletions

View File

@ -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')"), '自检:正则抓不到焊死的单值');
});

View File

@ -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;

View File

@ -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 })

View File

@ -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 替换)

View File

@ -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; })
// 回复框

View File

@ -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 : '—')

View File

@ -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%')