迁移 — VoiceStudio ui/ 基础组件 → shadcn/ui(Tailwind v4)¶
状态: 基础部分已落地 · P1 表单基础组件已落地(由 shadcn 支持的 Input/Select/Textarea/Slider;Table 基础已添加) · 起草日期: 2026-06-30 · 类型: 渐进式组件库采用,无预期视觉变化 负责人立场: 希望在不重新换肤应用的前提下,获得干净、常规的组件基础(shadcn) · 本计划建议: 在现有 prop API 之后采用 shadcn 基础组件,通过 token 桥按 VoiceStudio 调色板设置主题;分批迁移;绝不一次性大爆炸式替换。参见 §6。
为什么¶
VoiceStudio 的 src/ui/ 基础组件(Button.jsx、Input.jsx、Badge、Panel、Tabs、……)是手工编写且忠实于 token 的,但每个组件都用一长串任意属性的 Tailwind 字符串重新编码了变体逻辑、焦点环和禁用状态(参见 ui/Button.jsx 中的 [transition:…]/[box-shadow:…] 代码块)。shadcn/ui 是事实上的 React 基础组件惯例:cva 变体映射、cn() 合并辅助函数、Radix 行为基础组件(已经是依赖项),以及 npx shadcn add 可扩展的扁平 components/ui/* 目录结构。采用它,我们就能获得一个有维护、文档完善的基础,并且让贡献者可以直接粘贴“开箱即用”的标准 shadcn 代码片段。
采用 shadcn 的风险在于,它自带一套灰度(neutral)调色板。直接引入原版 shadcn 会把应用重新涂成灰色,并破坏主题切换。这个基础部分通过 token 桥(§2)解决了这个问题,使 shadcn 组件继承现有的 VoiceStudio 外观——默认 Gruvbox 粉色以及每一个 [data-theme]——而无需逐个组件重新设置样式。
这不是重新设计。契约与 CSS→Tailwind 迁移(docs/css-to-tailwind-migration.md)相同:每一步都与当前调色板渲染一致,而视觉回归测试框架(src/test/visual/)是证明这一点的门禁。
本基础 PR 中已落地的内容¶
- 面向 Tailwind v4 + Vite + React 19 的 shadcn init。
frontend/components.json(样式new-york、rsc:false、tsx:true、基础颜色neutral、css-vars 开启)、src/lib/utils.ts(cn()=clsx+tailwind-merge),以及vite.config.js+tsconfig.json中的@/*→src/*路径别名,使@/lib/utils和未来的npx shadcn add都能正确解析。 src/index.css中的 token 桥(§2)。- 两个验证组件 —
src/components/ui/button.tsx、src/components/ui/input.tsx(逐字复制的 shadcn new-york 样式,class 字符串未修改)— 在视觉回归框架中渲染到默认 / midnight / catppuccin 主题,并带有已提交的基线。 - 新增依赖:
class-variance-authority、clsx、tailwind-merge、tw-animate-css、@radix-ui/react-slot(根bun.lock已重新生成;bun install --frozen-lockfile已确认与 Docker 构建同步)。
没有修改或替换任何现有组件。shadcn 基础组件尚未接入应用——它们只是作为下述各批次迁移的已验证基础而存在。
2. Token 桥(P0 — 决定一切)¶
shadcn 组件引用一套固定的语义词汇(bg-background、bg-primary、bg-card、text-muted-foreground、border-input、ring-ring、bg-destructive、……)。这些工具类只有在 Tailwind 的主题定义了 --color-background、--color-primary 等变量时才存在。VoiceStudio 的 @theme 块则定义的是 --color-bg、--color-brand、--color-danger、……。这套桥将前者映射到后者。
它位于 index.css 中,是一个单独的 @theme inline 块。inline 是关键所在:它使每个生成的工具类输出 … { background-color: var(--color-bg) }(一个实时引用),而不是固化静态值,因此运行时的 [data-theme] 覆盖可以顺利生效。
映射表¶
| shadcn token(Tailwind key) | ← VoiceStudio token | 备注 |
|---|---|---|
--color-background |
--color-bg |
应用外壳背景 |
--color-foreground |
--color-fg |
主文本 |
--color-card / --color-popover |
--color-bg-elev-1 |
抬升表面 |
--color-card-foreground / --color-popover-foreground |
--color-fg |
表面上的文本 |
--color-primary |
--color-brand |
品牌粉(随主题变化) |
--color-primary-foreground |
--color-fg-inverse |
品牌填充上的深色文本(与现有 primary Button 一致) |
--color-secondary / --color-muted |
--color-bg-elev-2 |
轻微填充 |
--color-secondary-foreground |
--color-fg |
— |
--color-muted-foreground |
--color-fg-muted |
弱化/占位文本 |
--color-accent |
(复用现有 --color-accent) |
已在基础 @theme 中(琥珀色)—— bg-accent 可直接使用,无需重新输出 |
--color-accent-foreground |
--color-fg-inverse |
强调填充上的深色文本 |
--color-destructive |
--color-danger |
错误/破坏性红色 |
--color-destructive-foreground |
--color-fg-inverse |
— |
--color-border |
(复用现有 --color-border) |
已在基础 @theme 中 — border-border 可直接使用,无需重新输出 |
--color-input |
--color-border |
输入框轮廓 |
--color-ring |
--color-brand |
焦点环 |
--radius |
--radius-lg(6px) |
shadcn 基础圆角;--radius-* 刻度本身保持不变,因此 rounded-md 仍保持 VoiceStudio 的 4px |
为什么无需修改 themes.css,主题切换仍能正常工作。 每个被桥接的工具类都解析为 VoiceStudio 的 --color-* token,而 ui/themes.css 已经按每个 [data-theme] 重新声明了这些 token。因此切换到 midnight 会把 --color-brand 变为紫色,所有的 shadcn bg-primary 也会自动跟随。已在测试框架中验证:同一个 button.tsx 渲染出品牌粉(默认)、紫色(midnight)和薰衣草色(catppuccin),并带有正确主题背景和破坏性红色。无需为每个主题维护单独的 shadcn token 块——themes.css 中的一条注释记录了这一点。
为什么现有 token 是安全的。 accent 和 border 已存在于基础 @theme 中;在桥中重新输出它们会变成自引用(--color-accent: var(--color-accent))——往好里说是空操作,往坏里说是循环引用——因此它们被有意省略并原样复用。--radius-* 刻度未被改动,因此所有现有的 rounded-sm/md/lg/xl 使用者都不会改变。
3. shadcn 组件的位置与别名¶
| 关注点 | 决策 |
|---|---|
| 位置 | src/components/ui/*.tsx(shadcn 约定)——与现有 src/ui/*.jsx 原语区分开,因此迁移期间两者共存,不会产生命名冲突 |
components 别名 |
@/components |
ui 别名 |
@/components/ui |
utils 别名 |
@/lib/utils |
lib / hooks |
@/lib / @/hooks |
| 路径解析 | 在 vite.config.js(resolve.alias)和 tsconfig.json(paths)中均为 @/* → src/* |
| 语言 | .tsx(仓库混合使用 JS/JSX;新的 shadcn 文件采用 TS,以匹配 shadcn 输出并获得 props 类型) |
4. 原语 → shadcn 映射与 props 兼容策略¶
现有 ui/* 原语在应用各处都有调用点。迁移不得改动这些调用点。策略:保留现有 props API,替换实现。 每个 ui/*.jsx 都变成一层薄包装,将其当前 props 映射到 shadcn 组件上,因此消费者(<Button variant="subtle" size="sm">、<Input size="md">)可以原样继续工作。
现有 ui/ 原语 |
shadcn 目标 | Props 桥接(现有 → shadcn) |
|---|---|---|
Button.jsx(primary/subtle/ghost/danger/chip/preset/icon,size sm/md,loading,block,leading/trailing) |
components/ui/button.tsx |
primary→default,subtle→outline,ghost→ghost,danger→destructive;chip/preset/icon 保留为 VoiceStudio 专属变体并加入 cva 映射;size md→default;loading(加载指示器 + disable)、block(w-full)、leading/trailing(插槽子元素)在 JS 层包装 |
Input.jsx Input |
components/ui/input.tsx |
size sm/md/lg → 扩展 shadcn cva(shadcn 默认只有一种尺寸)或映射到 padding 类;aria-invalid 已共用 |
Input.jsx Textarea |
npx shadcn add textarea |
同样的 size 桥接 |
Input.jsx Select |
决定:保留原生 + ui-select 箭头,套用 shadcn 外壳(inputBaseClass)。Radix select.tsx 已添加到 components/ui/ 供 新的 调用点使用,但该原语仍保持原生,因为 DubSegmentTable/CompareModal/GeneralTab 依赖 onChange={(e) => …e.target.value},而 Radix 仅传值的 onValueChange 会破坏这一用法 |
|
Input.jsx Field |
保留为围绕 shadcn label + 控件的组合包装 |
|
Badge.jsx |
npx shadcn add badge |
tone → variant 映射 |
Tabs.jsx |
npx shadcn add tabs(Radix;已是依赖项) |
items/value/onChange → 受控 Tabs |
Progress.jsx |
npx shadcn add progress(Radix;已是依赖项) |
保留 tone/size/shimmer props |
Slider.jsx |
npx shadcn add slider(Radix;已是依赖项) |
保留 value/onChange/label/showValue |
Panel.jsx |
npx shadcn add card |
glass 变体保留其 .css 残留(按 CSS→Tailwind 计划) |
Segmented.jsx |
npx shadcn add toggle-group(Radix;已是依赖项) |
保留 items/value |
shadcn 无法 1:1 覆盖的内容(chip/preset/icon Button 变体、带数值气泡的 Slider、glass Panel)会加入 shadcn 组件的 cva/标记中,而不是被遗弃——包装层正是 VoiceStudio 专属行为所在之处。
5. 分阶段推进(分波次实施)¶
每个 wave(波次)=一个或多个可独立发布、CI 全绿的 PR。顺序安排为:只有在信心增长时,影响范围才随之扩大。
P0 — 基础(本 PR)¶
初始化 + token 桥接 + cn() + 2 个验证组件 + 基线 + 本文档。未改动任何应用组件。已完成。
P1 — 原语(在现有 API 背后替换实现)¶
将 ui/Button.jsx 和 ui/Input.jsx 转换为 components/ui/button.tsx / input.tsx 之上的薄包装,并把 VoiceStudio 专属变体移植到 shadcn cva 中。为每个原语添加一对 基线-PR → 转换-PR(与 CSS→Tailwind 计划 §4 相同的做法)。然后逐个处理 Badge、Tabs、Progress、Slider、Segmented、Panel。
- 成功标准: 每次替换后,可视化测试台都能在容差范围内显示现有组件规格(Button、Input、……)保持不变;调用点不受影响。
已落地(表单/数据类原语)。 ui/Input.jsx(Input/Textarea/Select/Field)和 ui/Slider.jsx 现已包装 shadcn 组件,导出 + props API 不变:
- input.tsx 导出 inputBaseClass(即外壳,行为无变化——ShadcnInput 基线逐字节一致);新增 textarea.tsx、select.tsx(+@radix-ui/react-select)、slider.tsx、table.tsx,已加入 components/ui/。
- Input/Textarea 渲染 shadcn 组件;一个小的 fieldSizeVariants cva(命名调色板工具类,tailwind-merge 无冲突)在外壳之上恢复了 VoiceStudio 基于 padding 的 sm/md/lg 尺寸体系 + 填充的 bg-bg-elev-2。
- Select 保持原生(见 §4);Slider 在 shadcn Slider 外层保留基于数字的 onChange + 标签/数值气泡装饰,并通过 data-slot 的 track/thumb 选择器进行调优。
- Table 刻意不重新路由。 ui/Table.jsx 是一个 flex-<div> 外观包装器,其 .ui-table*/.segment-table 全局类(Table.css)是共享契约,由 ModelsTable / DubSegmentTable / EngineCompatibilityMatrix 直接使用(这些是虚拟化的 react-window 列表,需要 div/flex 布局,而非语义化 <table>)。shadcn table.tsx 仅为新表格数据提供;Table.jsx 及其全局类保持不变。其工具栏自动继承了基于 shadcn 的 Input/Button。
- 已验证: 只有 3 个 Input-* 基线发生了变化(调色板在 default/midnight/catppuccin 之间保持一致);Slider/Table 在容差范围内。vitest 641 项全绿;oxlint 0 错误;oxfmt --check 无差异;vite build 全绿;bun install --frozen-lockfile 保持同步。
P2 — 使用层(在更清晰的场景直接采用 shadcn)¶
新 UI 直接使用 @/components/ui/*。高频界面(设置标签页、对话框)会从包装器迁往原生 shadcn,在这些场景中 props 桥接没有额外价值。执行 npx shadcn add dialog/dropdown-menu/tooltip 来替换手写包装的 Radix 用法(这些需要 tw-animate-css,已导入)。
P3 — 删除 CSS 并精简 index.css¶
随着基础组件迁移到 shadcn,移除 Button.css/Input.css 的残留样式,并归并任何剩余的 shadcn 共享 token。裁掉 index.css 中现已由 shadcn 组件负责的全局样式。这与 CSS→Tailwind 的 P4 自然搭配。
6. 风险与工作量(坦诚评估)¶
- 最大风险 —— 变体保真度。 VoiceStudio 的 Button 有 7 种变体及定制的 focus/disabled 处理;shadcn 自带 6 种且尺寸不同。包装器方案能控制住这个风险(能映射的做映射,其余移植进
cva),但 P1 Button 是最难的单一步骤,应在经过人工过目的基线 diff 保护下上线。缓解措施: 视觉测试工具台已经对Button/Input在 3 种主题下做了快照;任何产生偏差的替换都会在门禁处失败。 tw-animate-css目前尚未使用。 它是为后续 Dialog/Dropdown 波次引入的;Button/Input 并不需要。风险较低(纯增量工具类 + keyframes),但它属于“还没用就先带着”的依赖。作为基础 PR 可以接受;如果 P2 延期,再重新评估。- knip 会将这 3 个新文件标记为未使用。 这在意料之中——它们只是被视觉测试工具台引用的验证组件,而 knip 不将其视为生产入口。这里的 knip 并非 CI 门禁,所以这只是提示信息;一旦 P1 接入包装器,告警就会消失。
@/*别名是全局的。 这是增量且标准的配置;现有相对导入不受影响。已通过typecheck:ci、Vite 构建和 Docker 冻结 lockfile 安装验证。- 工作量: P1 每个基础组件约 1 天(基线 + 替换 + 验证);完整基础组件集约 1–1.5 周。P2/P3 并入 CSS→Tailwind 的时间线。
建议。 通过包装器将 shadcn 作为基础组件底座,经由桥接层完成主题化,并按照上述波次顺序迁移——绝不整体替换。唯一最重要的护栏是视觉基线:在三种测试工具主题下没有前后快照,就不要替换某个基础组件的实现。
已遵守的约束¶
- 保持 main 绿色 —— 每个波次都是独立的 CI 全绿 PR。该基础通过
vite build、typecheck:ci、oxlint(0 错误)、oxfmt --check、vitest(641 项测试)、完整的test:visual套件(48 项)以及bun install --frozen-lockfile。 - 文档同步 —— 本文档与基础 PR 同时落地;当 P1 将 shadcn 设为默认基础组件时,
CONTRIBUTING.md的组件编写指南会同步更新(VoiceStudio 当前发布的文档中没有任何一份描述 必需的基础组件来源,因此 P0 不会产生过期文档)。 - 不影响版本 / Docker / Tauri / Python —— 仅前端改动;新增运行时依赖时重新生成根
bun.lock,并验证 Docker 的 frozen-lockfile 路径;不提升应用版本(package.json的 version 保持不变)。 - 本地优先 / 跨平台一致性 —— 纯样式 + 表现层组件;无行为改动、无平台差异、无网络依赖。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio