前言
前面六篇完成了后端核心功能:用户系统、知识库管理、文档处理、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. ✅ 后端核心功能
✅ 前端开发(一)------页面框架 ← 你在这里
📝 前端开发(二)------对话界面
...
本文由 Zyentor(智元界) 原创发布
本文发布于 Zyentor(智元界) ------ AI 开发者社区