AI 电子伴侣
创建时间: 2026-04-24最后更新: 2026-08-04

1. 概述

shadcn/ui 不是一个装完就直接用的完整组件库,它更像一套「可复制、可改造、可放进自己代码库」的组件源码方案。

它的价值不在于帮你藏住实现细节,而在于把实现权交还给你:样式是 Tailwind,交互底座是 Radix,组件源码本身就在项目里,后面要改结构、改 class、改交互都可以直接改。

放到这个 monorepo 里,它特别合适。

因为我们已经有了 Tailwind v4 + @repo/ui 这条共享组件链路,这时候再引入 shadcn/ui,目标就不该是让 webadmin 各自再生成一份按钮和表单组件,而是把首批基础组件直接沉淀到 packages/ui,让两个子站一起复用。

这样做有三个好处:

  • 组件 API 统一,两个子站不会各写各的
  • 共享包继续掌握源码,后面要改样式和交互不用绕远路
  • 可以基于共享包的 tailwindcss 顺手验证 cvacn、Radix 基础依赖

因此,我们先按下面这个目标推进:

prompt.md
1
在现有 Tailwind v4 + @repo/ui 共享包方案之上,引入第一批 shadcn/ui 基础组件,并在 apps/web 与 apps/admin 中实际使用这些组件完成验证。
2
3
本次首批组件:
4
- Button
5
- Card
6
- Input
7
- Label
8
- Separator

2. 接入边界

这里继续把 packages/ui 当成唯一组件来源,不在 apps/webapps/admin 各自生成一套 shadcn 组件,确保两个子站使用的是同一个组件库。

也就是说,应用侧继续保持这种导入方式:

index.tsx
1
import { Button } from '@repo/ui/button'
2
import { Card } from '@repo/ui/card'

这条规则有两个直接作用。

一是避免组件源码散落到多个子站,后面改一个按钮要追三份代码。二是保持共享包出口稳定,先用最小改动把首批组件接起来,暂时不额外引入更深的目录分层和导出规则。

继续沿用 packages/ui/src/theme.css 里已有的 token、@theme 和 Tailwind 扫描方式,先把基础组件接入、API 统一和跨应用验证做完。

3. 补充 workspace 依赖

shadcn 风格组件真正依赖的不多,核心依赖就是三类东西:

  • class-variance-authority 负责变体管理
  • clsx + tailwind-merge 负责类名合并
  • Radix 的轻量 primitives 提供 SlotLabelSeparator 这些底座

因为这个仓库已经在根目录用 catalog 管理共享版本,所以这里继续保持一致,把公共依赖收进 pnpm-workspace.yaml

pnpm-workspace.yaml
1
catalog:
2
class-variance-authority: ^0.7.1
3
clsx: ^2.1.1
4
tailwind-merge: ^3.3.1
5
"@radix-ui/react-slot": ^1.2.3
6
"@radix-ui/react-label": ^2.1.7
7
"@radix-ui/react-separator": ^1.1.7

然后在 packages/ui/package.json 里把这些依赖放进 dependencies

packages/ui/package.json
01
{
02
"dependencies": {
03
"@radix-ui/react-label": "catalog:",
04
"@radix-ui/react-separator": "catalog:",
05
"@radix-ui/react-slot": "catalog:",
06
"class-variance-authority": "catalog:",
07
"clsx": "catalog:",
08
"react": "catalog:",
09
"react-dom": "catalog:",
10
"tailwind-merge": "catalog:"
11
}
12
}

4. 在共享包里补 cn 工具

shadcn 风格组件几乎都会复用一个 cn 工具,所以先在共享包里把它补上。

文件位置放在 packages/ui/src/lib/utils.ts

packages/ui/src/lib/utils.ts
1
import { type ClassValue, clsx } from 'clsx'
2
import { twMerge } from 'tailwind-merge'
3
4
export function cn(...inputs: ClassValue[]) {
5
return twMerge(clsx(inputs))
6
}

这里有两个细节。

第一,组件内部统一走相对路径导入。

这一步先不对外暴露 @repo/ui/lib/*,避免还没稳定就把内部工具路径变成公共 API。

第二,clsxtailwind-merge 要一起用。

单独用 clsx 只能解决条件拼接,处理不了 Tailwind 冲突类合并;加上 twMerge 之后,像 px-4 px-6rounded-xl rounded-2xl 这类冲突才能按预期收敛。

5. 先升级 Button 和 Card

优先复用共享包里已有文件,直接原位升级。

5.1 Button

packages/ui/src/button.tsx 之前只是一个非常简单的演示按钮,这一轮把它改成 shadcn 风格的 Button。

核心点有三个:

  • cva 定义 variantsize
  • Slot 支持 asChild
  • 保持导出路径不变,应用侧导入方式不用改
packages/ui/src/button.tsx
01
import * as React from 'react'
02
import { Slot } from '@radix-ui/react-slot'
03
import { cva, type VariantProps } from 'class-variance-authority'
04
import { cn } from './lib/utils'
05
06
const buttonVariants = cva(
07
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950',
08
{
09
variants: {
10
variant: {
11
default: 'bg-brand-500 text-white hover:bg-brand-600',
12
secondary: 'bg-white/10 text-white hover:bg-white/15',
13
outline: 'border border-white/15 bg-transparent text-slate-100 hover:bg-white/10',
14
ghost: 'text-slate-100 hover:bg-white/10',
15
},
16
size: {
17
default: 'h-11 px-5',
18
sm: 'h-9 px-4 text-xs',
19
lg: 'h-12 px-6 text-base',
20
icon: 'size-10 rounded-full',
21
},
22
},
23
defaultVariants: {
24
variant: 'default',
25
size: 'default',
26
},
27
}
28
)
29
30
type ButtonProps = React.ComponentProps<'button'> &
31
VariantProps<typeof buttonVariants> & {
32
asChild?: boolean
33
}
34
35
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
36
const Comp = asChild ? Slot : 'button'
37
38
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />
39
}
40
41
export { Button, buttonVariants }

这里的 asChild 很关键。

后面如果要让按钮直接包住 aLink 或别的组件,就不用额外再写一层 LinkButton。在共享组件里先把这个能力补齐,后面会很省事。

5.2 Card

Card 也不再保留原来那种单文件、单组件、强绑定 titlehref 的结构,而是改成一组更标准的组合式子组件:

  • Card
  • CardHeader
  • CardTitle
  • CardDescription
  • CardContent
  • CardFooter
packages/ui/src/card.tsx
01
import * as React from 'react'
02
import { cn } from './lib/utils'
03
04
function Card({ className, ...props }: React.ComponentProps<'div'>) {
05
return (
06
<div
07
className={cn(
08
'rounded-[2rem] border border-white/10 bg-slate-950/45 text-slate-50 shadow-card backdrop-blur',
09
className
10
)}
11
{...props}
12
/>
13
)
14
}
15
16
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
17
return <div className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
18
}
19
20
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
21
return <div className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
22
}
23
24
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
25
return <div className={cn('text-sm leading-6 text-slate-300', className)} {...props} />
26
}
27
28
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
29
return <div className={cn('px-6 pb-6', className)} {...props} />
30
}
31
32
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
33
return <div className={cn('flex items-center px-6 pb-6', className)} {...props} />
34
}
35
36
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

这种结构更适合共享包。

因为它不再替你预设内容结构,应用侧可以自由拼装:表单卡片、信息卡片、操作卡片都能沿用同一套基础组件。

6. 再补 Input、Label、Separator

首批组件里剩下三个都比较直接,但验证意义很强。

  • Input 验证表单类组件的共享样式
  • Label 验证 Radix primitive 与语义绑定
  • Separator 验证基础结构组件和横向视觉分隔
packages/ui/src/input.tsx
01
import * as React from 'react'
02
import { cn } from './lib/utils'
03
04
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
05
return (
06
<input
07
type={type}
08
data-slot="input"
09
className={cn(
10
'flex h-11 w-full rounded-2xl border border-white/12 bg-white/5 px-4 py-2 text-sm text-slate-50 outline-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-brand-500/60 focus-visible:ring-2 focus-visible:ring-brand-500/40',
11
className
12
)}
13
{...props}
14
/>
15
)
16
}
17
18
export { Input }
packages/ui/src/label.tsx
01
import * as React from 'react'
02
import * as LabelPrimitive from '@radix-ui/react-label'
03
import { cn } from './lib/utils'
04
05
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
06
return (
07
<LabelPrimitive.Root
08
className={cn('text-sm font-medium leading-none text-slate-200', className)}
09
{...props}
10
/>
11
)
12
}
13
14
export { Label }
packages/ui/src/separator.tsx
01
import * as React from 'react'
02
import * as SeparatorPrimitive from '@radix-ui/react-separator'
03
import { cn } from './lib/utils'
04
05
function Separator({
06
className,
07
orientation = 'horizontal',
08
decorative = true,
09
...props
10
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
11
return (
12
<SeparatorPrimitive.Root
13
decorative={decorative}
14
orientation={orientation}
15
className={cn(
16
'shrink-0 bg-white/10',
17
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
18
className
19
)}
20
{...props}
21
/>
22
)
23
}
24
25
export { Separator }

这里最值得关注的是 Label 的验证价值。

只要页面里 htmlForid 对得上,点 Label 能聚焦对应的 Input,就说明共享组件除了样式正常,基础语义和交互关系也没丢。

7. 复用共享演示入口做第一轮验证

为了验证两个子站是否能够正确消费这批新组件,最省事的方式就是直接修改现有的 packages/ui/src/tailwind-demo.tsx

因为 webadmin 已经在首页接了这个演示组件,所以一旦这里改成新组件,两个子站就会自动开始帮我们验证共享包编译、Tailwind 扫描和导入导出是否都正常。

packages/ui/src/tailwind-demo.tsx
01
import { Button } from './button'
02
import {
03
Card,
04
CardContent,
05
CardDescription,
06
CardFooter,
07
CardHeader,
08
CardTitle,
09
} from './card'
10
import { Input } from './input'
11
import { Label } from './label'
12
import { Separator } from './separator'
13
14
type TailwindDemoProps = {
15
appName: string
16
}
17
18
const features = [
19
'Shared Tailwind utilities',
20
'Shadcn-style primitives',
21
'Rendered from @repo/ui',
22
]
23
24
export function TailwindDemo({ appName }: TailwindDemoProps) {
25
return (
26
<Card className="w-full max-w-5xl overflow-hidden bg-[linear-gradient(135deg,rgba(79,124,255,0.18),rgba(15,23,42,0.92))]">
27
<CardHeader className="gap-4 md:flex-row md:items-start md:justify-between">
28
<div className="space-y-3">
29
<span className="inline-flex w-fit items-center rounded-full border border-brand-500/20 bg-brand-50 px-3 py-1 text-xs font-semibold tracking-[0.24em] text-brand-700 uppercase">
30
shared ui package
31
</span>
32
<div className="space-y-2">
33
<CardTitle className="text-2xl text-white md:text-3xl">
34
Tailwind and shadcn primitives are active in {appName}
35
</CardTitle>
36
<CardDescription className="max-w-2xl text-slate-200">
37
This section is rendered from <code className="rounded bg-white/10 px-2 py-1 text-slate-100">packages/ui</code> and now uses reusable Button, Card, Input, Label, and Separator components.
38
</CardDescription>
39
</div>
40
</div>
41
42
<div className="grid gap-3 sm:grid-cols-3">
43
{features.map((feature) => (
44
<div
45
key={feature}
46
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-slate-100 shadow-sm shadow-black/10"
47
>
48
{feature}
49
</div>
50
))}
51
</div>
52
</CardHeader>
53
54
<CardContent className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
55
<div className="space-y-4 rounded-[1.5rem] border border-white/10 bg-slate-950/35 p-5">
56
<div className="space-y-2">
57
<Label htmlFor={`${appName}-workspace`}>Workspace label</Label>
58
<Input id={`${appName}-workspace`} defaultValue={`${appName}.workspace.local`} />
59
</div>
60
<Separator />
61
<div className="flex flex-wrap gap-3">
62
<Button>Primary action</Button>
63
<Button variant="secondary">Secondary action</Button>
64
<Button variant="outline">Outline action</Button>
65
</div>
66
</div>
67
68
<div className="rounded-[1.5rem] border border-white/10 bg-white/5 p-5 text-sm leading-6 text-slate-200">
69
<p>
70
The same primitives can now be imported from the shared package and reused in both sub-apps without adding separate component copies.
71
</p>
72
</div>
73
</CardContent>
74
75
<CardFooter className="flex-wrap gap-3 border-t border-white/10 pt-6">
76
<Button asChild variant="ghost">
77
<a href="https://ui.shadcn.com/docs/components/button" target="_blank" rel="noopener noreferrer">
78
View shadcn button reference
79
</a>
80
</Button>
81
<Button asChild variant="outline">
82
<a href="https://ui.shadcn.com/docs/components/card" target="_blank" rel="noopener noreferrer">
83
View card reference
84
</a>
85
</Button>
86
</CardFooter>
87
</Card>
88
)
89
}

这一步能顺带验证三件事:

  • 新增组件文件确实被 packages/ui/src/theme.css 扫描到了
  • @repo/ui/* 顶层子路径导入方式仍然成立
  • 共享包里的组件样式和结构在两个子站都能正确落地

8. 两个子站都要写一段更贴近业务的用法

只有共享演示块还不够。

因为它本质上还是一个通用展示区,没法完全说明组件进入真实页面结构后是否依旧正常。

所以这里分别在 apps/web/app/page.tsxapps/admin/app/page.tsx 再加一段更贴近业务的基础用法。

8.1 web

web 首页可以放一个简洁表单区块,重点验证 Label + Input + Button + Card + Separator 这一套组合:

apps/web/app/page.tsx
01
import { Button } from '@repo/ui/button'
02
import {
03
Card,
04
CardContent,
05
CardDescription,
06
CardHeader,
07
CardTitle,
08
} from '@repo/ui/card'
09
import { Input } from '@repo/ui/input'
10
import { Label } from '@repo/ui/label'
11
import { Separator } from '@repo/ui/separator'
12
import { TailwindDemo } from '@repo/ui/tailwind-demo'
13
14
export default function Home() {
15
return (
16
<>
17
<TailwindDemo appName="web" />
18
19
<Card>
20
<CardHeader>
21
<CardTitle>Primitive validation in web</CardTitle>
22
<CardDescription>
23
This section imports shared Button, Input, Label, Card, and Separator components directly from <code className="rounded bg-white/10 px-2 py-1 text-slate-100">@repo/ui</code>.
24
</CardDescription>
25
</CardHeader>
26
<CardContent className="space-y-5">
27
<div className="grid gap-2">
28
<Label htmlFor="project-name">Project name</Label>
29
<Input id="project-name" placeholder="AI Agent workspace" />
30
</div>
31
<Separator />
32
<div className="flex flex-wrap gap-3">
33
<Button>Save draft</Button>
34
<Button variant="secondary">Preview</Button>
35
<Button variant="outline">Open docs</Button>
36
</div>
37
</CardContent>
38
</Card>
39
</>
40
)
41
}

这段的价值在于它更接近真实业务页面:有表单项、有分隔、有操作按钮,而且所有组件都直接从共享包导入。

8.2 admin

admin 首页则更适合做一个操作区,重点验证不同按钮尺寸和变体:

apps/admin/app/page.tsx
01
import { Button } from '@repo/ui/button'
02
import {
03
Card,
04
CardContent,
05
CardDescription,
06
CardHeader,
07
CardTitle,
08
} from '@repo/ui/card'
09
import { Input } from '@repo/ui/input'
10
import { Label } from '@repo/ui/label'
11
import { Separator } from '@repo/ui/separator'
12
import { TailwindDemo } from '@repo/ui/tailwind-demo'
13
14
export default function Home() {
15
return (
16
<>
17
<TailwindDemo appName="admin" />
18
19
<Card>
20
<CardHeader>
21
<CardTitle>Admin primitive validation</CardTitle>
22
<CardDescription>
23
This block checks the same shared primitives in a second app with a slightly different layout and button sizing.
24
</CardDescription>
25
</CardHeader>
26
<CardContent className="space-y-5">
27
<div className="grid gap-2 md:max-w-md">
28
<Label htmlFor="tenant-id">Tenant identifier</Label>
29
<Input id="tenant-id" placeholder="team-enterprise-01" />
30
</div>
31
<Separator />
32
<div className="flex flex-wrap gap-3">
33
<Button size="sm">Queue sync</Button>
34
<Button variant="secondary">Inspect</Button>
35
<Button size="lg" variant="outline">Publish changes</Button>
36
</div>
37
</CardContent>
38
</Card>
39
</>
40
)
41
}

两个页面分开这样验证之后,能更明确地确认一件事:共享包里的基础组件不是只在一个演示块里可用,而是真的能跨应用消费。

9. 总结

这次不急着上 DialogPopoverDropdownMenu 这类更重的交互组件,原因很现实。

因为一旦开始做这类组件,主题变量、客户端边界、Portal、焦点管理、可访问性细节都会一起进来,文章复杂度会立刻上升。

当前阶段先把下面这些基础能力跑通,更值:

  • 按钮变体和尺寸
  • 表单输入与标签绑定
  • 结构容器和分隔线
  • cvacn、Radix primitives 的接入链路
  • 共享包被多个应用消费时的编译与样式稳定性

这批组件已经足够覆盖「共享包接入 shadcn 风格组件」最核心的路径。

处理完之后,我们可以按照下面的命令进行验证:

index.bash
1
pnpm install
2
pnpm --filter @repo/ui check-types
3
pnpm --filter web check-types
4
pnpm --filter admin check-types
5
pnpm lint
6
pnpm build

然后分别启动两个前端:

index.bash
1
pnpm --filter web dev
2
pnpm --filter admin dev

浏览器里打开:

  • http://localhost:3005
  • http://localhost:3006

检查:

  • 两个子站都能看到新的共享演示区和页面内业务区块
  • Button 的不同 variantsize 样式确实有差异
  • 点击 Label 可以聚焦对应 Input
  • Separator 可见,Card 结构正常
  • 控制台没有 hydration、module resolution、Tailwind class 丢失这类报错

如果出问题,优先排查下面几项。

共享组件样式缺失

先看新增组件文件是不是都位于 packages/ui/src 下面,因为 theme.css@source "./**/*.{ts,tsx}"; 只会扫描这个范围。

Button 的 asChild 类型或导入报错

优先看 @radix-ui/react-slot 有没有装上,以及 Button 的 props 类型是不是包含 asChild?: boolean

应用侧导入失败

先核对顶层文件名和共享包导出规则是不是对应得上,重点看 @repo/ui/button@repo/ui/card 这类现有子路径有没有被破坏。