跳转至

迁移 — 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