42 KiB
Thing UI 设计系统与开发约束
适用范围:
src/下全部前端代码(12 个模块、220+ 组件) 约束力:第 7 章为硬性约束,Code Review 据此判定;第 3–5 章为实现规范 配套:UI Design System画布(色彩 / 排版 / 组件 / 骨架四屏可视化规范) 唯一事实来源:token 值以src/style.css为准,本文档与之同步
1. 设计原则
以下 8 条为不可协商项,贯穿全部模块。
P1 · 单一强调色
全应用只有 --primary 一种交互色,用于按钮、链接、选中态、焦点环。不存在"第二个主题色"。状态色(success/warning/danger)仅表达状态,不得用于可点击元素。
P2 · 颜色只能来自 token
禁止在组件内写 hex、rgb、oklch,禁止引用 Tailwind 原生色阶(bg-blue-500、text-emerald-700)。组件里出现的每个颜色都必须能追溯到 style.css 的一个语义变量。
P3 · 状态必须四态齐全 任何数据驱动的界面必须实现 加载 / 空 / 错误 / 成功 四态。禁止空白,禁止静默失败。这是最容易漏、也最影响质感的一条。
P4 · 一个主区域,一个主操作 每屏有且仅有一个视觉重心、一个主按钮。破坏性操作(停止内核、删除记录)必须视觉降级或二次确认,且不得与主操作同权重。
P5 · 骨架统一,内容区自治 所有模块共享同一套外壳(侧边栏 / 标题栏 / Tab 行)。模块之间允许不同的只有内容区布局——这是"统一"与"灵活"的分界线。
P6 · 密度一致 全应用统一使用紧凑偏中密度:13px 正文、34px 控件、16px 卡片内边距。禁止在同一屏内混用疏密两种节奏。
P7 · 卡片统一描边 + 轻阴影,浮层用重阴影
层级靠**表面色差 + 1px 描边 + 轻阴影(shadow-sm / --shadow-card)**表达。浮层(下拉、气泡、对话框)使用更重的 --shadow-popover / --shadow-dialog。
P8 · 中文界面不做负字距
字体模板中的负字距(-0.28px 等)是为拉丁字母设计的,会让中文发挤。拉丁文与数字可保留微调,中文一律 letter-spacing: 0。
2. Design Token
2.1 命名与结构
Token 分四组,在现有 shadcn 变量之上扩展,不另起一套:
shadcn 既有(值调整) → --background / --foreground / --primary / --card / --border / ...
本项目扩展 → --canvas / --fg-* / --success / --warning / --data-* / --shadow-*
关键调整:把 shadcn 的 --primary 从当前近黑色(oklch(0.21 0.006 285.885))改为强调蓝 #0066CC。这样全部 shadcn 组件(Button / Switch / Checkbox / focus ring)自动获得统一的交互色,无需逐组件改样式。
2.2 色彩
浅色模式(:root)
:root {
/* ── 表面 ───────────────────────────── */
--canvas: #F5F5F7; /* 应用底 / 模块内容区背景 */
--background: #FFFFFF; /* shadcn 基准面 */
--card: #FFFFFF; /* 卡片 / 面板 */
--popover: #FFFFFF; /* 浮层 */
--subtle: #F0F0F2; /* 次级填充:搜索框底、分段控件槽、代码块 */
--hover: #EBEBED; /* 幽灵控件 hover */
--active: #E4E4E7; /* 幽灵控件 active */
/* ── 描边 ───────────────────────────── */
--border: #E4E4E7; /* 卡片外框、输入框 */
--border-strong: #D1D1D6; /* 需要更强分隔时 */
--divider: #F0F0F2; /* 卡片内部分隔线(1px) */
--input: #E4E4E7;
/* ── 文字 ───────────────────────────── */
--foreground: #1D1D1F; /* 主文字:标题、正文、数值 */
--fg-secondary: #55555C; /* 次文字:键名、标签说明、幽灵控件文字 */
--muted-foreground: #8A8A90; /* 辅助:占位符、规格说明、次级 meta */
--fg-disabled: #B4B4BA; /* 禁用 */
/* ── 强调 ───────────────────────────── */
--primary: #0066CC;
--primary-hover: #0055B3;
--primary-active: #004A9E;
--primary-foreground: #FFFFFF;
--primary-soft: #E8F1FC; /* 选中 Tab 底、选中行底 */
--primary-soft-text: #004F9E; /* 上述底上的文字 */
--ring: #0071E3;
/* ── 语义状态 ───────────────────────── */
--success: #0F7B4F; --success-soft: #E7F5EE; --success-text: #0B5C3B;
--warning: #A96B00; --warning-soft: #FDF4E4; --warning-text: #7D4F00;
--danger: #C7362F; --danger-soft: #FDECEA; --danger-text: #9A2620;
--info: #0066CC; --info-soft: #E8F1FC; --info-text: #004F9E;
--neutral: #8A8A90; --neutral-soft: #F0F0F2; --neutral-text: #55555C;
--destructive: #C7362F; /* shadcn 兼容别名,指向 danger */
--destructive-foreground: #FFFFFF;
/* ── 数据方向(与状态语义解耦)───────── */
--data-down: #0F7B4F; /* 下载 / 入站 */
--data-up: #C7362F; /* 上传 / 出站 */
/* ── 尺寸 ───────────────────────────── */
--radius: 12px;
--radius-xs: 4px; --radius-sm: 6px; --radius-md: 8px;
--radius-lg: 12px; --radius-xl: 16px;
/* ── 阴影 ───────────────────────────── */
--shadow-card: 0 1px 2px rgba(0,0,0,0.06);
--shadow-popover: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.10);
--shadow-dialog: 0 4px 12px rgba(0,0,0,0.08), 0 20px 48px rgba(0,0,0,0.16);
}
深色模式(.dark)
深色下必须成对重定义,且强调色改为亮蓝(#0066CC 在深底上对比度不足):
.dark {
--canvas: #16161A;
--background: #16161A;
--card: #1E1E22;
--popover: #1E1E22;
--subtle: #26262B;
--hover: #2C2C32;
--active: #33333A;
--border: #2E2E34;
--border-strong: #3A3A42;
--divider: #26262B;
--input: #2E2E34;
--foreground: #F5F5F7;
--fg-secondary: #A8A8B0;
--muted-foreground: #7A7A82;
--fg-disabled: #55555C;
--primary: #2997FF;
--primary-hover: #4AA8FF;
--primary-active: #1A85E8;
--primary-foreground: #FFFFFF;
--primary-soft: #14304D;
--primary-soft-text: #7FC0FF;
--ring: #2997FF;
--success: #3DC77F; --success-soft: #12301F; --success-text: #7FE0AB;
--warning: #E0A83C; --warning-soft: #33260E; --warning-text: #F0C976;
--danger: #FF6B60; --danger-soft: #3A1A18; --danger-text: #FF9E96;
--info: #2997FF; --info-soft: #14304D; --info-text: #7FC0FF;
--neutral: #7A7A82; --neutral-soft: #26262B; --neutral-text: #A8A8B0;
--destructive: #FF6B60; --destructive-foreground: #FFFFFF;
--data-down: #3DC77F;
--data-up: #FF6B60;
}
状态色三件套的用法
每个语义状态有 3 个 token,用途严格区分:
| Token | 用途 | 示例 |
|---|---|---|
--success |
实心:状态点、图标、粗体数值 | ● 运行中 的圆点 |
--success-soft |
浅底:徽章背景、行高亮 | <span class="bg-success-soft"> |
--success-text |
浅底上的文字 | 徽章内的"已安装" |
禁止把 --success 用作文字色 + --success-soft 以外的底色组合,也禁止用状态色做可点击元素的颜色(见 P1)。
强调色的使用边界
--primary 只表达两件事:这里可以点、这里是当前项。
| 用 | 不用 |
|---|---|
| 主按钮底色、开关开启态、焦点环 | 卡片标题前的装饰性图标 |
| 当前 Tab / 选中列表行 / 选中项 | 全部 Tab 的图标 |
| 可点击的文字链接 | 状态徽章(那是状态语义色的职责) |
| 输入框聚焦态 | 大面积背景、分隔线、装饰元素 |
反例(已修正):proxy 概览页的 6 个卡片标题图标全部用了 text-primary,加上主按钮、开关、选中态,整屏发蓝 —— 结果"蓝色"不再意味着"可点击",层次感也丢了。已统一改为 --fg-secondary。
数据方向色(--data-*)
下载/上传速率沿用现有视觉(绿/红),但独立成 token,与 success/danger 解耦。
待决策:当前
data-up与danger同色,会让人把"上传速率"误读为"异常"。若希望彻底解耦,建议改为--data-down: #0066CC(蓝)/--data-up: #A96B00(琥珀)。这是唯一改动成本为零的时机——只需改两个 token。
2.3 排版
字体族保持不变(现有 --font-sans 已正确针对中文优化):
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
生产代码不引用外部字体 CDN(设计稿 / 原型页可保留 Inter);数值对齐通过全局 font-feature-settings: 'tnum','lnum' 实现,不依赖具体字体。
字号标尺(9 级,全部为 px):
| Token | 字号 / 字重 | 行高 | 用途 |
|---|---|---|---|
display |
28 / 700 | 36 | 页面级大标题(设置页、关于页),全应用不超过 1 处/屏 |
metric |
22 / 600 | 28 | 关键数值(速率、容量)· 必须 font-variant-numeric: tabular-nums |
h1 |
16 / 600 | 24 | 模块标题、卡片标题 |
h2 |
14 / 600 | 20 | 区块标题、卡片标题(小卡) |
h3 |
13 / 600 | 20 | 小节标题、列表分组标题 |
body |
13 / 400 | 20 | 界面默认正文 |
body-strong |
13 / 600 | 20 | 行内强调、键值行的键 |
caption |
12 / 400 | 18 | 辅助说明、参数解释 |
label |
11 / 500 | 16 | 徽章、标签、表头、单位 |
规则:
- 字号只从以上 9 级中取,禁止
text-[13.5px]这类任意值。 - 数值(速率、版本号、计数)统一用
tabular-nums,避免刷新时数字跳动。 - 中文
letter-spacing: 0;拉丁小标题可-0.1px,正文一律0。 - 行高按表中固定值写,不用 Tailwind 默认相对行高。
2.4 间距
4px 基数,8 个值,用尽即止:
| Token | 值 | 典型用途 |
|---|---|---|
space-1 |
4 | 图标与文字间隙、徽章内边距(纵向) |
space-2 |
8 | 控件内元素间隙、徽章内边距(横向) |
space-3 |
12 | 列表行内间隙、卡片标题与内容间距 |
space-4 |
16 | 卡片内边距、卡片间距 |
space-5 |
20 | 模块内容区默认内边距 |
space-6 |
24 | 区块之间、对话框内边距 |
space-8 |
32 | 大区块分隔 |
space-12 |
48 | 页面级分段 |
禁止 p-[18px]、gap-[14px]、mt-[6px]。需要更细的调整时,说明是布局问题而非间距问题。
2.5 圆角
| Token | 值 | 适用 |
|---|---|---|
radius-xs |
4 | 内联小元素:标签内块、进度条端点 |
radius-sm |
6 | 徽章、小按钮、下拉项 |
radius-md |
8 | 按钮、输入框、下拉框、分段控件、Tab 项 |
radius-lg |
12 | 卡片、面板、样张容器 |
radius-xl |
16 | 对话框、大浮层 |
radius-full |
9999 | 状态点、开关、头像、搜索框(胶囊) |
规则:同层级控件必须同圆角。按钮 8px 就全应用 8px,不允许某模块用 6px。
2.6 阴影与层级
层级主要靠表面色差 + 描边 + 轻阴影(P7):
| 层级 | 表现 | 用途 |
|---|---|---|
| L0 应用底 | --canvas,无描边无阴影 |
窗口、模块内容区 |
| L1 卡片 | --card + 1px --border + 轻阴影 shadow-sm |
所有卡片、面板 |
| L1.5 次级容器 | --subtle,无描边 |
搜索框底、分段控件槽、代码块 |
| L2 浮层 | --card + --shadow-popover |
下拉菜单、气泡、tooltip、右键菜单 |
| L3 模态 | --card + --shadow-dialog + 遮罩 |
对话框、AlertDialog |
卡片默认使用轻阴影 --shadow-card(shadow-sm);--shadow-popover / --shadow-dialog 仅用于浮层与模态。
2.7 动效
--ease-standard: cubic-bezier(0.22, 0.61, 0.36, 1); /* 沿用现有 tab-animate 曲线 */
--duration-fast: 120ms; /* hover、颜色变化 */
--duration-base: 200ms; /* 展开、浮层出现 */
--duration-slow: 320ms; /* 页面/Tab 切换 */
规则:
- 只对
opacity和transform做动画(性能)。 - 按钮按下统一
transform: scale(0.97),不用颜色变化模拟按压。 - 所有动画必须包在
@media (prefers-reduced-motion: reduce)里降级为none(现有.music-eq已正确实现,作为模板)。 - Tab 切换复用现有全局
.tab-animate类,不要各模块自建过渡。
2.8 图标
- 库:
@lucide/vue,禁止混用其他图标库或内联 Unicode 符号(↓✓●等)。 - 尺寸只有 3 档:
14(行内 / 徽章内)、16(按钮、Tab、默认)、20(侧边栏、卡片标题)。 stroke-width统一1.75(lucide 默认 2,偏粗,与 13px 正文不协调)—— 在style.css里对.lucide全局设置,不要逐个组件写。- 图标不单独承载语义:状态点旁必须有文字,工具栏图标必须有 tooltip 或文字标签(P4 可理解性)。
2.9 主题色自定义(已落地)
需求:设置页提供主题色选择,统一改变全应用的强调色。
可行性:可行且成本低 —— 因为全应用的"可交互色"已经收敛到单一来源 --primary。落地后组件层改动为 0。
实现:src/lib/theme.ts(派生 + 应用)+ appStore.setAccent() + 设置页「主题色」卡片。
用户只选一个基色,深浅两套由代码派生 —— 不需要为浅色/深色各选一次(同色相两个亮度是既有做法:
浅 #0066cc / 深 #2997ff),也不会出现"浅色选青、深色选紫"的语义割裂。
需要派生的变量
只改基色是不够的,必须同时派生 7 个变量,否则 hover / 选中态仍是旧色:
| 变量 | 派生规则(OKLCH,色相 H 原样保留) | 用途 |
|---|---|---|
--primary |
基色,L 归一到 0.42–0.58(浅)/ 0.68–0.80(深),C 限制在 0.06–0.20 |
主按钮、开关、焦点环 |
--primary-hover |
浅:L − 0.08;深:L + 0.06 | 主按钮 hover |
--primary-active |
浅:L − 0.16;深:L − 0.10 | 主按钮按下 |
--primary-soft |
浅:oklch(0.95, C×0.30, H);深:oklch(0.30, C×0.45, H) |
选中底、幽灵按钮 hover |
--primary-soft-text |
基色加深/提亮起步,再按对比度推进到 ≥4.5:1 | 上述底上的文字 |
--ring |
同 --primary |
焦点环 |
--primary-foreground |
白字低于 2.5:1 时才换 #1D1D1F |
填充上的文字 |
深色模式单独派生一套(深底上要提亮),不能复用浅色值。
关键技术点
-
用 OKLCH 做派生,不要用 HSL。 只换色相 H、锁定 L 与 C,才能保证换任意色相后对比度一致。HSL 的"明度"感知不均匀,换色相会时亮时暗。
-
对比度靠"推进亮度"而不是固定阈值判定。 同一 OKLCH L 下不同色相的 WCAG 亮度差很多(L=0.58 的青柠对白底只有 ~2.8:1,蓝色却有 ~5:1), 所以归一到区间后还要继续推进 L 直到达标:
- 浅色:
--primary对#FFFFFF≥ 4.5:1(它会被当文字用在白卡上)→ 选亮色自动压暗 - 深色:
--primary对#1E1E22≥ 4.5:1 → 自动提亮 --primary-soft-text对--primary-soft≥ 4.5:1 以上断言在src/lib/theme.test.ts里对 10 个预设 × 2 模式全量校验。
- 浅色:
-
--primary-foreground的取舍是有意的。 深色模式下强调色必须够亮才能在深底上当文字用(≥4.5:1),同一颜色做填充时白字只能到 2.7–3:1 —— 现有主题就是这个取舍(#2997ff+ 白字 ≈ 2.98:1)。因此门槛按 UI 组件的 2.5:1 兜底, 只在自定义极亮色时才改深墨。 -
只开放"强调色",不开放状态色。
success/warning/danger是语义色,改了会破坏"绿色=成功"的心智模型。--data-*同理。--info一族当前取值与强调色相同(蓝),但不跟随主题色 —— 它表达"信息"而非"可交互", 换紫/绿主色后少数徽标(代理"国外"、HTTP 1xx/3xx、快速面板分类)仍是蓝色,属有意的语义区分。 -
预设 + 自定义。 10 个预设(默认蓝 / 靛蓝 / 紫罗兰 / 玫红 / 橙 / 琥珀 / 青柠 / 翠绿 / 青绿 / 石墨)+ 原生取色器。 纯自由取色很容易选出对比度不达标的颜色,所以色板优先。 色板显示色用派生后的浅色模式主色(所见即所得,如青柠显示为压暗后的绿)。
-
实时预览 + 可回滚。 点击色块 / 拖动取色器立即生效(写 7 个变量,无防抖);「恢复默认」清除行内变量回到内置蓝。
-
持久化:随其它设置写入
thing_app_settings(accentId+accentHex)。 属纯增量字段,SETTINGS_VERSION不递增 —— 递增会命中"版本不匹配清空"分支, 把用户的模块排序/启停/开机自启一并抹掉,为零收益付真实数据损失。 -
行内变量的优先级陷阱。 变量写在
documentElement的行内样式上,优先级高于:root与.dark两条规则,因此: 切模式必须整组 7 个重写(否则浅色的行内值会残留到深色模式); 恢复默认必须removeProperty(写空串仍算"已设置")。
落地范围
src/lib/theme.ts—— 派生计算 + 应用/清除(新增,含单测)src/stores/appStore.ts——accentId/accentHex+setAccent(),在applyTheme()与handleSystemThemeChange()里按当前模式应用src/modules/settings/GeneralSettings.vue—— 「主题色」卡片- 9 个独立窗口(快速面板 / 托盘菜单 / 下载窗 / 剪贴板弹窗与预览 / 翻译弹窗 / 截图覆盖层·编辑器·贴图)
各加一行
applyStoredAccent();托盘菜单额外补storage监听 src/style.css—— 无需改动(7 个变量已是 CSS 变量)- 首屏闪烁(FOUC)不需要额外处理:主窗口
visible: false,appStore.init()在show()前完成应用; 文档原建议的 Rustinitialization_script方案与本仓库 localStorage 方案冲突,不实施
影响面:0 处组件改动。 这正是把交互色收敛到 --primary 单点带来的收益 —— 现在不做,将来也不用挨个改组件。
3. 页面框架
框架只定义三件事:谁多高、谁滚动、padding 加在哪。这三件事定死之前,任何模块级的美化都会被布局 bug 吃掉。
3.1 分区与高度约束链
AppShell h-screen flex-col
├ TitleBar h-14 shrink-0 固定 · 侧栏图标 + 模块 Tab + 搜索
└ 主体 flex-1 min-h-0 flex-row
├ Sidebar w-15 shrink-0 60px 固定
└ 内容滚动区 flex-1 min-h-0 overflow-y-auto ← 全应用唯一滚动容器
└ 内容 wrapper p-5(占满内容宽,无 max-width)
Tab 已并入标题栏(见 3.5),内容区不含独立 Tab 行,纵向空间最大化。
三个必须遵守的点(都是实际踩过的坑,不是理论):
-
min-h-0不能漏。 flex 子项默认min-height: auto—— 内容变高时它不收缩,直接把父容器顶破,底部 padding 被推出视口。这就是"卡片贴着应用底部"的根因。 -
padding 加在滚动容器内部的 wrapper 上。 加在滚动容器自身,底部 padding 会被内容高度覆盖,滚动到底依然贴边。
-
断点按内容宽度选,不按视口宽度。 侧边栏固定占 60px —— 视口 1000px 时内容只剩 940px。两列网格必须用
md(768) 而不是lg(1024),否则窗口一窄就退化成单列全宽、卡片大片留白。
3.2 滚动归属
全应用只有一处滚动容器。
- 模块内容区随外层滚动,不是每个模块各建一个滚动容器
- 模块根 / 页面级容器禁止
overflow-y-auto—— 那会绕过外层ScrollArea,出现原生宽滚动条(快速面板踩过);根容器一律min-h-full - 局部列表(剪贴板历史、音乐队列等)可以用
ScrollArea组件做独立滚动 —— 这是「局部区域」而非「页面级」 - 需要固定表头的长表格:用
position: sticky固定表头,而不是给表格单独开滚动容器 - 例外:terminal / translate 的工作台面板内部滚动(workbench 模式特征)
- 代价:切换 Tab 时共享同一个滚动位置(可以接受,反而更符合"翻页"直觉)
统一组件使用规则(违规即 UI 不一致,等同违反第 5 章 D 系):
| 需求 | 用 | 禁止 |
|---|---|---|
| 页面滚动 | 外层 ScrollArea(自动,模块无需处理) |
模块根 overflow-y-auto |
| 局部列表滚动 | ScrollArea 组件 |
原生 overflow |
| 键值行 / 状态点 / 徽章 / 大数值 | @/components/common 对应组件 |
手写 flex 结构 |
| 卡片 | Card / InfoGroup(card) / StatCard(统一 shadow-sm) |
手写描边块 |
| 多分组设置表单 | SettingGroup + SettingRow |
手写行 |
3.3 内容区宽度
内容区不限宽,占满窗口宽度(已移除 max-w-6xl 限制)。窗口拉宽时卡片随内容区延展,不再居中约束。
3.4 内容区布局模式
内容区只能从以下五种里选,不得自创:
| 模式 | 适用 | 规格 |
|---|---|---|
grid |
概览 / 状态总览 | grid-cols-1 md:grid-cols-2·gap 16·items-start 让卡片各自高度 |
split |
列表类(clipboard / music / terminal) | 左栏 280–320px·右栏 fill·中间 1px --border·无 padding |
workbench |
输入-输出类(devtools / translate) | 纵向堆叠·gap 12 |
settings |
设置页 | 纵向堆叠·gap 24·复用 SettingGroup |
plain |
其他 | 纵向堆叠·gap 12 |
grid 的用法边界:只适合「各块内容量接近」的概览页。若各块信息量差异大,两列会让矮的那列留空。
概览页标准结构 · 「状态横幅 + 卡片网格」(适用于 proxy / 下载 / 硬件监控,四处横幅已统一,见下方「落地状态」):
第一行:状态横幅(全宽,独立于网格,高约 76px)
├ 容器 `Card`(`!py-0 !gap-0`)+ `CardContent`(`flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4`)
│ 圆角/描边/阴影一律由 Card 提供(`rounded-xl + border + shadow-sm`),禁止手写 `rounded-lg border bg-card`
├ 左组成组(状态 → 核心属性 → 实时指标,依次靠左,不把指标悬在中间)
│ 状态区 状态点 8px + 状态文字 13px/500 + 进程名 13px 次要色(状态点/文字用语义色;运行态加 status-dot-pulse)
│ 指标区 label 11px 灰 + 数值 18px/600 tabular-nums;核心属性(如内核版本、PID)也占一个指标位
└ 操作区 运行中 = 重启(outline)+停止(destructive);停止 = 启动(primary);sm = 32px;`ml-auto` 推到行尾
下方:**两列瀑布流** `columns-1 md:columns-2`(卡片 `break-inside-avoid`,列间距/卡间距 16px),`InfoGroup variant="card"`
├ 卡内键值**一律单列**(`InfoGroup columns=1`,一行一条)—— 双列键值会挤压值区,路径类长值被截断
├ 瀑布流各卡自然高度、列内堆叠,**没有「同行等高」的配对压力** —— 双列网格的配对调整是反复返工的根源,已废弃
├ 表单类内容(下拉/输入)用「label 在上 + 控件全宽」纵排,不塞进键值行的值区
└ 阅读顺序为纵向(先左列后右列),分组按重要性排序;列底部不齐是模型特征,不是缺陷
适用判断:模块概览有「一个核心状态 + 2-4 个实时指标 + 一个主操作」就用横幅;三者不齐的模块直接用卡片网格,不要硬凑横幅。
落地状态(2026-09-21 统一):proxy 概览 / proxy 连接 / 下载任务 / 硬件监控概览四处横幅均按本节实现 ——
- 硬件监控:
PID / 传感器 / 重启 / 事件从text-xs内联「标签: 值」提升为指标位,进程名用ThingHK,操作区xs→sm,停止按钮改destructive - 下载:
下载中 / 等待 / 已暂停 / 已完成 / 已取消从Badge改为指标位,且恒显(0 比隐藏更真实,也避免位置漂移) - 状态文字一律「状态 + 进程名」顺序(
运行中 mihomo/已连接 ThingHK/运行中 下载引擎)
已否决的两个方案(避免回头再试):
- 平铺分组(InfoGroup 无容器版):无容器所以视觉"散";键值两端对齐导致一行一条时"空"。
- 等宽两列卡片:内容量不齐导致列内留白。D2 之所以成立,是横幅吸收了最"高"的内容(状态+指标),剩下的卡片内容量天然接近。
设置行结构(SettingGroup + SettingRow):
分组标题(h2 14/600)+ 可选说明
└ 设置行:左 label · 右控件,行高 44,行间 1px --divider
3.5 导航与 Tab
Tab 只有一处:标题栏。 模块内容区不再渲染 Tab 行。
| 层级 | 位置 | 内容 |
|---|---|---|
| 一级导航 | 侧边栏 60px | 模块图标(代理 / 音乐 / 剪贴板…) |
| 二级导航 | 标题栏内 | 当前模块的 Tab(概览 / 连接 / 节点 / 订阅 / 设置) |
为什么:Tab 行独占 48px 纵向空间,却只承载一个切换器。并入标题栏后,内容区最大化。
实现:
useModuleTabs('proxy', activeTab, tabs, { alwaysVisible: true })
- 模块不渲染
TabsList,标题栏切换器常驻 - 传
alwaysVisible后不再注册IntersectionObserver - 未传该选项的模块保持原行为(Tab 行滚出视口时才在标题栏补一个)—— 属过渡状态,应逐步迁移
选中态:--primary 实心胶囊 + 白字。标题栏空间小、Tab 数量少,实心比下划线更易扫读。
过渡期存在两套 Tab 外观:模块内的
TabsList(下划线)与标题栏切换器(实心胶囊)。全部模块迁移完成后,只保留标题栏这一套,那时TabsTrigger的下划线样式可以简化掉。
3.6 状态覆盖(P3 落地)
每种模式都必须实现四态,抽成通用组件,不在模块内手写:
| 状态 | 组件 | 表现 |
|---|---|---|
| 加载 | <StateBlock variant="loading"> |
Spinner 16px + "加载中…",高度撑满容器最小 120px |
| 空 | <StateBlock variant="empty"> |
图标 32px(--muted-foreground) + 说明 caption + 可选主按钮 |
| 错误 | <StateBlock variant="error"> |
图标 32px(--danger) + 错误信息 + "重试"次按钮 |
| 无权限 | <StateBlock variant="denied"> |
图标 + 说明 + 引导操作 |
验收:随机打开任一模块的任一 Tab,断网/清空数据/制造错误,都必须看到对应的块,而不是空白。
3.7 页面构造示例 · proxy(样板,含其余四页规划)
概览页已定稿(横幅 + 瀑布流)。其余四页用同一套模式组装,空态一律按 M8(结构恒定 + — 占位):
统一原则(四页共用)
- 每页从 3.4 的模式里选一种承载,不自创
- 统计类信息统一用「指标行」形态(与横幅指标区同构:label 11 + 数值 18/600),容器口径同横幅 —— 用
Card承载,不做裸指标行(M8) - 表格/列表统一用卡片承载,长表头
sticky - 行内操作集中在行尾,用
icon-sm(28px),禁微缩按钮
连接 —— 指标行 + 表格卡(workbench 变体)
指标行:活跃连接 + 规则命中 chips(与概览横幅同容器 Card + `px-4 py-4`;chips 紧挨活跃连接,随命中项变化向右延伸)
下载/上传速率已删(与概览横幅重复);命中规则计数已删(恒等于活跃连接数)
表格卡(全宽):进程/域名 + 国内国外徽章 + 规则 + 上下行流量 + 断开
表头 sticky · 行 hover(bg-hover) · 空态「暂无活跃连接」
节点 —— 组导航 + 节点网格
顶部:代理组切换(SegmentedNav,组多时降级为 Select)+ 测速按钮(行右)
节点网格:节点小卡(名称 + 延迟徽章 + 选中态 primary-soft),grid-cols-2 lg:grid-cols-3
未运行时网格空态「暂无节点」,结构不变
订阅 —— 列表卡 + 导入区(plain 模式)
列表卡:行式(名称 / 更新时间 / 节点数 + 更新、删除),行高 52,行间 1px divider
导入区:URL 输入 + 导入按钮,置于列表卡上方
空态「暂无订阅」+ 主按钮引导导入
设置 —— 单卡 + 卡内标题(峰定稿偏好:设置页标题留在卡片内)
单张设置卡承载全部设置项,卡片占满内容宽(放大居中与概览/连接一致)
输入类双列(md:grid-cols-2)、开关行「左说明右开关」、提示条 primary-soft 底;保存按钮在标题栏
(SettingGroup / SettingRow 保留,用于全局设置页等多分组场景)
4. 组件规范
4.1 业务组件清单(统一从 @/components/common 导入)
以下 14 个组件已建立完成。模块内不得再手写同类结构——这是"UI 不统一"反复复发的根本原因:只要没有现成的替代品,开发者就只能手写。
| # | 组件 | 解决的问题 | 关键 props |
|---|---|---|---|
| 1 | ModuleShell |
页面框架落地件:高度约束链 + 滚动归属 + 内容区 padding/限宽 | layout, padded, maxWidth, tabs |
| 2 | ModuleToolbar |
模块内容区顶部操作条 | slot: left/right |
| 3 | SectionHeader |
区块标题 + 说明 + 右侧操作 | title, description, slot: action |
| 4 | StatCard |
键值卡片(需要视觉分组的独立块) | title, description, slot: action |
| 4a | InfoGroup |
平铺分组(概览页键值快照,密度优先) | title, columns, slot: action / slot: footer |
| 5 | MetricValue |
大数值 + 标签 + 单位 + 方向色 | value, unit, label, trend |
| 6 | KeyValueRow |
键值行(状态/版本/路径) | label, value, slot: value |
| 7 | StatusDot |
状态点 + 文字 | state: success/warning/danger/neutral, label |
| 8 | StatusBadge |
徽章 | variant, size: sm/md |
| 9 | SettingRow |
设置行 | label, description, slot: control |
| 10 | SettingGroup |
设置分组 | title, description |
| 11 | DataListRow |
列表行(主从分栏左栏) | title, subtitle, active, slot: trailing |
| 12 | StateBlock |
加载/空/错误/无权限 | variant, title, description, slot: action |
| 13 | CopyField |
可复制文本(路径、版本号) | value, mono |
| 14 | ConfirmDialog |
破坏性操作确认 | title, description, variant: danger |
4.2 关键组件规格
按钮(基于 shadcn Button,只调 token 与尺寸)
| 变体 | 底色 | 文字 | 描边 | 用途 |
|---|---|---|---|---|
default(主) |
--primary |
--primary-foreground |
无 | 每屏仅 1 个 |
outline(次) |
--card |
--foreground |
--border-strong |
常规操作 |
ghost(幽灵) |
无底 / hover --primary-soft |
--fg-secondary / hover --primary-soft-text |
无 | 工具栏、行内 |
destructive(危险) |
--danger |
白 | 无 | 停止/删除,需确认 |
尺寸:sm 高 28 / default 高 34 / lg 高 40。内边距 px-3.5(default),圆角 radius-md。按下统一 scale(0.97)。
禁止 h-4 px-1.5 text-[10px] 这类微缩按钮(ProxyModule.vue:1768 有此写法)——需要更小的按钮时用 StatusBadge。
交互反馈(hover / active / focus)
| 场景 | hover | active |
|---|---|---|
| 主按钮 | --primary-hover(变深) |
--primary-active |
| 次按钮 / 卡片 | --hover(中性加深) |
--active |
| 幽灵按钮 / 图标按钮 | --primary-soft + --primary-soft-text |
--primary-soft |
| 列表行 / 菜单项 | --hover |
— |
| 选中行 / 选中 Tab | — | --primary-soft |
焦点环全应用统一:ring-2 ring-ring/40。hover ≠ selected:hover 表达"可以点",selected 表达"已经选中",后者才用强调色。
状态点
8px 圆 · border-radius: full · 底色 = 对应 --<state>
与文字间距 6px · 文字用 body 或 label
可选呼吸动效(运行中):opacity 1↔0.55,1.8s 循环,prefers-reduced-motion 下禁用。
徽章
高 20 · 内边距 3px 8px · 圆角 radius-sm
底 --<state>-soft · 文字 --<state>-text · 字号 label(11/500)
卡片
底 --card · 1px --border · 圆角 radius-lg(12) · 内边距 space-4(16)
标题 h2(14/600) · 标题与内容间距 space-3(12)
统一轻阴影 shadow-sm(--shadow-card)—— shadcn Card / InfoGroup card / StatCard 一致
输入 / 下拉 / 分段控件
高度 34 · 圆角 radius-md(8) · 1px --border
输入与下拉态:底 --card
分段控件:外槽底 --subtle,内边距 3,选中项底 --card + 文字 --foreground,未选中项文字 --fg-secondary
聚焦:border 不变,box-shadow: 0 0 0 2px --ring(40% alpha)
4.3 组件用法要点
- 业务组件统一从
@/components/common导入(见 4.1 清单),模块内禁止手写同类结构。 - 页面用
ModuleShell包裹,通过layout(grid / split / workbench / settings / plain)选内容区模式。 - 内容区先按四态组织:先
loading,再empty/error,最后才是数据内容(见 3.6 / M8)。 - 颜色只用 token 类名(
bg-card border border-border),禁止 hex 与原生色阶(见第 5 章 D1–D2)。 - 数值统一
tabular-nums;状态点 / 徽章用StatusDot/StatusBadge,键值用KeyValueRow/InfoGroup。
5. 开发约束(硬性)
5.1 禁止清单
| # | 禁止 | 替代方案 |
|---|---|---|
| D1 | 组件内出现 hex / rgb / oklch 字面量 | 用语义 token 类名 |
| D2 | Tailwind 原生色阶 bg-blue-500 text-emerald-700 border-slate-200 |
用 bg-primary text-success-text border-border |
| D3 | 间距/圆角/尺寸的任意值 p-[18px] rounded-[10px] |
归位 4px 栅格:p-4 rounded-md(非 4 倍数就近归档)。字号例外:正文标准档 text-[11px]~text-[14px] 是项目字号体系(Tailwind 无 13px 预设),允许保留;非标准档(15/13.5/16px 等)归位最近标准档 |
| D4 | 用状态色表达可点击性(绿色按钮 = 主操作) | 主操作一律 --primary |
| D5 | 卡片使用重阴影(shadow-popover / shadow-dialog 或 hover 抬起阴影) | 卡片统一轻阴影 shadow-sm |
| D6 | 模块内手写状态点 / 徽章 / 卡片标题 / 键值行 | 用第 4.1 节组件 |
| D7 | 混用图标库,或用 Unicode 符号(↓ ✓ ● ▸)当图标 |
lucide,尺寸 14/16/20 |
| D8 | 中文加负字距 | letter-spacing: 0 |
| D9 | 生产代码 @import 外部字体 CDN |
系统字体栈 |
| D10 | 数据驱动界面缺四态之一 | StateBlock |
| D11 | 一屏多个主按钮 / 破坏性操作与主操作同权重 | 视觉降级 + ConfirmDialog |
| D12 | 在 .dark 下靠改组件类名做适配 |
只改 token,组件代码不感知主题 |
5.2 必须清单
- M1 新增模块必须使用
ModuleShell,内容区三选一(第 3.2 节) - M2 所有颜色/间距/圆角/字号可追溯到
style.css的 token - M3 深色模式与浅色模式同时验证,不得只测一套
- M4 数值使用
tabular-nums - M5 动效尊重
prefers-reduced-motion - M6 控件最小点击区 28×28(桌面标准,非触摸的 44)
- M7 文字与背景对比度 ≥ 4.5:1;
--muted-foreground不得用于正文,只用于 ≥12px 的辅助信息 - M8 数据驱动的界面结构恒定:无数据时保持完整结构、值用
—占位(横幅、键值行、表格单元同规则);不得用「xx 未运行」之类文案整块替换结构。运行状态由横幅状态区或状态点表达,不在卡片内复述。控件在不可用时禁用,而不是隐藏整块。—与0的区分:—= 数据缺失/不适用(如未运行时的 PID);0= 语义上就是零(如未运行时的连接数、速率)—— 可计数指标显示 0 比—更真实。指标行/横幅必须有容器(卡片),裸指标行浮在画布上与页面不协调。 - M9 动态数值的指标位按数据类型固定最小宽度:速率/流量/计数高频刷新,宽度变化会左右推移兄弟元素。做法同 OSD(
tabular-nums+min-width)。宽度按内容定,不要一刀切——速率min-w-24(容纳999 KB/s)、版本/累计min-w-20、纯计数min-w-12;统一给大值会留白并把同卡操作按钮挤换行 - M8 键盘可聚焦元素必须有可见焦点环(
ring-2 ring-ring/40)
5.3 Code Review 检查点
提交前自查(建议做成 MR 模板 checklist):
grep -rE "(#[0-9a-fA-F]{3,8}|(bg|text|border)-(slate|gray|zinc|blue|emerald|amber|red|rose|orange|sky|purple|violet)-[0-9]{2,3})" src/无新增命中grep -rE "\w-\[[^\]]+\]" src/新增行数可控且能说明理由- 深浅两套主题截图留存
- 加载/空/错误三态至少手动触发过一次
- 破坏性操作有确认
建议自动化:加入 ESLint 规则,直接拦截 D1–D3。
// eslint 思路示例:禁止特定 class 模式
{
files: ['**/*.vue'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/(bg|text|border)-(slate|gray|zinc|blue|emerald|amber|red|rose|orange|sky|purple|violet)-\\d{3}/]',
message: '禁止使用 Tailwind 原生色阶,请使用语义 token(见 UI_DESIGN_SYSTEM.md 第 5.1 节 D2)'
}
]
}
}
6. 工具与状态
6.1 组件预览页(改 token 后的回归工具)
路径:preview/ —— 不在生产构建入口内,只在 bun dev 时可访问。
bun dev
# 浏览器打开 http://localhost:14210/preview/index.html
用途:改完 src/style.css 的 token 后,把全部基础组件与业务组件过一遍,确认浅色 / 深色两套都正确 —— 替代"改完 token 靠记忆推断影响面"。本页不依赖任何 Tauri API,纯浏览器即可运行。
查询参数:
| 参数 | 作用 | 示例 |
|---|---|---|
theme=dark |
以深色模式启动 | ?theme=dark |
only=<id> |
只渲染指定分区,便于放大核对 | ?only=data |
已内置 id 的分区:forms、data、shell。需要放大看其他分区时,按同样方式给 <section> 加 id 即可。
实现注意:
only的隔离 CSS 只能用显式标记([data-block]/[data-page-header]),不能用section/header这类标签选择器——StatCard内部也用<section>和<header>,会被一起隐藏,产生"组件没渲染"的假象。
6.2 当前状态
- 业务组件层(第 4.1 节 14 个组件)与 Token 层已落地,
preview/预览页已完成浅色/深色视觉回归。 - 色阶治理:Tailwind 原生色阶从约 155 处降至 3 处(proxy / terminal / monitor / translate / music 的色阶与 Tabs 已完成)。
- proxy 概览页定稿为「状态横幅 + 两列瀑布流」,InfoGroup 新增
variant="card"。 - 状态横幅统一(2026-09-21):proxy 概览 / proxy 连接 / 下载任务 / 硬件监控概览四处横幅统一为 §3.4 的三段式(状态区 → 指标区 → 操作区)+
Card容器;监控与下载的旧样式(text-xs内联键值、Badge 计数、xs按钮)已废弃。 - 主题色自定义落地(2026-09-21,见 §2.9):
src/lib/theme.ts+appStore.setAccent()+ 设置页「主题色」卡片(10 预设 + 自定义),深浅两套自动派生、对比度自动兜底;组件层 0 改动。 - 终端模块非终端页对齐(2026-09-21,见 TERMINAL_MODULE_PLAN.md §5):根 padding 归类、列表 Card 化、表单走
@/components/ui原语、四态/徽章走@/components/common、设置页用SettingGroup/SettingRow(此后二者与StateBlock/StatusBadge有真实消费者);终端画布、状态栏、h-11契约、xterm 链保持不动。 - 剩余项:
OsdWindow免改(功能性配置,非 UI token 问题)DownloadWindow/ClipboardPopup仍有少量 hex,待统一- ESLint 约束规则(第 5.3 节)尚未搭建
6.3 待决策
| # | 问题 | 状态 | 结论 / 影响 |
|---|---|---|---|
| 1 | 数据方向色是否与状态色解耦 | 已定(暂保留现有取值) | 保留"下载=绿、上传=红",已独立成 --data-* token,要换色只改这 2 个值 |
| 2 | 强调色用 #0066CC |
✅ 已采用 | 落地;同时解决 shadcn primary 近黑导致按钮发灰 |
| 3 | 浮窗(OSD/截图/下载)是否纳入统一体系 | 部分已定 | OsdWindow 免改(功能性配置);DownloadWindow/ClipboardPopup 少量 hex 待统一;不强行并入主界面 token |
| 4 | --font-mono 是否用网络字体 |
✅ 已定 | 系统 Cascadia Code / Consolas,不引外部字体 |
| 5 | 侧边栏宽度 60px | ✅ 已定 | 沿用现有实现 |
| 6 | 内容区限宽 | ✅ 已定 | 改为不限宽占满窗口宽度(原 max-w-6xl 限制已移除) |
| 7 | 卡片阴影 | ✅ 已定 | 卡片统一轻阴影 shadow-sm(shadcn Card / InfoGroup card / StatCard 一致),浮层仍用 shadow-popover/shadow-dialog |
维护约定:Token 值以 src/style.css 为唯一事实来源,本文档与之同步;两者冲突时以本文档为准并修正代码。新增 token 或组件须同步更新第 2、4 章。