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:
61
client/electron/test/cross-client-theme.test.mjs
Normal file
61
client/electron/test/cross-client-theme.test.mjs
Normal 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/);
|
||||
});
|
||||
50
client/harmony/entry/src/main/ets/common/Theme.ets
Normal file
50
client/harmony/entry/src/main/ets/common/Theme.ets
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user