【零基础AI应用开发】第03章:UI 搭建 — Tailwind CSS + shadcn/ui(UI篇)

📦 项目教程仓库:https://github.com/ZIQI-a/AI_Agent_study 🚀 成品项目地址:https://github.com/ZIQI-a/huamiao_Agent

本章目标

要做的事:安装 shadcn/ui,搭建话喵的整体布局(侧边栏 + 主内容区),设计配色方案

学到的知识

  • Tailwind CSS 的工作原理(原子化 CSS)
  • shadcn/ui 的独特理念(不是 npm 包,是 copy 组件)
  • 主题系统和自定义配色
  • 响应式设计

3.1 Tailwind CSS 回顾

项目创建时已经自动集成了 Tailwind。快速回顾核心用法:

html 复制代码
<!-- 传统 CSS -->
<button class="btn-primary">按钮</button>
<!-- 需要去 .css 文件里找 .btn-primary 的定义 -->

<!-- Tailwind CSS -->
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
  按钮
</button>
<!-- 样式直接写在 className 里,所见即所得 -->

核心理念:一个类名 = 一个 CSS 属性

bash 复制代码
bg-blue-500    →  background-color: #3b82f6
text-white     →  color: white
px-4           →  padding-left: 1rem; padding-right: 1rem
py-2           →  padding-top: 0.5rem; padding-bottom: 0.5rem
rounded        →  border-radius: 0.25rem
hover:bg-blue-600 → 鼠标悬停时背景变深
flex           →  display: flex
items-center   →  align-items: center
gap-4          →  gap: 1rem

不用写 CSS 文件,不用起类名,不用担心样式冲突。

3.2 安装 shadcn/ui

shadcn/ui 是目前最流行的 React 组件库之一。它的理念很独特:

bash 复制代码
传统组件库(如 Ant Design、Material UI):
  pnpm add antd  → 安装为 node_modules 里的包
  import { Button } from "antd"  → 引用包里的组件

shadcn/ui:
  npx shadcn@latest add button  → 把组件代码复制到你的项目里
  import { Button } from "@/components/ui/button"  → 引用你项目里的组件
  
  代码是你的,可以随意修改
  不会因为版本更新而破坏你的定制

初始化 shadcn/ui

bash 复制代码
# 在项目根目录执行
pnpm dlx shadcn@latest init

会问你几个问题:

bash 复制代码
✔ Would you like to use CSS variables for theming? → Yes
✔ Which color would you like to use as the base color? → Neutral
✔ Would you like to use CSS variables for colors? → Yes
✔ Are you using a custom tailwind prefix? → (直接回车,不使用)
✔ Where is your global CSS file? → src/app/globals.css
✔ Do you want to use React Server Components? → Yes
✔ Configure the import alias for components? → @/components
✔ Configure the import alias for utils? → @/lib/utils

初始化完成后,你会看到 src/components/ui/ 目录被创建了。

添加常用组件

bash 复制代码
# 添加按钮组件
pnpm dlx shadcn@latest add button

# 添加输入框
pnpm dlx shadcn@latest add input

# 添加卡片
pnpm dlx shadcn@latest add card

# 添加下拉选择
pnpm dlx shadcn@latest add select

# 添加对话框
pnpm dlx shadcn@latest add dialog

# 添加标签页
pnpm dlx shadcn@latest add tabs

# 添加 Toast 提示
pnpm dlx shadcn@latest add toast

# 添加骨架屏(加载占位)
pnpm dlx shadcn@latest add skeleton

# 一次性添加多个
pnpm dlx shadcn@latest add button input card select tabs toast skeleton

每个命令执行后,组件代码会被复制到 src/components/ui/ 目录。

3.3 自定义主题配色

我们为话喵设计一个温暖的猫咪主题配色。

打开 src/app/globals.css,修改 CSS 变量:

css 复制代码
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 40 33% 98%;        /* 温暖的米白色 */
    --foreground: 20 14% 12%;        /* 深棕色文字 */
    --card: 40 33% 96%;              /* 卡片背景 */
    --card-foreground: 20 14% 12%;
    --popover: 40 33% 96%;
    --popover-foreground: 20 14% 12%;
    --primary: 25 85% 57%;           /* 主色:温暖的橘色 */
    --primary-foreground: 40 33% 98%;
    --secondary: 30 20% 90%;         /* 辅助色:浅驼色 */
    --secondary-foreground: 20 14% 20%;
    --muted: 30 15% 92%;
    --muted-foreground: 20 8% 45%;
    --accent: 45 80% 60%;            /* 强调色:琥珀色 */
    --accent-foreground: 20 14% 12%;
    --destructive: 0 84% 60%;
    --destructive-foreground: 40 33% 98%;
    --border: 30 15% 87%;
    --input: 30 15% 87%;
    --ring: 25 85% 57%;
    --radius: 0.75rem;
  }

  .dark {
    --background: 20 14% 10%;
    --foreground: 40 20% 92%;
    --card: 20 14% 12%;
    --card-foreground: 40 20% 92%;
    --popover: 20 14% 12%;
    --popover-foreground: 40 20% 92%;
    --primary: 25 85% 57%;
    --primary-foreground: 40 33% 98%;
    --secondary: 20 10% 18%;
    --secondary-foreground: 40 20% 90%;
    --muted: 20 10% 16%;
    --muted-foreground: 30 10% 55%;
    --accent: 45 70% 50%;
    --accent-foreground: 40 33% 98%;
    --destructive: 0 63% 45%;
    --destructive-foreground: 40 33% 98%;
    --border: 20 10% 20%;
    --input: 20 10% 20%;
    --ring: 25 85% 57%;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

知识点:HSL 颜色值 shadcn/ui 用 HSL(色相、饱和度、亮度)而不是十六进制来定义颜色。 25 85% 57% = 色相 25(橙色范围)、饱和度 85%、亮度 57% 这样调整颜色更直观:改色相换色调,改饱和度调整鲜艳程度。

3.4 搭建整体布局

我们要做一个经典的左侧导航 + 右侧内容的布局。

创建导航组件

创建 src/components/layout/sidebar.tsx

typescript 复制代码
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";

// 导航菜单项
const navItems = [
  {
    label: "首页",
    href: "/",
    icon: "🏠",
  },
  {
    label: "文章创作",
    href: "/articles/create",
    icon: "✍️",
  },
  {
    label: "古诗词",
    href: "/poems",
    icon: "📜",
  },
  {
    label: "风格文库",
    href: "/styles",
    icon: "📚",
  },
  {
    label: "历史记录",
    href: "/history",
    icon: "📋",
  },
];

export function Sidebar() {
  const pathname = usePathname();

  return (
    <aside className="w-64 border-r bg-card flex flex-col">
      {/* Logo */}
      <div className="p-6 border-b">
        <Link href="/" className="flex items-center gap-2">
          <span className="text-3xl">🐱</span>
          <div>
            <h1 className="text-xl font-bold text-primary">话喵</h1>
            <p className="text-xs text-muted-foreground">AI 智能创作平台</p>
          </div>
        </Link>
      </div>

      {/* 导航菜单 */}
      <nav className="flex-1 p-4 space-y-1">
        {navItems.map((item) => {
          const isActive = pathname === item.href;
          return (
            <Link
              key={item.href}
              href={item.href}
              className={cn(
                "flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors",
                isActive
                  ? "bg-primary/10 text-primary font-medium"
                  : "text-muted-foreground hover:bg-muted hover:text-foreground"
              )}
            >
              <span className="text-lg">{item.icon}</span>
              {item.label}
            </Link>
          );
        })}
      </nav>

      {/* 底部信息 */}
      <div className="p-4 border-t">
        <div className="text-xs text-muted-foreground">
          <p>Powered by DeepSeek</p>
          <p className="mt-1">v0.1.0</p>
        </div>
      </div>
    </aside>
  );
}

知识点:cn() 函数是什么? 这是 shadcn/ui 初始化时创建的工具函数(在 src/lib/utils.ts 里)。 它的作用是合并 Tailwind 类名,避免冲突:

typescript 复制代码
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

cn("text-red-500", "text-blue-500")"text-blue-500"(后面的覆盖前面的)

创建主布局

修改 src/app/layout.tsx

typescript 复制代码
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Sidebar } from "@/components/layout/sidebar";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "话喵 - AI 智能创作平台",
  description: "AI 驱动的文章创作、古诗词生成、风格仿写平台",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        <div className="flex h-screen">
          {/* 左侧导航 */}
          <Sidebar />
          {/* 右侧主内容 */}
          <main className="flex-1 overflow-y-auto">
            {children}
          </main>
        </div>
        <Toaster />
      </body>
    </html>
  );
}

3.5 更新首页

修改 src/app/page.tsx

typescript 复制代码
import Link from "next/link";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

const features = [
  {
    title: "文章创作",
    description: "输入标题,AI 帮你写文章。支持选择字数、风格和详细程度。",
    icon: "✍️",
    href: "/articles/create",
  },
  {
    title: "古诗词生成",
    description: "输入一个名词,AI 为你创作古诗词,附带注释和赏析。",
    icon: "📜",
    href: "/poems",
  },
  {
    title: "风格文库",
    description: "导入你喜欢的文章,AI 学习风格后进行仿写创作。",
    icon: "📚",
    href: "/styles",
  },
];

export default function Home() {
  return (
    <div className="container mx-auto py-12 px-6">
      {/* 头部 */}
      <div className="text-center mb-12">
        <h1 className="text-5xl font-bold mb-4">
          <span className="text-primary">话喵</span>
          <span className="text-3xl ml-2">🐱</span>
        </h1>
        <p className="text-xl text-muted-foreground">
          AI 智能创作平台 --- 让文字有温度
        </p>
      </div>

      {/* 功能卡片 */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
        {features.map((feature) => (
          <Link key={feature.href} href={feature.href}>
            <Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
              <CardHeader>
                <div className="text-4xl mb-2">{feature.icon}</div>
                <CardTitle>{feature.title}</CardTitle>
                <CardDescription>{feature.description}</CardDescription>
              </CardHeader>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  );
}

3.6 创建页面容器组件

每个功能页面都需要类似的布局结构(标题 + 内容区),我们提取一个通用容器。

创建 src/components/layout/page-container.tsx

typescript 复制代码
interface PageContainerProps {
  title: string;
  description?: string;
  children: React.ReactNode;
}

export function PageContainer({ title, description, children }: PageContainerProps) {
  return (
    <div className="container mx-auto py-8 px-6 max-w-4xl">
      <div className="mb-8">
        <h1 className="text-3xl font-bold">{title}</h1>
        {description && (
          <p className="text-muted-foreground mt-2">{description}</p>
        )}
      </div>
      {children}
    </div>
  );
}

更新文章创作页面 src/app/articles/create/page.tsx

typescript 复制代码
import { PageContainer } from "@/components/layout/page-container";

export default function CreateArticle() {
  return (
    <PageContainer
      title="文章创作"
      description="输入标题,AI 帮你写文章"
    >
      <p className="text-muted-foreground">文章创作功能即将上线...</p>
    </PageContainer>
  );
}

更新古诗词页面 src/app/poems/page.tsx

typescript 复制代码
import { PageContainer } from "@/components/layout/page-container";

export default function Poems() {
  return (
    <PageContainer
      title="古诗词生成"
      description="输入名词,AI 创作古诗词"
    >
      <p className="text-muted-foreground">古诗词生成功能即将上线...</p>
    </PageContainer>
  );
}

3.7 响应式设计

当前布局在手机上侧边栏会挤占太多空间。我们加上响应式处理。

修改 src/components/layout/sidebar.tsx,在顶部加上移动端菜单:

typescript 复制代码
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { useState } from "react";

const navItems = [
  { label: "首页", href: "/", icon: "🏠" },
  { label: "文章创作", href: "/articles/create", icon: "✍️" },
  { label: "古诗词", href: "/poems", icon: "📜" },
  { label: "风格文库", href: "/styles", icon: "📚" },
  { label: "历史记录", href: "/history", icon: "📋" },
];

export function Sidebar() {
  const pathname = usePathname();
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      {/* 移动端顶部栏 */}
      <div className="md:hidden fixed top-0 left-0 right-0 z-50 bg-card border-b p-4 flex items-center justify-between">
        <Link href="/" className="flex items-center gap-2">
          <span className="text-2xl">🐱</span>
          <span className="font-bold text-primary">话喵</span>
        </Link>
        <button
          onClick={() => setIsOpen(!isOpen)}
          className="p-2 hover:bg-muted rounded-lg"
        >
          {isOpen ? "✕" : "☰"}
        </button>
      </div>

      {/* 遮罩层 */}
      {isOpen && (
        <div
          className="md:hidden fixed inset-0 z-40 bg-black/50"
          onClick={() => setIsOpen(false)}
        />
      )}

      {/* 侧边栏 */}
      <aside
        className={cn(
          "fixed md:static inset-y-0 left-0 z-50 w-64 border-r bg-card flex flex-col transition-transform md:translate-x-0",
          isOpen ? "translate-x-0" : "-translate-x-full"
        )}
      >
        {/* Logo */}
        <div className="p-6 border-b">
          <Link
            href="/"
            className="flex items-center gap-2"
            onClick={() => setIsOpen(false)}
          >
            <span className="text-3xl">🐱</span>
            <div>
              <h1 className="text-xl font-bold text-primary">话喵</h1>
              <p className="text-xs text-muted-foreground">AI 智能创作平台</p>
            </div>
          </Link>
        </div>

        {/* 导航菜单 */}
        <nav className="flex-1 p-4 space-y-1">
          {navItems.map((item) => {
            const isActive = pathname === item.href;
            return (
              <Link
                key={item.href}
                href={item.href}
                onClick={() => setIsOpen(false)}
                className={cn(
                  "flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors",
                  isActive
                    ? "bg-primary/10 text-primary font-medium"
                    : "text-muted-foreground hover:bg-muted hover:text-foreground"
                )}
              >
                <span className="text-lg">{item.icon}</span>
                {item.label}
              </Link>
            );
          })}
        </nav>

        {/* 底部信息 */}
        <div className="p-4 border-t">
          <div className="text-xs text-muted-foreground">
            <p>Powered by DeepSeek</p>
            <p className="mt-1">v0.1.0</p>
          </div>
        </div>
      </aside>
    </>
  );
}

同时修改 src/app/layout.tsx,给主内容区加上移动端的顶部间距:

typescript 复制代码
// 修改 body 内的布局
<body className={inter.className}>
  <div className="flex h-screen">
    <Sidebar />
    <main className="flex-1 overflow-y-auto pt-16 md:pt-0">
      {children}
    </main>
  </div>
  <Toaster />
</body>

本章小结

概念 说明
Tailwind CSS 原子化 CSS,一个类名对应一个 CSS 属性
shadcn/ui 组件代码复制到你的项目,完全可定制
CSS 变量主题 用 HSL 值定义颜色,支持暗色模式
cn() 函数 合并 Tailwind 类名,避免冲突
响应式设计 md: 前缀表示在中等屏幕以上生效

动手验证

  1. 启动 pnpm dev,看到话喵的布局(左侧导航 + 右侧内容)
  2. 点击导航栏切换页面,观察高亮状态
  3. 缩小浏览器窗口,侧边栏自动隐藏,出现汉堡菜单
  4. 点击汉堡菜单,侧边栏从左侧滑出
  5. 首页显示三个功能卡片,点击可跳转

下一章预告

我们将第一次调用 AI API ------ 用 DeepSeek 的大模型生成一段文字。你将理解 LLM API 的调用方式、Token 的概念、以及如何处理 API 错误。


如果这个教程对你有帮助,欢迎 ⭐ Star 支持一下!