源代码

"use client";

import { useState } from "react";
import Link from "next/link";
import { Search, Package, Copy, CheckCircle } from "lucide-react";
import { motion, stagger } from "motion/react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
  getAllComponents,
  getCategories,
  getComponentsByCategory,
  searchComponents,
  type ComponentInfo,
} from "@/lib/registry";
import { useClipboard } from "use-clipboard-copy";
import { toast } from "sonner";

export default function ComponentsPage() {
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedCategory, setSelectedCategory] = useState("all");
  const clipboard = useClipboard();

  const allComponents = getAllComponents();
  const categories = getCategories();

  // 根据搜索和分类过滤组件
  const filteredComponents = searchQuery
    ? searchComponents(searchQuery)
    : selectedCategory === "all"
      ? allComponents
      : getComponentsByCategory(selectedCategory);

  const handleCopyCommand = (componentId: string) => {
    const command = `npx shadcn@latest add @qiuye-ui/${componentId}`;
    clipboard.copy(command);
    toast.success("复制成功!", {
      description: `已复制命令: ${command}`,
    });
  };

  const cardWrapperVariants = {
    show: {
      opacity: 1,
      y: 0,
      type: "spring",
      stiffness: 120,
      damping: 40,
      transition: {
        delay: 0.75,
        duration: 0.25,
        when: "beforeChildren",
        delayChildren: stagger(0.25),
        // staggerChildren: 1, // 效果不太一样, 后续父元素不动, 子元素再次出现时只会同时出现
      },
    },
  };

  return (
    <div className="container mx-auto px-6 py-8">
      {/* Header */}
      <div className="mb-8">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{
            type: "spring",
            stiffness: 120,
            damping: 40,
            duration: 0.25,
          }}
        >
          <h1 className="text-4xl font-bold tracking-tight mb-4">QiuYe UI</h1>
          <p className="text-xl text-muted-foreground mb-6">
            精心设计的自定义UI组件,让您的应用更加出色
          </p>

          <div className="flex items-center gap-4 text-sm text-muted-foreground">
            <div className="flex items-center gap-2">
              <Package className="h-4 w-4" />
              <span>{allComponents.length} 个组件</span>
            </div>
            <div className="flex items-center gap-2">
              <span>•</span>
              <span>{categories.length} 个分类</span>
            </div>
            <div className="flex items-center gap-2">
              <span>•</span>
              <span>一键复制CLI命令</span>
            </div>
          </div>
        </motion.div>
      </div>

      {/* Search and Filter */}
      <motion.div
        className="mb-8"
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{
          type: "spring",
          stiffness: 120,
          damping: 40,
          duration: 0.5,
          delay: 0.45,
        }}
      >
        <div className="flex flex-col sm:flex-row gap-4 mb-6">
          <div className="relative flex-1">
            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
            <Input
              placeholder="搜索组件..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              className="pl-10"
            />
          </div>
        </div>

        <Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
          <TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-1">
            <TabsTrigger value="all" className="text-xs">
              全部
            </TabsTrigger>
            {categories.map((category) => (
              <TabsTrigger key={category} value={category} className="text-xs">
                {category}
              </TabsTrigger>
            ))}
          </TabsList>
        </Tabs>
      </motion.div>

      {/* Components Grid */}
      <div>
        {filteredComponents.length === 0 ? (
          <motion.div
            className="text-center py-12"
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{
              type: "spring",
              stiffness: 120,
              damping: 40,
              duration: 0.35,
              delay: 0.75,
            }}
          >
            <Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
            <h3 className="text-lg font-semibold mb-2">没有找到匹配的组件</h3>
            <p className="text-muted-foreground">
              尝试修改搜索关键词或选择其他分类
            </p>
          </motion.div>
        ) : (
          <motion.div
            className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
            initial={{ opacity: 0, y: 20 }}
            variants={cardWrapperVariants}
            animate="show"
            // initial={{ opacity: 0, y: 20 }}
            // animate={{ opacity: 1, y: 0 }}
            // transition={{
            //   type: "spring",
            //   stiffness: 120,
            //   damping: 40,
            //   duration: 0.35,
            //   delay: 0.75,
            //   when: "beforeChildren",
            //   // delayChildren: stagger(2),
            //   // delayChildren: 2,
            // }}
          >
            {filteredComponents.map((component, index) => (
              <ComponentCard
                key={component.cliName + index}
                component={component}
                index={index}
                onCopyCommand={handleCopyCommand}
              />
            ))}
          </motion.div>
        )}
      </div>
    </div>
  );
}

interface ComponentCardProps {
  component: ComponentInfo;
  index: number;
  onCopyCommand: (componentId: string) => void;
}

function ComponentCard({
  component,
  index,
  onCopyCommand,
}: ComponentCardProps) {
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    onCopyCommand(component.cliName);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  const cardVariants = {
    show: {
      opacity: 1,
      y: 0,
      type: "spring",
      stiffness: 120,
      damping: 40,
      transition: {
        duration: 0.35,
      },
    },
  };

  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      variants={cardVariants}
      // initial={{ opacity: 0, y: 20 }}
      // animate={{ opacity: 1, y: 0 }}
      // transition={{
      //   type: "spring",
      //   stiffness: 120,
      //   damping: 40,
      //   duration: 0.75,
      //   delay: (index + 1) * 0.2,
      // }}
      whileHover={{ y: -4, transition: { duration: 0.2 } }}
    >
      <Card className="h-full hover:shadow-lg transition-all duration-300">
        <CardHeader>
          <div className="flex items-start justify-between">
            <div className="flex-1">
              <CardTitle className="text-lg mb-2">{component.name}</CardTitle>
              <CardDescription className="text-sm line-clamp-2">
                {component.description}
              </CardDescription>
            </div>
            <Badge variant="secondary" className="ml-2">
              {component.category}
            </Badge>
          </div>
        </CardHeader>

        <CardContent className="space-y-4">
          {/* Tags */}
          <div className="flex flex-wrap gap-1">
            {component.tags.slice(0, 3).map((tag) => (
              <Badge key={tag} variant="outline" className="text-xs">
                {tag}
              </Badge>
            ))}
            {component.tags.length > 3 && (
              <Badge variant="outline" className="text-xs">
                +{component.tags.length - 3}
              </Badge>
            )}
          </div>

          {/* CLI Command */}
          <div className="bg-muted/50 rounded-md p-3">
            <div className="flex items-center justify-between">
              <code className="text-sm font-mono">
                npx shadcn@latest add @qiuye-ui/{component.cliName}
              </code>
              <Button
                size="sm"
                variant="ghost"
                onClick={handleCopy}
                className="h-6 w-6 p-0"
              >
                {copied ? (
                  <CheckCircle className="h-3 w-3 text-green-500" />
                ) : (
                  <Copy className="h-3 w-3" />
                )}
              </Button>
            </div>
          </div>

          {/* Actions */}
          <div className="flex gap-2">
            <Button asChild variant="outline" size="sm" className="flex-1">
              <Link href={`/components/${component.cliName}`}>查看详情</Link>
            </Button>
            <Button onClick={handleCopy} size="sm" className="flex-1">
              {copied ? "已复制" : "复制命令"}
            </Button>
          </div>

          {/* Metadata */}
          <div className="flex justify-between text-xs text-muted-foreground">
            <span>v{component.version}</span>
            <span>by {component.author}</span>
          </div>
        </CardContent>
      </Card>
    </motion.div>
  );
}

个人理解笔记

  • 需要像官方文档示例React Transitions中写的一样,给父子组件创建两个variants,里面用同一个命名空间(如本例中的show)配置想要实现的动画效果(为了能有动画当然还要给父子组件都设置和show里面状态不一样的initial配置,以便动画从initial过渡到show的状态)。然后在父组件的variantstransition中配置when: "beforeChildren",给父组件设置animate="show"即可实现“先父后子”的动画序列效果。如果有多个子元素,需要子元素间隔一定的时间依次执行动画,可以给父元素的transition中设置delayChildren: stagger(0.25)来实现这样的效果。如果需要设置spring动画配置,则这里不能放在transition下,而应该放在show下。

你贴的示例已经用到了 motion/react(v12 的导入路径)以及 when: "beforeChildren"delayChildren: stagger(...)。本文就基于这段代码,系统讲讲在 Motion v12(原 Framer Motion)里如何实现“父完 → 子启”的编排,并给出几种常见写法与坑点。顺手也把你当前写法做了些优化建议。


0. 先搞清楚 v12 的几个要点

  • v12 包名从 framer-motion 变成了 motion,React 里改为 import { motion, stagger } from "motion/react";对 React 用户几乎无破坏升级。(motion.dev)
  • 编排核心仍是 variants 的 transition.whendelayChildren,以及配合 stagger() 做子元素的级联延时。(motion.dev)

1. 写法一(最常用):when: "beforeChildren" + delayChildren + stagger()

这个写法就是你代码的思路:父容器先进场,结束后再依次让子项出现。

import { motion, stagger } from "motion/react";

const wrapper = {
  hidden: { opacity: 0, y: 20 },
  show: {
    opacity: 1, y: 0,
    transition: {
      // ① 先让父级做完
      when: "beforeChildren",
      // ② 再开始播放子级;这里也可以加 startDelay
      delayChildren: stagger(0.15, { startDelay: 0.2 }),
      type: "spring", stiffness: 120, damping: 40,
    },
  },
};

const item = {
  hidden: { opacity: 0, y: 12 },
  show: {
    opacity: 1, y: 0,
    transition: { type: "spring", stiffness: 120, damping: 40 },
  },
};

export function Grid({ children }) {
  return (
    <motion.div
      variants={wrapper}
      initial="hidden"
      animate="show"
      className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
    >
      {children.map((child) => (
        <motion.div key={child.key} variants={item} whileHover={{ y: -4 }} />
      ))}
    </motion.div>
  );
}

要点与坑:

  • 孩子不要再单独写 initial/animate,让它们继承父级的同名 variant key(如上 hidden/show),否则会覆盖掉编排。(motion.dev)
  • delayChildren 在 v12 中可直接接收 stagger(...),还能用 fromstartDelayease 做更精细的队列控制。(motion.dev)
  • 你原代码里父级 type: "spring" 同时设置了 duration当 spring 使用 stiffness/damping/mass 时会覆盖 duration/bounce;如果你需要“按时长对齐”,要么改 type: "tween",要么用 v12 的 visualDuration(或给 spring 配 duration + bounce,但别再同时设刚度参数)。(motion.dev)

2. 写法二:只用 delayChildren/stagger()(不强制父完再子)

如果你不需要等父级完全结束,而是希望“父先动一会儿,子再陆续进来”,可以不设 when,只用 delayChildren: stagger(...);那子级会基于父级动画开始时刻延迟启动。(motion.dev)

const wrapper = {
  hidden: { opacity: 0, y: 12 },
  show: {
    opacity: 1, y: 0,
    transition: {
      delayChildren: stagger(0.12, { startDelay: 0.2 }),
    },
  },
};

3. 写法三(最强控制):useAnimate 顺序执行(await 承诺)

当你需要严格时序(比如:父 -> 第1列卡片 -> 第2列卡片 -> …)或跨层级/跨组件编排,直接用 useAnimate命令式时间线,它的 animate 返回 Promise,「await 前一个动画完成再播下一个」即可。(motion.dev)

import { useEffect } from "react";
import { useAnimate, stagger } from "motion/react";

export function StrictSequence() {
  const [scope, animate] = useAnimate();

  useEffect(() => {
    (async () => {
      // 1) 父级
      await animate(scope.current, { opacity: 1, y: 0 }, { duration: 0.3 });

      // 2) 子级(选择器会自动作用到 scope 内)
      await animate(".card", { opacity: 1, y: 0 }, { delay: stagger(0.12) });
    })();
  }, [animate]);

  return (
    <div ref={scope}>
      <div className="card" />
      <div className="card" />
      <div className="card" />
    </div>
  );
}

animate() 本身就是 Promise-like:你可以 await animation 等它结束。(motion.dev)


4. 进阶:退出/切换场景用 AnimatePresence + when

当列表增删(或路由切换)时,AnimatePresence 能让出场动画也被编排。你仍然可以在 variants 里用 when: "beforeChildren"/"afterChildren" 去约束父子出场的相对顺序。(motion.dev)


5. 回到你的代码:几处可以直接改进

  1. Card 容器的 variants
    你已经把 when: "beforeChildren"delayChildren: stagger(0.25) 写在父网格上,这很好。建议把 spring 的时长控制也统一一下——如果仍用 stiffness/damping,就别再写 duration;若要时间对齐,建议换 type: "tween" 或在 spring 里用 visualDuration。(motion.dev)

  2. 子项别重复声明 initial/animate
    你用的是 variants + animate="show" 的父传子模式,子卡片组件只需要 variants={cardVariants} 即可,避免在子级再写 initial/animate 破坏编排。(motion.dev)

  3. 更灵活的队列
    如果想让最后一列先出现,可以用 stagger(0.25, { from: "last" });或者卡片多的时候加 ease 让队列分布更自然。(motion.dev)


6. 一个“融合你代码风格”的改写片段

const cardWrapperVariants = {
  hidden: { opacity: 0, y: 20 },
  show: {
    opacity: 1, y: 0,
    transition: {
      when: "beforeChildren",
      // 从左到右依次出现;也可 { from: "last" } 或 { startDelay: 0.3 }
      delayChildren: stagger(0.18),
      type: "spring", stiffness: 120, damping: 40,
    },
  },
};

const cardVariants = {
  hidden: { opacity: 0, y: 12 },
  show: {
    opacity: 1, y: 0,
    transition: { type: "spring", stiffness: 120, damping: 40 },
  },
};

// 父
<motion.div
  className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
  variants={cardWrapperVariants}
  initial="hidden"
  animate="show"
>
  {filteredComponents.map((c, i) => (
    // 子
    <motion.div key={c.cliName + i} variants={cardVariants} whileHover={{ y: -4 }}>
      <ComponentCard ... />
    </motion.div>
  ))}
</motion.div>

7. 速查清单(遇事先看它)

  • 父先、子后transition.when: "beforeChildren"。(motion.dev)
  • 延迟并队列化子动画delayChildren: stagger(step, options)。(motion.dev)
  • 严格时序useAnimate + await animate(...)。(motion.dev)
  • Spring 时长注意:设了 stiffness/damping/mass 会覆盖 duration/bounce;想按时长对齐可用 tweenvisualDuration。(motion.dev)
  • 让子级继承 variants:尽量别在子级重复 initial/animate。(motion.dev)

如果你愿意,我也可以把你整段页面代码按上面的建议直接改一版(保留现有 UI/动画风格),再帮你补一份「进入视口才播放」的 whileInView 版本,适配长列表场景。

最终优化版(含exit和layout效果)

"use client";

import { useState } from "react";
import Link from "next/link";
import { Search, Package, Copy, CheckCircle } from "lucide-react";
import { motion, stagger, AnimatePresence } from "motion/react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
  getAllComponents,
  getCategories,
  getComponentsByCategory,
  searchComponents,
  type ComponentInfo,
} from "@/lib/registry";
import { useClipboard } from "use-clipboard-copy";
import { toast } from "sonner";

export default function ComponentsPage() {
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedCategory, setSelectedCategory] = useState("all");
  const clipboard = useClipboard();

  const allComponents = getAllComponents();
  const categories = getCategories();

  // 根据搜索和分类过滤组件
  const filteredComponents = searchQuery
    ? searchComponents(searchQuery)
    : selectedCategory === "all"
      ? allComponents
      : getComponentsByCategory(selectedCategory);

  const handleCopyCommand = (componentId: string) => {
    const command = `npx shadcn@latest add @qiuye-ui/${componentId}`;
    clipboard.copy(command);
    toast.success("复制成功!", {
      description: `已复制命令: ${command}`,
    });
  };

  // 父级网格:先进场,再播放子项;离场时等子项先退场
  const gridVariants = {
    hidden: { opacity: 0, y: 20 },
    show: {
      opacity: 1,
      y: 0,
      transition: {
        when: "beforeChildren",
        delayChildren: stagger(0.18),
        type: "spring",
        visualDuration: 0.25,
        bounce: 0.12,
      },
    },
    // 只有在整个网格卸载时才会触发(例如外层条件渲染切换)
    exit: {
      opacity: 0,
      y: 12,
      transition: {
        when: "afterChildren", // 先等子项完成 exit
        duration: 0.2, // 这里用 tween,让离场更可控
      },
    },
  } as const;

  // 子卡片:进入 / 离开动画
  const cardVariants = {
    hidden: { opacity: 0, y: 20, scale: 0.98 },
    show: {
      opacity: 1,
      y: 0,
      scale: 1,
      transition: {
        type: "spring",
        visualDuration: 0.45,
        bounce: 0.48,
      },
    },
    exit: {
      opacity: 0,
      y: -8,
      scale: 0.98,
      transition: { duration: 0.2 },
    },
  } as const;

  return (
    <div className="container mx-auto px-6 py-8">
      {/* Header */}
      <div className="mb-8">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{
            type: "spring",
            stiffness: 120,
            damping: 40,
            duration: 0.25,
          }}
        >
          <h1 className="text-4xl font-bold tracking-tight mb-4">QiuYe UI</h1>
          <p className="text-xl text-muted-foreground mb-6">
            精心设计的自定义UI组件,让您的应用更加出色
          </p>

          <div className="flex items-center gap-4 text-sm text-muted-foreground">
            <div className="flex items-center gap-2">
              <Package className="h-4 w-4" />
              <span>{allComponents.length} 个组件</span>
            </div>
            <div className="flex items-center gap-2">
              <span>•</span>
              <span>{categories.length} 个分类</span>
            </div>
            <div className="flex items-center gap-2">
              <span>•</span>
              <span>一键复制CLI命令</span>
            </div>
          </div>
        </motion.div>
      </div>

      {/* Search and Filter */}
      <motion.div
        className="mb-8"
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{
          type: "spring",
          stiffness: 120,
          damping: 40,
          duration: 0.45,
          delay: 0.25,
        }}
      >
        <div className="flex flex-col sm:flex-row gap-4 mb-6">
          <div className="relative flex-1">
            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
            <Input
              placeholder="搜索组件..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              className="pl-10"
            />
          </div>
        </div>

        <Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
          <TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-1">
            <TabsTrigger value="all" className="text-xs">
              全部
            </TabsTrigger>
            {categories.map((category) => (
              <TabsTrigger key={category} value={category} className="text-xs">
                {category}
              </TabsTrigger>
            ))}
          </TabsList>
        </Tabs>
      </motion.div>

      {/* Components Grid / Empty State 切换:带离场 */}
      <AnimatePresence mode="wait">
        {filteredComponents.length === 0 ? (
          // 空态:等网格离场完再出现
          <motion.div
            key="empty"
            className="text-center py-12"
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 10 }}
            transition={{ type: "tween", duration: 0.25 }}
          >
            <Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
            <h3 className="text-lg font-semibold mb-2">没有找到匹配的组件</h3>
            <p className="text-muted-foreground">
              尝试修改搜索关键词或选择其他分类
            </p>
          </motion.div>
        ) : (
          // 网格:父先、子后;移除子项时播放 exit
          <motion.div
            key="grid"
            className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
            variants={gridVariants}
            initial="hidden"
            animate="show"
            exit="exit"
          >
            <AnimatePresence mode="popLayout">
              {filteredComponents.map((component) => (
                <motion.div
                  key={component.cliName} // 使用稳定 key,确保正确的出场动画
                  variants={cardVariants}
                  layout // 位置变化时做流畅过渡
                  // exit="exit" // 设置 exit 后, 父先子后的 stagger 入场动画就失效了
                  whileHover={{ y: -4, transition: { duration: 0.2 } }}
                >
                  <ComponentCard
                    component={component}
                    onCopyCommand={handleCopyCommand}
                  />
                </motion.div>
              ))}
            </AnimatePresence>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

interface ComponentCardProps {
  component: ComponentInfo;
  onCopyCommand: (componentId: string) => void;
}

// 精简后的子组件:不再包一个 motion.div,动画交给父级
function ComponentCard({ component, onCopyCommand }: ComponentCardProps) {
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    onCopyCommand(component.cliName);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <Card className="h-full hover:shadow-lg transition-all duration-300">
      <CardHeader>
        <div className="flex items-start justify-between">
          <div className="flex-1">
            <CardTitle className="text-lg mb-2">{component.name}</CardTitle>
            <CardDescription className="text-sm line-clamp-2">
              {component.description}
            </CardDescription>
          </div>
          <Badge variant="secondary" className="ml-2">
            {component.category}
          </Badge>
        </div>
      </CardHeader>

      <CardContent className="space-y-4">
        {/* Tags */}
        <div className="flex flex-wrap gap-1">
          {component.tags.slice(0, 3).map((tag) => (
            <Badge key={tag} variant="outline" className="text-xs">
              {tag}
            </Badge>
          ))}
          {component.tags.length > 3 && (
            <Badge variant="outline" className="text-xs">
              +{component.tags.length - 3}
            </Badge>
          )}
        </div>

        {/* CLI Command */}
        <div className="bg-muted/50 rounded-md p-3">
          <div className="flex items-center justify-between">
            <code className="text-sm font-mono">
              npx shadcn@latest add @qiuye-ui/{component.cliName}
            </code>
            <Button
              size="sm"
              variant="ghost"
              onClick={handleCopy}
              className="h-6 w-6 p-0"
            >
              {copied ? (
                <CheckCircle className="h-3 w-3 text-green-500" />
              ) : (
                <Copy className="h-3 w-3" />
              )}
            </Button>
          </div>
        </div>

        {/* Actions */}
        <div className="flex gap-2">
          <Button asChild variant="outline" size="sm" className="flex-1">
            <Link href={`/components/${component.cliName}`}>查看详情</Link>
          </Button>
          <Button onClick={handleCopy} size="sm" className="flex-1">
            {copied ? "已复制" : "复制命令"}
          </Button>
        </div>

        {/* Metadata */}
        <div className="flex justify-between text-xs text-muted-foreground">
          <span>v{component.version}</span>
          <span>by {component.author}</span>
        </div>
      </CardContent>
    </Card>
  );
}

A Student on the way to full stack of Web3.