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

757 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```css
: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` 在深底上对比度不足):
```css
.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` 已正确针对中文优化):
```css
--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-card``shadow-sm`);`--shadow-popover` / `--shadow-dialog` 仅用于浮层与模态。
### 2.7 动效
```css
--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. 只对 `opacity``transform` 做动画(性能)。
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.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.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_settings``accentId` + `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: false``appStore.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`,操作区 `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 纵向空间,却只承载一个切换器。并入标题栏后,内容区最大化。
**实现**
```ts
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-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 ≠ 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` 包裹,通过 `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):
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。
```js
// 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` 时可访问。
```bash
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 章。