AI 全栈开发实战(7):前端开发(一)——搭建 KNow 页面框架与核心页面

前言

前面六篇完成了后端核心功能:用户系统、知识库管理、文档处理、RAG 问答。从今天开始进入前端开发阶段,把后端能力转化为用户可以使用的界面。

本篇搭建前端页面框架,完成仪表盘、登录、知识库管理等核心页面的开发。

1. 前端项目结构

复制代码
frontend/src/
├── main.tsx                    # 入口
├── App.tsx                     # 路由配置
├── index.css                   # Tailwind 样式
├── lib/
│   ├── api.ts                  # Axios 实例 + 拦截器
│   └── utils.ts                # 工具函数
├── hooks/
│   ├── useAuth.tsx             # 认证状态
│   └── useChat.ts              # 流式对话
├── api/
│   ├── auth.ts                 # 认证 API
│   ├── knowledgeBase.ts        # 知识库 API
│   └── chat.ts                 # 对话 API
├── components/
│   ├── Layout.tsx              # 布局组件
│   ├── ProtectedRoute.tsx      # 路由保护
│   └── ui/                    # shadcn/ui 组件
├── pages/
│   ├── Login.tsx               # 登录页
│   ├── Register.tsx            # 注册页
│   ├── Dashboard.tsx           # 仪表盘
│   ├── KnowledgeBaseDetail.tsx # 知识库详情
│   └── Chat.tsx                # 对话页
└── types/
    └── index.ts                # 类型定义

2. 布局组件

tsx 复制代码
// frontend/src/components/Layout.tsx
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";

export default function Layout() {
  const { user, logout } = useAuth();
  const location = useLocation();
  const navigate = useNavigate();

  const navLinks = [
    { path: "/dashboard", label: "知识库", icon: "📚" },
    { path: "/chat", label: "对话", icon: "💬" },
  ];

  return (
    <div className="min-h-screen bg-gray-50">
      {/* Top Nav */}
      <header className="bg-white border-b sticky top-0 z-50">
        <div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
          <div className="flex items-center gap-6">
            <Link to="/dashboard" className="font-bold text-blue-600 text-lg">
              KNow
            </Link>
            <nav className="hidden md:flex items-center gap-1">
              {navLinks.map((link) => (
                <Link
                  key={link.path}
                  to={link.path}
                  className={`px-3 py-1.5 rounded-md text-sm transition ${
                    location.pathname.startsWith(link.path)
                      ? "bg-blue-50 text-blue-600 font-medium"
                      : "text-gray-600 hover:bg-gray-100"
                  }`}
                >
                  {link.icon} {link.label}
                </Link>
              ))}
            </nav>
          </div>

          <div className="flex items-center gap-3">
            <Link to="/bookmarks" className="text-sm text-gray-500 hover:text-gray-700">
              🔖 收藏
            </Link>
            {user ? (
              <div className="flex items-center gap-2">
                <span className="text-sm text-gray-600">{user.nickname}</span>
                <Button variant="outline" size="sm" onClick={logout}>
                  退出
                </Button>
              </div>
            ) : (
              <Button size="sm" onClick={() => navigate("/login")}>
                登录
              </Button>
            )}
          </div>
        </div>
      </header>

      {/* Mobile Nav */}
      <div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t z-50">
        <div className="flex justify-around py-2">
          {navLinks.map((link) => (
            <Link
              key={link.path}
              to={link.path}
              className={`flex flex-col items-center px-3 py-1 text-xs ${
                location.pathname.startsWith(link.path)
                  ? "text-blue-600"
                  : "text-gray-500"
              }`}
            >
              <span className="text-lg">{link.icon}</span>
              <span>{link.label}</span>
            </Link>
          ))}
        </div>
      </div>

      {/* Main Content */}
      <main className="pb-16 md:pb-0">
        <Outlet />
      </main>
    </div>
  );
}

3. 路由保护

tsx 复制代码
// frontend/src/components/ProtectedRoute.tsx
import { Navigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { user, isLoading } = useAuth();

  if (isLoading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="animate-spin h-8 w-8 border-4 border-blue-600 border-t-transparent rounded-full" />
      </div>
    );
  }

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
}

4. 仪表盘页面

tsx 复制代码
// frontend/src/pages/Dashboard.tsx
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import {
  listKnowledgeBases,
  createKnowledgeBase,
  deleteKnowledgeBase,
  KnowledgeBase,
} from "@/api/knowledgeBase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";

export default function Dashboard() {
  const { user } = useAuth();
  const navigate = useNavigate();
  const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
  const [loading, setLoading] = useState(true);
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const [desc, setDesc] = useState("");

  const load = async () => {
    setLoading(true);
    try {
      const res = await listKnowledgeBases();
      setKbs(res.items);
    } catch (e) {
      console.error(e);
    }
    setLoading(false);
  };

  useEffect(() => {
    load();
  }, []);

  const handleCreate = async () => {
    if (!name.trim()) return;
    await createKnowledgeBase({ name, description: desc });
    setOpen(false);
    setName("");
    setDesc("");
    load();
  };

  const handleDelete = async (id: string) => {
    if (!confirm("确定删除?文档也会被删除。")) return;
    await deleteKnowledgeBase(id);
    load();
  };

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <div className="flex items-center justify-between mb-8">
        <div>
          <h1 className="text-2xl font-bold">我的知识库</h1>
          <p className="text-sm text-gray-500 mt-1">
            欢迎回来,{user?.nickname}
          </p>
        </div>
        <Dialog open={open} onOpenChange={setOpen}>
          <DialogTrigger asChild>
            <Button>新建知识库</Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>新建知识库</DialogTitle>
            </DialogHeader>
            <div className="space-y-4 pt-4">
              <Input
                placeholder="知识库名称"
                value={name}
                onChange={(e) => setName(e.target.value)}
              />
              <Textarea
                placeholder="描述(可选)"
                value={desc}
                onChange={(e) => setDesc(e.target.value)}
              />
              <Button onClick={handleCreate} className="w-full">
                创建
              </Button>
            </div>
          </DialogContent>
        </Dialog>
      </div>

      {loading ? (
        <div className="text-center py-20 text-gray-400">加载中...</div>
      ) : kbs.length === 0 ? (
        <div className="text-center py-20 border-2 border-dashed rounded-xl">
          <div className="text-5xl mb-4">📚</div>
          <h3 className="text-lg font-medium text-gray-600">
            还没有知识库
          </h3>
          <p className="text-sm text-gray-400 mt-1">
          创建一个知识库,开始上传文档
          </p>
          <Button className="mt-4" onClick={() => setOpen(true)}>
            新建知识库
          </Button>
        </div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {kbs.map((kb) => (
            <Card
              key={kb.id}
              className="cursor-pointer hover:shadow-md transition"
              onClick={() => navigate(`/knowledge-bases/${kb.id}`)}
            >
              <CardContent className="p-5">
                <h3 className="font-semibold text-gray-900">{kb.name}</h3>
                <p className="text-sm text-gray-500 mt-1 line-clamp-2">
                  {kb.description || "暂无描述"}
                </p>
                <div className="flex items-center justify-between mt-4 text-xs text-gray-400">
                  <span>{kb.document_count} 个文档</span>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={(e) => {
                      e.stopPropagation();
                      handleDelete(kb.id);
                    }}
                  >
                    删除
                  </Button>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}

5. 知识库详情页

tsx 复制代码
// frontend/src/pages/KnowledgeBaseDetail.tsx
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
  listDocuments,
  uploadDocument,
  deleteDocument,
  Document,
} from "@/api/knowledgeBase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";

const STATUS_LABEL: Record<string, string> = {
  pending: "等待处理",
  processing: "处理中",
  ready: "已完成",
  failed: "处理失败",
};

const STATUS_COLOR: Record<string, string> = {
  pending: "bg-yellow-100 text-yellow-700",
  processing: "bg-blue-100 text-blue-700",
  ready: "bg-green-100 text-green-700",
  failed: "bg-red-100 text-red-700",
};

export default function KnowledgeBaseDetail() {
  const { id } = useParams();
  const navigate = useNavigate();
  const [docs, setDocs] = useState<Document[]>([]);
  const [loading, setLoading] = useState(true);
  const [uploading, setUploading] = useState(false);
  const fileRef = useRef<HTMLInputElement>(null);

  const load = async () => {
    if (!id) return;
    setLoading(true);
    try {
      const res = await listDocuments(id);
      setDocs(res.items);
    } catch (e) {
      console.error(e);
    }
    setLoading(false);
  };

  useEffect(() => {
    load();
  }, [id]);

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files?.length || !id) return;
    setUploading(true);
    for (const file of Array.from(files)) {
      try {
        await uploadDocument(id, file);
      } catch (err) {
        console.error("Upload failed:", file.name, err);
      }
    }
    setUploading(false);
    load();
    if (fileRef.current) fileRef.current.value = "";
  };

  const handleDelete = async (docId: string) => {
    if (!id || !confirm("确定删除?")) return;
    await deleteDocument(id, docId);
    load();
  };

  const formatSize = (bytes: number) => {
    if (bytes < 1024) return bytes + " B";
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
    return (bytes / 1024 / 1024).toFixed(1) + " MB";
  };

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <div className="flex items-center justify-between mb-6">
        <div>
          <button
            onClick={() => navigate("/dashboard")}
            className="text-sm text-gray-400 hover:text-gray-600 mb-1 block"
          >
            ← 返回
          </button>
          <h1 className="text-2xl font-bold">文档管理</h1>
        </div>
        <div className="flex gap-3">
          <Button variant="outline" onClick={() => navigate(`/chat?kb=${id}`)}>
            💬 开始问答
          </Button>
          <Button disabled={uploading} onClick={() => fileRef.current?.click()}>
            {uploading ? "上传中..." : "上传文档"}
          </Button>
          <input
            type="file"
            ref={fileRef}
            className="hidden"
            multiple
            accept=".pdf,.txt,.md,.docx"
            onChange={handleUpload}
          />
        </div>
      </div>

      {loading ? (
        <div className="text-center py-20 text-gray-400">加载中...</div>
      ) : docs.length === 0 ? (
        <div className="text-center py-20 border-2 border-dashed rounded-xl">
          <div className="text-5xl mb-4">📄</div>
          <h3 className="text-lg font-medium text-gray-600">还没有文档</h3>
          <p className="text-sm text-gray-400 mt-1">
            上传 PDF、TXT、MD 或 DOCX 文件
          </p>
          <Button className="mt-4" onClick={() => fileRef.current?.click()}>
            上传第一个文档
          </Button>
        </div>
      ) : (
        <div className="space-y-2">
          {docs.map((doc) => (
            <Card key={doc.id}>
              <CardContent className="flex items-center justify-between py-3 px-4">
                <div className="flex items-center gap-3">
                  <span className="text-xl">
                    {doc.file_type === "pdf"
                      ? "📕"
                      : doc.file_type === "md"
                      ? "📝"
                      : "📄"}
                  </span>
                  <div>
                    <p className="text-sm font-medium">{doc.filename}</p>
                    <p className="text-xs text-gray-400">
                      {formatSize(doc.file_size)} · {doc.chunk_count} 个片段
                    </p>
                  </div>
                </div>
                <div className="flex items-center gap-2">
                  <span
                    className={`text-xs px-2 py-1 rounded-full ${
                      STATUS_COLOR[doc.status]
                    }`}
                  >
                    {STATUS_LABEL[doc.status]}
                  </span>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleDelete(doc.id)}
                  >
                    删除
                  </Button>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}

6. 路由配置

tsx 复制代码
// frontend/src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Layout from "@/components/Layout";
import ProtectedRoute from "@/components/ProtectedRoute";
import Login from "@/pages/Login";
import Register from "@/pages/Register";
import Dashboard from "@/pages/Dashboard";
import KnowledgeBaseDetail from "@/pages/KnowledgeBaseDetail";
import Chat from "@/pages/Chat";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <BrowserRouter>
          <Routes>
            <Route path="/login" element={<Login />} />
            <Route path="/register" element={<Register />} />
            <Route element={<Layout />}>
              <Route
                path="/dashboard"
                element={
                  <ProtectedRoute>
                    <Dashboard />
                  </ProtectedRoute>
                }
              />
              <Route
                path="/knowledge-bases/:id"
                element={
                  <ProtectedRoute>
                    <KnowledgeBaseDetail />
                  </ProtectedRoute>
                }
              />
              <Route
                path="/chat"
                element={
                  <ProtectedRoute>
                    <Chat />
                  </ProtectedRoute>
                }
              />
            </Route>
            <Route path="*" element={<Navigate to="/dashboard" />} />
          </Routes>
        </BrowserRouter>
      </AuthProvider>
    </QueryClientProvider>
  );
}

export default App;

7. 入口文件

tsx 复制代码
// frontend/src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

8. 样式文件

css 复制代码
/* frontend/src/index.css */
@import "tailwindcss";

@layer base {
  body {
    @apply bg-gray-50 text-gray-900 antialiased;
  }
}

9. 前后端联调

配置 Vite 代理,让前端开发时能调用后端 API:

typescript 复制代码
// frontend/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
  server: {
    port: 3000,
    proxy: {
      "/api": {
        target: "http://localhost:8000",
        changeOrigin: true,
      },
    },
  },
});

Docker 环境下代理指向后端容器:

typescript 复制代码
proxy: {
  "/api": {
    target: "http://backend:8000",
    changeOrigin: true,
  },
}

10. 验证

bash 复制代码
# 启动前端开发服务器
cd frontend && npm run dev

# 访问
open http://localhost:3000

# 验证流程
# 1. 访问 /dashboard → 自动跳转到 /login
# 2. 注册一个新账号
# 3. 创建知识库 → 看到卡片列表
# 4. 点击知识库 → 进入文档管理
# 5. 上传文档 → 看到状态变化

总结

今天完成了前端页面框架的搭建:

组件 功能
Layout 顶部导航 + 底部移动端导航 + 响应式布局
ProtectedRoute 未登录自动跳转登录页
Dashboard 知识库列表 + 新建/删除
KnowledgeBaseDetail 文档管理 + 上传/删除/状态
App 路由配置 + Auth/Query Provider

下一篇我们继续前端开发------打造流式对话界面,实现打字机效果和完整的对话体验。


本文是 《AI 全栈开发实战------做一个真正的产品》 系列的第 7 篇。

系列目录:

1-6. ✅ 后端核心功能

  1. ✅ 前端开发(一)------页面框架 ← 你在这里

  2. 📝 前端开发(二)------对话界面

...

本文由 Zyentor(智元界) 原创发布


本文发布于 Zyentor(智元界) ------ AI 开发者社区

原文链接:https://www.zyentor.com/news/3790