feat(clients): UI 设计同步到客户端 —— Electron 包重建 + 鸿蒙设计令牌

用户:「下一步就是同步 ui 设计到客户端了」。

## ① Electron 客户端(之前严重滞后)

打包产物停在 **09:11**,而前端 dist 是 **12:23** ⇒ 今天所有 UI 工作(导航合并、悬浮玻璃、
圆桌语言、Composer、动画、模糊分层…)**一个都不在包里**。已重建 AppImage + deb。

**验证**(关键:AppImage/deb 是压缩容器,`grep` 直接扫是扫不到的 ——
我第一次就差点因此得出"包里没有"的结论):把 deb 解到 /tmp 再查 `app.asar`:

    comm-tabs=2  compose-fab=1  nav-rail=2  glass-control=2  cal-slide-next=2
    构建戳 "5ce25f6·0914-1240"(与当前提交一致)

## ② 鸿蒙客户端

它是**原生 ArkTS 应用**(22 个 .ets、自带 API 层),不是 WebView 壳 ⇒ 设计要移植。

第一步做的是**共用词表**:新增 `common/Theme.ets`(品牌蓝、页面底、面、分隔线、
导航玻璃不透明度、圆角 14/8、语义色、字号),全部注明与 WebUI 令牌的对应关系
(含一个易错点:CSS 是 `rgb(r g b / a)`,鸿蒙是 `#AARRGGBB`,0.72×255≈184=0xB8)。

顺带修掉一个真 bug:TabBar 的选中色写成 `this.currentIndex === 0` ⇒
**只有第一个 tab 会高亮**。现在按每个 tab 自己的下标判断。

**编译验证**:`hvigorw assembleHap` → **BUILD SUCCESSFUL**(HAP 已打包)。
(无法在设备上跑:本机 `hdc list targets` 为空、HAP 未签名 ⇒ 视觉未验证,如实说明。)

## ③ 判据:跨客户端令牌一致性

新增 `test/cross-client-theme.test.mjs` 5 条:品牌蓝、卡片圆角(0.875rem=14px)、
导航玻璃 0.72 —— 断言的是"两边对同一件事取值一致",不约束实现方式
(CSS 变量 vs ArkTS 常量本来就该不同),并带一条扰动自检。
这种漂移**没有任何判据会红**,所以必须显式钉住。

## 还没做的(如实说明)

鸿蒙端只同步了**设计语言**,功能面不对等:鸿蒙是 收件箱/会话/联系人 三个 tab,
没有 日历/授权/管理;也没有玻璃悬浮底栏与 Compose 共用组件。要做功能对齐是另一件事,
需要单独排期(我可以按你的优先级来)。
This commit is contained in:
2026-09-14 12:43:08 +08:00
parent 5ce25f6fdb
commit cefd96a6a3
3 changed files with 119 additions and 5 deletions

View File

@ -0,0 +1,61 @@
/**
* 两个客户端必须用**同一份设计词表**。
*
* 用户下一步要求:「同步 ui 设计到客户端」。同步的第一件事不是把每个页面重画一遍,
* 而是两边共用同一套令牌(颜色/圆角/玻璃透明度)—— 否则每加一个页面就重抄一遍色值,
* 两个客户端会越走越远,而且这种漂移**没有任何判据会红**。
*
* 这里只断言"两边对同一件事的取值一致"不断言实现方式WebUI 用 CSS 变量、
* 鸿蒙用 ArkTS 常量,本来就该不同)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const web = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
const harmony = readFileSync(
join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'),
'utf8'
);
const hex = h => h.toUpperCase();
test('品牌蓝一致', () => {
// WebUI: --c-blue-600 应该是 37 99 235 = #2563EB
const webBlue = web.match(/--c-blue-600:\s*(\d+)\s+(\d+)\s+(\d+)/);
assert.ok(webBlue, 'WebUI 要有 --c-blue-600');
const asHex = hex(
'#' +
[webBlue[1], webBlue[2], webBlue[3]]
.map(n => Number(n).toString(16).padStart(2, '0'))
.join('')
);
const harmonyBlue = harmony.match(/accent: string = '(#[0-9A-Fa-f]{6})'/);
assert.ok(harmonyBlue, '鸿蒙要有 accent');
assert.equal(hex(harmonyBlue[1]), asHex, `品牌蓝不一致WebUI ${asHex} vs 鸿蒙 ${harmonyBlue[1]}`);
});
test('卡片圆角一致WebUI 0.875rem = 14px', () => {
assert.match(web, /--radius-card:\s*0\.875rem/);
assert.match(harmony, /radiusCard: number = 14/);
});
test('导航玻璃不透明度一致(都是 0.72', () => {
assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/);
// 0.72 × 255 ≈ 184 = 0xB8鸿蒙用 #AARRGGBB顺序与 CSS 不同)
assert.match(harmony, /navBgLight: string = '#B8FFFFFF'/);
});
test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => {
const mutated = harmony.replace(/accent: string = '#2563EB'/, "accent: string = '#FF0000'");
const m = mutated.match(/accent: string = '(#[0-9A-Fa-f]{6})'/);
assert.notEqual(hex(m[1]), '#2563EB');
});
test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => {
assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/);
});

View File

@ -0,0 +1,50 @@
/*
* AgentMail 鸿蒙客户端 — 设计令牌
*
* 与 WebUI 的令牌**一一对应**(见 client/electron/src/index.css 的 `:root` 段)。
* 用户要求「下一步就是同步 ui 设计到客户端了」——同步的第一步不是把每个页面都改一遍,
* 而是让两边**用同一份词表**:颜色、圆角、玻璃透明度、模糊档位。
* 否则每加一个页面就会重新抄一遍色值,两个客户端慢慢就长得不一样了。
*
* 注意几个刻意的取舍:
* - WebUI 用 `rgb(r g b / a)`,鸿蒙用 `#AARRGGBB` —— 顺序不同,转换时必须显式写清;
* 0.72 × 255 ≈ 184 = 0xB8、0.88 × 255 ≈ 224 = 0xE0。
* - WebUI 的"正文面"是 0.88 不透明的玻璃(字要读得清),鸿蒙这里给的是**实色**
* surface没有壁纸时不需要透透反而降低可读性。
* - 只有**导航**用半透明 + 模糊(浮在内容之上,模糊有遮蔽意义)——与 WebUI 同一结论。
*/
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';
/** 分隔线 */
static readonly border: string = '#E2E8F0';
/** 导航底:浅色玻璃 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 accent: string = '#2563EB';
static readonly accentFg: string = '#FFFFFF';
/** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger */
static readonly approve: string = '#15803D';
static readonly danger: string = '#B91C1C';
static readonly dangerBg: string = '#FEF2F2';
/** 圆角:卡片 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;
static readonly fontBody: number = 14;
}

View File

@ -3,6 +3,7 @@
* 收件箱 / 会话 / 联系人 三个 TabContent
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
@ -613,10 +614,12 @@ struct MainPage {
@State currentIndex: number = 0;
@Builder
TabBarBuilder(title: string, icon: string) {
TabBarBuilder(title: string, icon: string, index: number) {
Column() {
Text(icon).fontSize(20)
Text(title).fontSize(11).fontColor(this.currentIndex === 0 ? '#1A73E8' : '#666666')
Text(title)
.fontSize(Theme.fontTiny)
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
}
.width('100%')
.justifyContent(FlexAlign.Center)
@ -627,17 +630,17 @@ struct MainPage {
TabContent() {
InboxTab()
}
.tabBar(this.TabBarBuilder('收件箱', '📬'))
.tabBar(this.TabBarBuilder('收件箱', '📬', 0))
TabContent() {
SessionsTab()
}
.tabBar(this.TabBarBuilder('会话', '💬'))
.tabBar(this.TabBarBuilder('会话', '💬', 1))
TabContent() {
ContactsTab()
}
.tabBar(this.TabBarBuilder('联系人', '👤'))
.tabBar(this.TabBarBuilder('联系人', '👤', 2))
}
.onChange((index: number) => {
this.currentIndex = index;