Files
Thing/UI_DESIGN_SYSTEM.md
T
2026-09-22 19:10:16 +08:00

42 KiB
Raw Blame History

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-500text-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-updanger 同色,会让人把"上传速率"误读为"异常"。若希望彻底解耦,建议改为 --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 徽章、标签、表头、单位

规则

  1. 字号只从以上 9 级中取,禁止 text-[13.5px] 这类任意值
  2. 数值(速率、版本号、计数)统一用 tabular-nums,避免刷新时数字跳动。
  3. 中文 letter-spacing: 0;拉丁小标题可 -0.1px,正文一律 0
  4. 行高按表中固定值写,不用 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-cardshadow-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 切换 */

规则

  1. 只对 opacitytransform 做动画(性能)。
  2. 按钮按下统一 transform: scale(0.97),不用颜色变化模拟按压。
  3. 所有动画必须包在 @media (prefers-reduced-motion: reduce) 里降级为 none(现有 .music-eq 已正确实现,作为模板)。
  4. Tab 切换复用现有全局 .tab-animate 类,不要各模块自建过渡

2.8 图标

  • 库:@lucide/vue禁止混用其他图标库或内联 Unicode 符号 等)。
  • 尺寸只有 3 档:14(行内 / 徽章内)、16(按钮、Tab、默认)、20(侧边栏、卡片标题)。
  • stroke-width 统一 1.75lucide 默认 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.420.58(浅)/ 0.680.80(深),C 限制在 0.060.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 填充上的文字

深色模式单独派生一套(深底上要提亮),不能复用浅色值。

关键技术点

  1. 用 OKLCH 做派生,不要用 HSL。 只换色相 H、锁定 L 与 C,才能保证换任意色相后对比度一致。HSL 的"明度"感知不均匀,换色相会时亮时暗。

  2. 对比度靠"推进亮度"而不是固定阈值判定。 同一 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 模式全量校验。
  3. --primary-foreground 的取舍是有意的。 深色模式下强调色必须够亮才能在深底上当文字用(≥4.5:1),同一颜色做填充时白字只能到 2.7–3:1 —— 现有主题就是这个取舍(#2997ff + 白字 ≈ 2.98:1)。因此门槛按 UI 组件的 2.5:1 兜底, 只在自定义极亮色时才改深墨。

  4. 只开放"强调色",不开放状态色。 success / warning / danger 是语义色,改了会破坏"绿色=成功"的心智模型。--data-* 同理。 --info 一族当前取值与强调色相同(蓝),但不跟随主题色 —— 它表达"信息"而非"可交互" 换紫/绿主色后少数徽标(代理"国外"、HTTP 1xx/3xx、快速面板分类)仍是蓝色,属有意的语义区分。

  5. 预设 + 自定义。 10 个预设(默认蓝 / 靛蓝 / 紫罗兰 / 玫红 / 橙 / 琥珀 / 青柠 / 翠绿 / 青绿 / 石墨)+ 原生取色器。 纯自由取色很容易选出对比度不达标的颜色,所以色板优先。 色板显示色用派生后的浅色模式主色(所见即所得,如青柠显示为压暗后的绿)。

  6. 实时预览 + 可回滚。 点击色块 / 拖动取色器立即生效(写 7 个变量,无防抖);「恢复默认」清除行内变量回到内置蓝。

  7. 持久化:随其它设置写入 thing_app_settingsaccentId + accentHex)。 属纯增量字段SETTINGS_VERSION 不递增 —— 递增会命中"版本不匹配清空"分支, 把用户的模块排序/启停/开机自启一并抹掉,为零收益付真实数据损失。

  8. 行内变量的优先级陷阱。 变量写在 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: falseappStore.init()show() 前完成应用; 文档原建议的 Rust initialization_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 行,纵向空间最大化。

三个必须遵守的点(都是实际踩过的坑,不是理论):

  1. min-h-0 不能漏。 flex 子项默认 min-height: auto —— 内容变高时它不收缩,直接把父容器顶破,底部 padding 被推出视口。这就是"卡片贴着应用底部"的根因。

  2. padding 加在滚动容器内部的 wrapper 上。 加在滚动容器自身,底部 padding 会被内容高度覆盖,滚动到底依然贴边。

  3. 断点按内容宽度选,不按视口宽度。 侧边栏固定占 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 左栏 280320px·右栏 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,操作区 xssm,停止按钮改 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(结构恒定 + 占位):

统一原则(四页共用)

  1. 每页从 3.4 的模式里选一种承载,不自创
  2. 统计类信息统一用「指标行」形态(与横幅指标区同构:label 11 + 数值 18/600),容器口径同横幅 —— 用 Card 承载,不做裸指标行(M8
  3. 表格/列表统一用卡片承载,长表头 sticky
  4. 行内操作集中在行尾,用 icon-sm28px),禁微缩按钮

连接 —— 指标行 + 表格卡(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.5default),圆角 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 ≠ selectedhover 表达"可以点"selected 表达"已经选中",后者才用强调色。

状态点

8px 圆 · border-radius: full · 底色 = 对应 --<state>
与文字间距 6px · 文字用 body 或 label

可选呼吸动效(运行中):opacity 1↔0.551.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 包裹,通过 layoutgrid / 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):

  1. 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/ 无新增命中
  2. grep -rE "\w-\[[^\]]+\]" src/ 新增行数可控且能说明理由
  3. 深浅两套主题截图留存
  4. 加载/空/错误三态至少手动触发过一次
  5. 破坏性操作有确认

建议自动化:加入 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 的分区:formsdatashell。需要放大看其他分区时,按同样方式给 <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-smshadcn Card / InfoGroup card / StatCard 一致),浮层仍用 shadow-popover/shadow-dialog

维护约定Token 值以 src/style.css 为唯一事实来源,本文档与之同步;两者冲突时以本文档为准并修正代码。新增 token 或组件须同步更新第 2、4 章。