feat(ui): 重构核心 UI 组件库,新增 shadcn/ui 组件

- 重构 Button、Card、Badge、Input、Textarea 等基础组件
- 新增 Accordion、Alert、Dialog、Dropdown、Form 等 shadcn/ui 组件
- 新增 AnimatedCounter、StatsShowcase、MetricCard 等数据展示组件
- 新增 ScrollReveal 滚动动画组件
- 重构 Toast 通知系统与 Tooltip 提示组件
- 更新设计令牌系统,对齐新品牌视觉
This commit is contained in:
张翔
2026-07-07 06:52:38 +08:00
parent 9053f69123
commit e78df62cd1
62 changed files with 4024 additions and 822 deletions
+68 -47
View File
@@ -1,91 +1,112 @@
'use client';
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden",
'group inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-fast ease-standard disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden select-none',
{
variants: {
variant: {
default:
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-brand-primary-rgb),0.3)] hover:-translate-y-0.5 active:scale-[0.97] group/btn before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent before:-translate-x-full hover:before:translate-x-full before:transition-transform before:duration-700 before:ease-out",
primary:
'bg-brand text-white shadow-sm hover:bg-brand-hover hover:shadow-brand-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
secondary:
"bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.97]",
destructive:
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)] active:scale-[0.97]",
'bg-ink text-white shadow-sm hover:bg-ink-light hover:shadow-md hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
outline:
"border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 hover:border-[var(--color-brand-primary)] active:scale-[0.97] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.15)]",
'border border-border-accent bg-transparent text-text-primary hover:bg-ink hover:text-white hover:border-ink hover:-translate-y-px hover:shadow-md active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
ghost:
"text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-brand-primary)]",
'text-text-secondary hover:bg-bg-secondary hover:text-text-primary active:scale-[0.97] transition-all duration-fast ease-standard',
link:
"text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)] after:absolute after:bottom-0 after:left-0 after:h-[1.5px] after:w-0 hover:after:w-full after:bg-[var(--color-brand-primary)] after:transition-all after:duration-300",
'text-text-primary underline-offset-4 hover:text-brand relative after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 hover:after:w-full after:bg-brand after:transition-all after:duration-normal after:ease-standard',
destructive:
'bg-error text-white shadow-sm hover:bg-error-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] transition-all duration-fast ease-standard',
},
size: {
default: "h-11 px-4 py-2",
sm: "h-9 rounded-md px-3 text-xs",
lg: "h-12 rounded-lg px-6 text-base",
icon: "h-11 w-11",
sm: 'h-9 rounded-md px-3 text-xs font-medium',
default: 'h-11 rounded-md px-5 py-2 font-medium',
lg: 'h-12 rounded-md px-6 text-base font-semibold',
xl: 'h-14 rounded-md px-8 text-base font-semibold',
icon: 'h-11 w-11 rounded-md',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'primary',
size: 'default',
},
}
)
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
showRipple?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, onClick, children, ...props }, ref) => {
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number }>>([])
({ className, variant, size, asChild = false, onClick, children, showRipple = true, ...props }, ref) => {
const [ripples, setRipples] = React.useState<{ x: number; y: number; id: number }[]>([]);
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const id = Date.now()
setRipples(prev => [...prev, { id, x, y }])
setTimeout(() => {
setRipples(prev => prev.filter(r => r.id !== id))
}, 600)
onClick?.(e)
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (showRipple && variant !== 'link' && variant !== 'ghost') {
const button = e.currentTarget;
const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const id = Date.now();
setRipples((prev) => [...prev, { x, y, id }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, 600);
}
onClick?.(e);
};
// asChild 模式:Slot 要求单个 React 元素子节点,因此跳过 ripple 和包裹 span。
// 直接把 children 交给 Slot,由 Slot 克隆并注入 className/onClick 等 props。
if (asChild) {
return (
<Slot
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
onClick={onClick}
{...props}
>
{children}
</Slot>
);
}
const Comp = asChild ? Slot : "button"
return (
<Comp
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
onClick={handleClick}
{...props}
>
{children}
{ripples.map(ripple => (
{ripples.map((ripple) => (
<span
key={ripple.id}
className="absolute rounded-full bg-white/30 pointer-events-none animate-ripple"
className="pointer-events-none absolute rounded-full bg-white/25 animate-ripple"
style={{
left: ripple.x,
top: ripple.y,
width: '20px',
height: '20px',
marginLeft: '-10px',
marginTop: '-10px',
transform: 'translate(-50%, -50%)',
}}
/>
))}
</Comp>
)
<span className="relative z-10 flex items-center justify-center gap-2">
{children}
</span>
</button>
);
}
)
Button.displayName = "Button"
);
Button.displayName = 'Button';
export { Button, buttonVariants }
export { Button, buttonVariants };