feat(harmony): 系统方案第一批 —— 表面/文字/圆角交给系统、删手写玻璃、每项一张卡;跨端判据改"意图相同"

jianf:「鸿蒙也同步,但是鸿蒙要求用系统方案」。按 pi 对齐的形状(A/B/C + 品牌色防线)落地。

## 鸿蒙侧改了什么

- `Theme.ets` 的表面/文字/分隔/遮罩/圆角**来源换成系统**:
  `pageBg→sys.color.ohos_id_color_background`、`surface→…_list_card_bg`、
  `surfaceMuted→…_sub_background`、`border→…_list_separator`、三级文字 `→…_text_primary/secondary/tertiary`、
  `overlay→…_mask_regular`、`radiusCard/Control→sys.float.ohos_id_corner_radius_card/button`。
  于是这些维度自动跟随深色模式与无障碍设置 —— 这正是"手抄 WebUI 色值"做不到的事。
- **删掉手写玻璃** `#B8FFFFFF`/`#B80F172A`:那两个值等于"我们替系统猜了深色该怎么做"。
  换成一个**档次**声明 `navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK` + 导航条上的
  `.backgroundBlurStyle(...)`;深浅两套颜色与模糊半径由系统按主题给。
  遮罩的两段式(色 + 透明度)同样删掉:拆两段本就是为了"随主题换向",而这件事系统已经做了。
- **每项一张卡/气泡**:收件箱行、会话组头、联系人列表行改成卡片(圆角 + 卡片底色 + 行间距),
  联系人列表那条贯通分隔线删除。
- 仍然自己写的只有两类:**品牌色**(`accent = #2563EB`,跨客户端身份)与**业务语义色**
  (权限三档、预算三档 —— 系统只有 warning/alert 两个情绪色,凑不出三档,硬套会丢语义)。

## 判据:从"取值相同"改"意图相同"(两侧一起改)

- 品牌蓝**唯一保留取值钉**,并新增防线:不得退化成 `$r('sys.color.*')`
  (系统强调色随主题/厂商皮肤变,"两个客户端是同一个产品"就靠不住了)。
- 圆角/材质/遮罩:改成"WebUI 自声明令牌 + 鸿蒙来自系统 + 差异被记录"(§7.12 有意差异表)。
- 新增 A(系统拥有的维度唯一来源是 `$r('sys.*')`,且不得退回 string/number)、
  B(旧机制不得回来:`navBg*`、8 位半透明色、`rgb(`/`rgba(`、写死的 14/8)、
  C(玻璃位置必须调 `backgroundBlurStyle` 且**只许一层**)。
- pi 指出的洞已补:裸色值判据原来只扫 `#RRGGBB(AA)`,抓不到 `rgba(`/`0xRRGGBBAA` ——
  而这几种恰是"改用系统材质"时最容易混进来的形态。现在四种一起扫,且**先剥注释**
  (注释里正当地引用旧写法不该被judged红)。
- pi 的 §5 建议也已落地:新增"版本库不得跟踪缓存/构建产物"判据(`.tmp/` 那次 554 个文件的事故判据化),
  并放行 `server/internal/static/static/placeholder.html`(go:embed 落点的有意占位,删了 Go 侧编不过)。

## 变异验证(能红,且红在对的地方)

| 变异 | 结果 |
|---|---|
| 品牌蓝 → 系统强调色 | 红 3 条 |
| 手写玻璃 `navBgLight` 回来 | 红 2 条 |
| 导航改用写死半透明色、不调 `backgroundBlurStyle` | 红 2 条 |
| 卡片上再开一层模糊 | 红 1 条("玻璃应只出现在一处,实际 2 处") |

## 验证 / 未验

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(10 个判据文件全绿:跨端 13 条、系统资源名 4 条、harmony-logic 19 条… + vitest 258/258)。
**未验**:观感(卡片间距、系统材质在导航条上的实际效果、深色模式)—— 需真机/模拟器;
模拟器要人在命令行跑一次 `harmony-emu start`。`sys.*` 名字全部对着 SDK 名表核过,且判据持续盯着。
This commit is contained in:
2026-09-14 14:04:04 +08:00
parent 4d226be056
commit 36f3183bba
7 changed files with 434 additions and 100 deletions

View File

@ -1,81 +1,113 @@
/*
* AgentMail 鸿蒙客户端 — 设计令牌
*
* 与 WebUI 的令牌**一一对应**(见 client/electron/src/index.css 的 `:root` 段)。
* 用户要求「下一步就是同步 ui 设计到客户端了」——同步的第一步不是把每个页面都改一遍,
* 而是让两边**用同一份词表**:颜色、圆角、玻璃透明度、模糊档位。
* 否则每加一个页面就会重新抄一遍色值,两个客户端慢慢就长得不一样了。
* ## 这个文件在 2026-09-14 换了一次"来源"jianf「鸿蒙要求用系统方案」
*
* 注意几个刻意的取舍:
* - WebUI 用 `rgb(r g b / a)`,鸿蒙用 `#AARRGGBB` —— 顺序不同,转换时必须显式写清;
* 0.72 × 255 ≈ 184 = 0xB8、0.88 × 255 ≈ 224 = 0xE0。
* - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清),鸿蒙这里给的是**实色**
* surface没有壁纸时不需要透透反而降低可读性。
* - 只有**导航**用半透明 + 模糊(浮在内容之上,模糊有遮蔽意义)——与 WebUI 同一结论。
* 原先这里的每一格都是**手抄 WebUI 的色值**`#F8FAFC`、`#0F172A`…)。现在分成两类,
* 分界线是"**系统有没有这份语义**"
*
* - **表面 / 文字 / 分隔 / 遮罩 / 圆角 / 材质 —— 交给系统**`$r('sys.color.*')`、
* `$r('sys.float.*')`、`BlurStyle.*`)。理由不是"看起来更原生",而是三条实际的:
* ① 它们会**跟随深色模式**(手抄的值不会,这正是 WebUI 那边"半深不浅"的病根);
* ② 跟随系统动效/无障碍设置;
* ③ 少一处会漂移的副本 —— 这些格子的值不再由我们决定,也就不会再和系统打架。
* - **品牌色与业务语义色 —— 仍然自己写**`accent = #2563EB` 是**跨客户端身份**
* (两个客户端是同一个产品),不能退化成随主题/厂商皮肤变的系统强调色;
* 权限三档(蓝/绿/琥珀)与预算三档(灰/橙/红)在系统里**没有对应物**
* (系统只有 warning/alert 两个"情绪色"),硬套会把语义丢掉。
*
* ## 名字不是猜的
*
* `sys.*` 的名字一旦写错,**编译期不报**、只有真机运行到那一行才炸 —— 而本机没有设备。
* 所以名字全部对着 SDK 自带的系统资源名表核过:
* `sdk/default/openharmony/toolchains/id_defined.json`(本机 API 267826 条)。
* 而且有一条判据(`client/electron/test/harmony-system-api.test.mjs`)持续盯着这件事:
* 源码里每个 `$r('sys.<type>.<name>')` 都必须在表里存在且类型相符。
*
* ## 保留的取舍
*
* - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清);鸿蒙用的系统卡片底色也是不透明的,
* 结论一致:**正文面不透,只有浮在内容之上的那一层用材质**"玻璃只出现在一层")。
* - 字体大小仍与 WebUI 的 text-2xs/xs/sm 对齐(字号不是"系统方案"要解决的问题,
* 两边的版式意图是同一套)。
*/
export class Theme {
/** 页面底色(对齐 WebUI 的 bg-gray-50 / slate-100 */
static readonly pageBg: string = '#F8FAFC';
/** 承载文字的面:实色,优先可读性(对应 WebUI --bg-glass 在无壁纸时的观感 */
static readonly surface: string = '#FFFFFFFF';
/** 次级面(列表行 hover、分组底 */
static readonly surfaceMuted: string = '#F1F5F9';
// ─────────────── 表面:系统语义色 ───────────────
/** 页面底色(系统 `ohos_id_color_background`:浅色白/深色深灰,自动跟随主题 */
static readonly pageBg: Resource = $r('sys.color.ohos_id_color_background');
/** 承载文字的面 = **列表卡片底色**(对应"每项一张卡/气泡",而不是通栏底色 */
static readonly surface: Resource = $r('sys.color.ohos_id_color_list_card_bg');
/** 次级面(分组底、列表行 hover */
static readonly surfaceMuted: Resource = $r('sys.color.ohos_id_color_sub_background');
/** 分隔线 */
static readonly border: string = '#E2E8F0';
static readonly border: Resource = $r('sys.color.ohos_id_color_list_separator');
/** 导航底:浅色玻璃 0.72(对应 WebUI --nav-bg: 255 255 255 / 0.72 */
static readonly navBgLight: string = '#B8FFFFFF';
/** 深色主题下的导航底0.72 的 #0F172A对应 --nav-bg 的 .dark 值) */
static readonly navBgDark: string = '#B80F172A';
/** 导航文字:未选中 / 选中(对应 --nav-fg-muted / --nav-active-fg */
static readonly navFg: string = '#475569';
static readonly navFgActive: string = '#1E40AF';
// ─────────────── 文字:系统语义色(三级) ───────────────
static readonly textPrimary: Resource = $r('sys.color.ohos_id_color_text_primary');
static readonly textMuted: Resource = $r('sys.color.ohos_id_color_text_secondary');
static readonly textSubtle: Resource = $r('sys.color.ohos_id_color_text_tertiary');
/** 文字:主 / 次 / 弱(对应 WebUI 的 slate-900 / slate-500 / slate-400 */
static readonly textPrimary: string = '#0F172A';
static readonly textMuted: string = '#64748B';
static readonly textSubtle: string = '#94A3B8';
/** 品牌蓝的浅底(对应 WebUI 的 blue-50用于高亮条/选中行) */
static readonly accentSoft: string = '#EFF6FF';
/** 品牌蓝(按钮、链接、焦点环) */
// ─────────────── 遮罩与材质:交给系统 ───────────────
/**
* 遮罩(模态/淡出层):系统遮罩色。
*
* 这里原来存着 `overlayColor` + `overlayAlpha` 两个常量,再用 `overlay()` 拼成
* `#AARRGGBB` —— 之所以要"色与透明度分开",是因为**遮罩色必须随主题换向**
* (浅色主题用白把图案洗淡、深色主题必须换黑,否则浅色照片在深色界面里糊成一块亮斑)。
* 那件事现在由系统做:一个语义色就够,且换向不会再漏。
*/
static readonly overlay: Resource = $r('sys.color.ohos_id_color_mask_regular');
/**
* 导航/浮层的材质档次。**不再有 `#B8FFFFFF` / `#B80F172A` 这种手写玻璃 alpha** ——
* 那两个值等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突;
* 而材质档次是系统给的,深浅两套颜色由系统按主题挑。
*
* 用 `COMPONENT_THICK`:贴在界面组件上的一层材质(导航条正属于这一类)。
*/
static readonly navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK;
// ─────────────── 圆角:系统尺寸 ───────────────
/** 卡片圆角:系统"卡片"圆角(不再与 WebUI 的 14vp 绑死 —— 允许各自跟随系统) */
static readonly radiusCard: Resource = $r('sys.float.ohos_id_corner_radius_card');
/** 控件圆角:系统"按钮"圆角 */
static readonly radiusControl: Resource = $r('sys.float.ohos_id_corner_radius_button');
// ─────────────── 品牌色:跨客户端身份,必须自己写 ───────────────
/**
* 品牌蓝(按钮、链接、焦点环)。
*
* **不要**为了"用系统方案"把它换成系统的强调色:系统强调色会随主题/厂商皮肤变,
* 一旦换过去,"两个客户端是同一个产品"这件事就靠不住了。
* 这是整个文件里**唯一必须与 WebUI 逐字一致**的取值(判据钉住)。
*/
static readonly accent: string = '#2563EB';
static readonly accentFg: string = '#FFFFFF';
/** 品牌蓝的浅底 / 深前景(与 WebUI 的 blue-50 / blue-700 成对) */
static readonly accentSoft: string = '#EFF6FF';
static readonly accentStrong: string = '#1D4ED8';
/** 导航文字:未选中 / 选中(对应 --nav-fg-muted / --nav-active-fg */
static readonly navFg: Resource = $r('sys.color.ohos_id_color_text_secondary');
static readonly navFgActive: string = Theme.accentStrong;
// ─────────────── 业务语义色:系统没有对应物,继续自己写 ───────────────
/** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger */
static readonly approve: string = '#15803D';
static readonly danger: string = '#B91C1C';
static readonly dangerBg: string = '#FEF2F2';
/** 成功的浅底(对应 WebUI 的 green-50 / green-700 */
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);
}
/**
* 权限档位 → 徽标底色 / 文字色。
@ -83,6 +115,10 @@ export class Theme {
* 与 WebUI 的 `PermissionChip.tsx` **同一映射**plan=蓝 / workspace=绿 / full=琥珀),
* 认不出的档位与空档位按 workspace 处理 —— 映射只有这一处实现,
* 页面不再各自 if-else 挑颜色。
*
* 为什么不用系统情绪色:系统只有 warning/alert 两个,凑不出三档;
* 而档位是**可被文档与判据按名字引用的枚举标识**plan/workspace/full
* 用情绪色顶替它,界面对了语义丢了。
*/
static permBg(mode: string): string {
if (mode === 'plan') {
@ -120,7 +156,8 @@ export class Theme {
* 与 WebUI 的 `BudgetChip` **同一映射**`WorkCard.tsx`
* 剩 0 = 红(用尽)、剩 ≤1 = 橙(将尽,任务需要人介入)、其余 = 中性灰。
* 档位本身由 `MailGrouping.ts` 的 `budgetState()` 算(那是可被判据执行的一层),
* 这里只管"哪个档用什么颜色"。
* 这里只管"哪个档用什么颜色"。这三个色**不进跨端"取值相同"判据**(业务局部),
* 但必须进枚举完整性判据(三档齐全、认不出的归一到中性档)。
*/
static budgetBg(state: string): string {
if (state === 'spent') {
@ -142,10 +179,6 @@ export class Theme {
return Theme.chipNeutralFg;
}
/** 圆角:卡片 14、控件 8与 WebUI 的 --radius-card / --radius-control 一致) */
static readonly radiusCard: number = 14;
static readonly radiusControl: number = 8;
/** 字体大小(与 WebUI 的 text-2xs/xs/sm 对齐) */
static readonly fontTiny: number = 11;
static readonly fontSmall: number = 12;

View File

@ -242,7 +242,7 @@ struct MailDetailPage {
// 遮罩
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay())
.backgroundColor(Theme.overlay)
.onClick(() => { this.showReplyBox = false; })
// 回复框

View File

@ -419,8 +419,16 @@ struct InboxTab {
Row() {
this.MailItem(mail)
}
.width('100%').height(80)
.width('100%').height(74)
/*
* **每项一张卡**不是通栏jianf 同步过来的语义 —— 列表项各自成卡/气泡,
* 卡与卡之间留缝,靠底色与圆角分开,不再用贯通整屏的分隔线。
* 底色用系统的"列表卡片底色",深浅主题由系统挑。
*/
.backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface)
.borderRadius(Theme.radiusCard)
.margin({ bottom: 6 })
.clip(true)
.onClick(() => {
this.openMail(mail);
})
@ -469,6 +477,9 @@ struct InboxTab {
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
.backgroundColor(Theme.surfaceMuted)
.borderRadius(Theme.radiusCard)
.margin({ bottom: 6 })
.clip(true)
.onClick(() => {
this.toggleExpanded(g.key);
})
@ -633,16 +644,20 @@ struct ContactsTab {
.width('100%').layoutWeight(1)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
} else {
List({ space: 1 }) {
// 列表视图:同样**每项一张卡**(不再用贯通分隔线)—— 与卡片视图同一语义
List({ space: 6 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
this.ContactItem(c, idx)
}
.height(85)
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.clip(true)
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 })
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
.width('100%').height('100%')
@ -809,6 +824,17 @@ struct MainPage {
}
.width('100%')
.justifyContent(FlexAlign.Center)
/*
* 导航底:**系统材质**,不是手写 alpha。
*
* 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)——
* 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突,
* 而且还要我们自己维护两套。现在只声明**档次**`Theme.navMaterial`
* 深浅两套颜色与模糊半径都由系统按主题给。
*
* 玻璃**只出现在这一层**(内容面不透)—— 嵌套各加一层模糊是 pi 点名要避免的。
*/
.backgroundBlurStyle(Theme.navMaterial)
}
build() {

View File

@ -176,7 +176,7 @@ struct SettingsPage {
Column() {
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay())
.backgroundColor(Theme.overlay)
.onClick(() => { this.showAddDialog = false; })
Column() {