diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..3c431cd --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,65 @@ +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * 标准 shadcn/ui Accordion 组件 + * 基于 @radix-ui/react-accordion + */ +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..39521fb --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,136 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { cn } from '@/lib/utils'; +import { buttonVariants } from './button'; + +/** + * 标准 shadcn/ui AlertDialog 组件 + * 基于 @radix-ui/react-alert-dialog + */ +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..3c25b32 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +/** + * 标准 shadcn/ui Alert 组件 + * 纯 HTML 实现,不依赖 Radix + */ +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-bg-primary text-text-primary border-border-primary', + destructive: + 'text-error bg-error-bg border-error [&>svg]:text-current', + success: + 'text-success bg-success-bg border-success [&>svg]:text-current', + warning: + 'text-warning bg-warning-bg border-warning [&>svg]:text-current', + info: 'text-info bg-info-bg border-info [&>svg]:text-current', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, alertVariants }; diff --git a/src/components/ui/animated-counter.tsx b/src/components/ui/animated-counter.tsx new file mode 100644 index 0000000..cd88310 --- /dev/null +++ b/src/components/ui/animated-counter.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; +import { useCountUp } from '@/hooks/use-count-up'; +import { useReducedMotion } from '@/hooks/use-reduced-motion'; +import { cn } from '@/lib/utils'; + +interface AnimatedCounterProps { + value: number; + decimals?: number; + duration?: number; + prefix?: string; + suffix?: string; + className?: string; + /** 只在进入视口时开始动画 */ + startOnView?: boolean; + /** 视口阈值 */ + threshold?: number; +} + +export function AnimatedCounter({ + value, + decimals = 0, + duration = 1800, + prefix = '', + suffix = '', + className, + startOnView = true, + threshold = 0.3, +}: AnimatedCounterProps) { + const ref = useRef(null); + const [start, setStart] = useState(!startOnView); + const shouldReduceMotion = useReducedMotion(); + + const count = useCountUp({ + end: value, + decimals, + duration, + enabled: start && !shouldReduceMotion, + }); + + useEffect(() => { + if (!startOnView) return; + + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setStart(true); + observer.disconnect(); + } + }, + { threshold }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [startOnView, threshold]); + + const displayValue = start ? (shouldReduceMotion ? value : count) : value; + + return ( + + {prefix} + {displayValue.toLocaleString('zh-CN', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} + {suffix} + + ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..23a266e --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,56 @@ +'use client'; + +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import { cn } from '@/lib/utils'; + +/** + * 标准 shadcn/ui Avatar 组件 + * 基于 @radix-ui/react-avatar + */ +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/back-to-top.tsx b/src/components/ui/back-to-top.tsx index c3c445c..1cade4d 100644 --- a/src/components/ui/back-to-top.tsx +++ b/src/components/ui/back-to-top.tsx @@ -35,13 +35,10 @@ export function BackToTop() { exit={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.8 }} transition={{ duration: 0.2, ease: 'easeOut' }} onClick={scrollToTop} - className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-[var(--color-brand-primary)] text-white rounded-full shadow-lg hover:bg-[var(--color-brand-primary-hover)] hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2" + className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-brand text-white hover:bg-brand-hover transition-all duration-300 ease-out focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-white" aria-label="返回顶部" title="返回顶部" - style={{ - boxShadow: '0 4px 14px rgba(var(--color-brand-primary-rgb), 0.4)', - }} - whileHover={shouldReduceMotion ? {} : { scale: 1.1 }} + whileHover={shouldReduceMotion ? {} : { scale: 1.05 }} whileTap={shouldReduceMotion ? {} : { scale: 0.95 }} > diff --git a/src/components/ui/badge.test.tsx b/src/components/ui/badge.test.tsx index 9807b41..601ef79 100644 --- a/src/components/ui/badge.test.tsx +++ b/src/components/ui/badge.test.tsx @@ -40,9 +40,9 @@ describe('Badge', () => { expect(badge).toBeInTheDocument(); }); - it('should render destructive variant', () => { - const { container } = render(Destructive); - const badge = container.querySelector('[data-variant="destructive"]'); + it('should render error variant', () => { + const { container } = render(Error); + const badge = container.querySelector('[data-variant="error"]'); expect(badge).toBeInTheDocument(); }); @@ -86,7 +86,8 @@ describe('Badge', () => { it('should have rounded-full class', () => { const { container } = render(Badge); - const badge = container.querySelector('.rounded-full'); + // Badge 使用 rounded-sm(shadcn/ui 规范) + const badge = container.querySelector('.rounded-sm'); expect(badge).toBeInTheDocument(); }); diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 9ca105b..db7cfc2 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,52 +1,57 @@ '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 badgeVariants = cva( - "inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 transition-all duration-300 overflow-hidden", + 'inline-flex items-center justify-center rounded-sm border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1.5 [&>svg]:pointer-events-none focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20 transition-all duration-fast ease-out', { variants: { variant: { - default: "bg-[var(--color-brand-primary)] text-white border-transparent shadow-sm", + default: 'bg-brand text-white border-transparent', secondary: - "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] border-[var(--color-border-primary)]", - destructive: - "bg-[var(--color-brand-primary)] text-white border-transparent hover:bg-[var(--color-brand-primary-hover)]", + 'bg-bg-secondary text-text-secondary border-border-primary', outline: - "border-[var(--color-primary)] text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary-lighter)]", - ghost: "text-[var(--color-text-placeholder)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary-lighter)]", - success: "bg-[var(--color-success)] text-white border-transparent hover:bg-[var(--color-success-hover)]", - warning: "bg-[var(--color-warning)] text-white border-transparent hover:bg-[var(--color-warning-hover)]", - info: "bg-[var(--color-info)] text-white border-transparent hover:bg-[var(--color-primary-light)]", + 'border-border-accent text-text-primary bg-transparent', + ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary', + success: 'bg-success/10 text-success border-transparent', + warning: 'bg-warning/10 text-warning border-transparent', + error: 'bg-error/10 text-error border-transparent', + info: 'bg-info/10 text-info border-transparent', + }, + size: { + sm: 'px-2 py-0.5 text-[10px]', + default: 'px-2.5 py-1 text-xs', + lg: 'px-3 py-1.5 text-sm', }, }, defaultVariants: { - variant: "default", + variant: 'default', + size: 'default', }, } -) +); function Badge({ className, - variant = "default", + variant = 'default', + size, asChild = false, ...props -}: React.ComponentProps<"span"> & +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" + const Comp = asChild ? Slot : 'span'; return ( - ) + ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/src/components/ui/brand-visuals.tsx b/src/components/ui/brand-visuals.tsx index aa53398..4cb6700 100644 --- a/src/components/ui/brand-visuals.tsx +++ b/src/components/ui/brand-visuals.tsx @@ -11,13 +11,13 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle return (