Initial commit: 国土空间规划课程智能体 v1.0
单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { BookOpen, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { FlickeringGrid } from "@/components/magicui/flickering-grid";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "用户名不能为空"),
|
||||
password: z.string().min(1, "密码不能为空"),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { login, error, clearError } = useAuthStore();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
|
||||
const success = await login(data);
|
||||
|
||||
if (success) {
|
||||
// 登录成功后回到主页,让用户看到无缝过渡效果
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app relative overflow-hidden">
|
||||
{/* 背景动画 */}
|
||||
<FlickeringGrid
|
||||
className="absolute inset-0 z-0 [mask-image:radial-gradient(800px_circle_at_center,white,transparent)]"
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color="#60A5FA"
|
||||
maxOpacity={0.1}
|
||||
flickerChance={0.05}
|
||||
/>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-shadow">
|
||||
<BookOpen className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">
|
||||
国土空间规划课程智能体
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-lg">登录您的账户</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">登录</CardTitle>
|
||||
<CardDescription>
|
||||
输入您的用户名和密码以访问系统
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 用户名输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
{...register("username")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-600">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码"
|
||||
{...register("password")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
"登录"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 注册链接 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
还没有账户?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 快速登录提示 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
首次使用?请先注册账户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { BookOpen, Eye, EyeOff, Loader2, CheckCircle } from "lucide-react";
|
||||
import { FlickeringGrid } from "@/components/magicui/flickering-grid";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(3, "用户名至少3个字符").max(20, "用户名最多20个字符"),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().min(6, "密码至少6个字符"),
|
||||
confirmPassword: z.string(),
|
||||
full_name: z.string().optional(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "密码不匹配",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { register: registerUser, error, clearError } = useAuthStore();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
|
||||
const success = await registerUser({
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
full_name: data.full_name,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
router.push("/chat");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app relative overflow-hidden">
|
||||
{/* 背景动画 */}
|
||||
<FlickeringGrid
|
||||
className="absolute inset-0 z-0 [mask-image:radial-gradient(800px_circle_at_center,white,transparent)]"
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color="#60A5FA"
|
||||
maxOpacity={0.1}
|
||||
flickerChance={0.05}
|
||||
/>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-shadow">
|
||||
<BookOpen className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">
|
||||
国土空间规划课程智能体
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-lg">创建您的账户</p>
|
||||
</div>
|
||||
|
||||
{/* 注册表单 */}
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">注册</CardTitle>
|
||||
<CardDescription>
|
||||
填写以下信息创建您的账户
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 用户名输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名 *</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
{...register("username")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-600">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 邮箱输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱 *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
{...register("email")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 姓名输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="full_name">姓名(可选)</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
type="text"
|
||||
placeholder="请输入您的姓名"
|
||||
{...register("full_name")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<p className="text-sm text-red-600">{errors.full_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码"
|
||||
{...register("password")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认密码输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="请再次输入密码"
|
||||
{...register("confirmPassword")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 注册按钮 */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
注册
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 登录链接 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
已有账户?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 注册提示 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
注册即表示您同意我们的服务条款和隐私政策
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Calendar,
|
||||
Target,
|
||||
Lightbulb,
|
||||
Download,
|
||||
Share2
|
||||
} from "lucide-react";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import { analyticsAPI } from "@/lib/api";
|
||||
|
||||
interface Statistics {
|
||||
total_sessions: number;
|
||||
total_messages: number;
|
||||
total_documents: number;
|
||||
active_days: number;
|
||||
popular_questions: Array<{
|
||||
question: string;
|
||||
count: number;
|
||||
category: string;
|
||||
}>;
|
||||
knowledge_coverage: Array<{
|
||||
topic: string;
|
||||
coverage: number;
|
||||
questions: number;
|
||||
}>;
|
||||
learning_trends: Array<{
|
||||
date: string;
|
||||
messages: number;
|
||||
sessions: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LearningReport {
|
||||
user_id: number;
|
||||
total_questions: number;
|
||||
topics_covered: string[];
|
||||
learning_progress: number;
|
||||
recommendations: string[];
|
||||
study_time: number;
|
||||
knowledge_gaps: string[];
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
const [learningReport, setLearningReport] = useState<LearningReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadAnalyticsData();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 调用真实API获取完整分析数据
|
||||
const response = await analyticsAPI.getFullAnalytics();
|
||||
|
||||
if (response.success) {
|
||||
const { statistics, learning_trends, popular_questions, knowledge_coverage, learning_report } = response.data;
|
||||
|
||||
// 构建Statistics对象
|
||||
const statisticsData: Statistics = {
|
||||
total_sessions: statistics.total_sessions,
|
||||
total_messages: statistics.total_messages,
|
||||
total_documents: statistics.total_documents,
|
||||
active_days: statistics.active_days,
|
||||
popular_questions: popular_questions,
|
||||
knowledge_coverage: knowledge_coverage,
|
||||
learning_trends: learning_trends
|
||||
};
|
||||
|
||||
// 构建LearningReport对象
|
||||
const learningReportData: LearningReport = {
|
||||
user_id: learning_report.user_id,
|
||||
total_questions: learning_report.total_questions,
|
||||
topics_covered: learning_report.topics_covered,
|
||||
learning_progress: learning_report.learning_progress,
|
||||
recommendations: learning_report.recommendations,
|
||||
study_time: learning_report.study_time,
|
||||
knowledge_gaps: learning_report.knowledge_gaps
|
||||
};
|
||||
|
||||
setStatistics(statisticsData);
|
||||
setLearningReport(learningReportData);
|
||||
} else {
|
||||
throw new Error("API返回失败");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("加载分析数据失败:", err);
|
||||
// 如果API调用失败,使用模拟数据作为后备
|
||||
const mockStatistics: Statistics = {
|
||||
total_sessions: 0,
|
||||
total_messages: 0,
|
||||
total_documents: 0,
|
||||
active_days: 0,
|
||||
popular_questions: [],
|
||||
knowledge_coverage: [],
|
||||
learning_trends: []
|
||||
};
|
||||
|
||||
const mockLearningReport: LearningReport = {
|
||||
user_id: 0,
|
||||
total_questions: 0,
|
||||
topics_covered: [],
|
||||
learning_progress: 0,
|
||||
recommendations: ["请先开始学习以获取分析数据"],
|
||||
study_time: 0,
|
||||
knowledge_gaps: []
|
||||
};
|
||||
|
||||
setStatistics(mockStatistics);
|
||||
setLearningReport(mockLearningReport);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}小时${mins}分钟`;
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">学习分析</h1>
|
||||
<p className="text-muted-foreground">了解您的学习进度和知识掌握情况</p>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex space-x-1 mb-8">
|
||||
{[
|
||||
{ id: "overview", name: "总览", icon: BarChart3 },
|
||||
{ id: "progress", name: "学习进度", icon: TrendingUp },
|
||||
{ id: "knowledge", name: "知识图谱", icon: BookOpen }
|
||||
].map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
variant={activeTab === tab.id ? "default" : "outline"}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="flex items-center"
|
||||
>
|
||||
<tab.icon className="w-4 h-4 mr-2" />
|
||||
{tab.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 总览标签页 */}
|
||||
{activeTab === "overview" && statistics && (
|
||||
<div className="space-y-6">
|
||||
{/* 关键指标 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare className="w-8 h-8 text-blue-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">对话会话</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.total_sessions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<BookOpen className="w-8 h-8 text-green-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">消息总数</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.total_messages}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-8 h-8 text-purple-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">文档数量</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.total_documents}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-8 h-8 text-orange-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">活跃天数</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.active_days}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 知识覆盖度 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>知识覆盖度</CardTitle>
|
||||
<CardDescription>各主题的学习覆盖情况</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{statistics.knowledge_coverage.map((item, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">{item.topic}</span>
|
||||
<span className="text-sm text-muted-foreground">{item.coverage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${item.coverage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{item.questions} 个问题</span>
|
||||
<span>{item.coverage}% 掌握度</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 热门问题 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>热门问题</CardTitle>
|
||||
<CardDescription>您最常问的问题</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{statistics.popular_questions.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.question}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.category}</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-blue-600">{item.count} 次</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 学习进度标签页 */}
|
||||
{activeTab === "progress" && learningReport && (
|
||||
<div className="space-y-6">
|
||||
{/* 学习进度 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>学习进度</CardTitle>
|
||||
<CardDescription>您的整体学习进度</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-medium text-foreground">总体进度</span>
|
||||
<span className="text-2xl font-bold text-blue-600">{learningReport.learning_progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-4">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
|
||||
style={{ width: `${learningReport.learning_progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">学习时间:</span>
|
||||
<span className="font-medium text-foreground">{formatTime(learningReport.study_time)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">问题总数:</span>
|
||||
<span className="font-medium text-foreground">{learningReport.total_questions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 学习建议 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Lightbulb className="w-5 h-5 mr-2" />
|
||||
学习建议
|
||||
</CardTitle>
|
||||
<CardDescription>基于您的学习情况,我们为您提供以下建议</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{learningReport.recommendations.map((recommendation, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 bg-blue-500/10 dark:bg-blue-500/20 rounded-lg">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full mt-2 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">{recommendation}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 知识缺口 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Target className="w-5 h-5 mr-2" />
|
||||
知识缺口
|
||||
</CardTitle>
|
||||
<CardDescription>需要重点关注的学习领域</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{learningReport.knowledge_gaps.map((gap, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 p-3 bg-orange-500/10 dark:bg-orange-500/20 rounded-lg">
|
||||
<div className="w-2 h-2 bg-orange-600 rounded-full mt-2 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">{gap}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识图谱标签页 */}
|
||||
{activeTab === "knowledge" && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>知识图谱</CardTitle>
|
||||
<CardDescription>您的知识结构可视化</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<BookOpen className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">知识图谱</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
知识图谱功能正在开发中,将展示您的知识结构和学习路径
|
||||
</p>
|
||||
<Button variant="outline">
|
||||
即将推出
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end space-x-4 mt-8">
|
||||
<Button variant="outline" className="flex items-center">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出报告
|
||||
</Button>
|
||||
<Button variant="outline" className="flex items-center">
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
分享
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import ChatInterface from "@/components/chat/chat-interface";
|
||||
import Sidebar from "@/components/chat/sidebar";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function ChatPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
|
||||
const { loadSessions, isLoading: chatLoading } = useChatStore();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated && !isInitialized) {
|
||||
// 加载聊天会话
|
||||
loadSessions();
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
|
||||
|
||||
if (authLoading || chatLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] bg-background flex">
|
||||
{/* 侧边栏 */}
|
||||
<div className="w-80 border-r bg-card/50 flex-shrink-0">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* 主聊天区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 禁用静态生成
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Loader2,
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
Lightbulb,
|
||||
Target,
|
||||
Network,
|
||||
List
|
||||
} from "lucide-react";
|
||||
import { courseContentAPI } from "@/lib/api";
|
||||
import { BookStructure } from "@/types";
|
||||
import dynamicImport from "next/dynamic";
|
||||
import NodeDetailDialog from "@/components/course-content/node-detail-dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText, ChevronRight } from "lucide-react";
|
||||
|
||||
// 动态导入 KnowledgeGraph 组件,禁用 SSR
|
||||
const KnowledgeGraph = dynamicImport(
|
||||
() => import("@/components/course-content/knowledge-graph"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function CourseContentPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const [bookStructure, setBookStructure] = useState<BookStructure | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'list'>('graph');
|
||||
const [selectedNode, setSelectedNode] = useState<{
|
||||
type: 'chapter' | 'section' | 'subsection';
|
||||
id: number;
|
||||
title: string;
|
||||
subsections?: Array<{ id: number; title: string }>;
|
||||
} | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadCourseContent();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
const loadCourseContent = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const structure = await courseContentAPI.getCourseContent();
|
||||
console.log("加载的书籍结构:", structure);
|
||||
console.log("章节数量:", structure?.chapters?.length || 0);
|
||||
if (structure?.chapters) {
|
||||
structure.chapters.forEach((chapter, index) => {
|
||||
console.log(`章节 ${index + 1}:`, chapter.title, "节数:", chapter.sections?.length || 0);
|
||||
});
|
||||
}
|
||||
setBookStructure(structure);
|
||||
} catch (err) {
|
||||
console.error("加载课程内容失败:", err);
|
||||
setError("加载课程内容失败: " + (err instanceof Error ? err.message : String(err)));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<GraduationCap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">课程内容</h1>
|
||||
<p className="text-muted-foreground">探索国土空间规划课程的核心知识点</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 视图切换 */}
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'graph' | 'list')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="graph" className="flex items-center space-x-2">
|
||||
<Network className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">知识图谱</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list" className="flex items-center space-x-2">
|
||||
<List className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">列表视图</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 课程内容展示 */}
|
||||
{!bookStructure ? (
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardContent className="text-center py-12">
|
||||
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">暂无课程内容</h3>
|
||||
<p className="text-muted-foreground">
|
||||
课程内容正在准备中,请稍后再来查看
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* 知识图谱视图 */}
|
||||
{viewMode === 'graph' && (
|
||||
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-border bg-background overflow-hidden">
|
||||
<KnowledgeGraph bookStructure={bookStructure} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 列表视图 */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-6">
|
||||
{bookStructure.chapters.map((chapter) => (
|
||||
<Card
|
||||
key={chapter.id}
|
||||
className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl mb-3 flex items-center space-x-2">
|
||||
<BookOpen className="w-5 h-5 text-blue-600" />
|
||||
<span>第{chapter.chapter_number}章 {chapter.title}</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedNode({
|
||||
type: 'chapter',
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>查看内容</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{chapter.sections.map((section) => (
|
||||
<AccordionItem key={section.id} value={`section-${section.id}`}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<AccordionTrigger className="text-base font-medium flex-1">
|
||||
<span>{section.title}</span>
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedNode({
|
||||
type: 'section',
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
subsections: section.subsections,
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="mr-2 flex items-center space-x-1"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="text-xs">查看</span>
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<div className="pt-2 pl-4 space-y-2">
|
||||
{section.subsections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无小节(知识点)</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground mb-2">
|
||||
小节(知识点) ({section.subsections.length} 个)
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{section.subsections.map((subsection) => (
|
||||
<li
|
||||
key={subsection.id}
|
||||
className="flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-start space-x-2 flex-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-600 mt-2 flex-shrink-0" />
|
||||
<span className="flex-1 text-sm text-muted-foreground">{subsection.title}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedNode({
|
||||
type: 'subsection',
|
||||
id: subsection.id,
|
||||
title: subsection.title,
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity ml-2 flex items-center space-x-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
<span className="text-xs">查看</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 节点详情对话框 */}
|
||||
{selectedNode && (
|
||||
<NodeDetailDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
nodeType={selectedNode.type === 'subsection' ? 'section' : selectedNode.type}
|
||||
nodeId={selectedNode.type === 'subsection' ? 0 : selectedNode.id} // nodeId is not used for direct subsection display
|
||||
nodeTitle={selectedNode.title}
|
||||
subsections={selectedNode.type === 'section' ? selectedNode.subsections : undefined}
|
||||
subsectionId={selectedNode.type === 'subsection' ? selectedNode.id : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MessageSquare, Loader2, MessageCircle } from "lucide-react";
|
||||
|
||||
import { forumAPI } from "@/lib/api";
|
||||
import type { ForumCategory, ForumPostSummary } from "@/types";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function ForumHomePage() {
|
||||
const [categories, setCategories] = useState<ForumCategory[]>([]);
|
||||
const [categoryPosts, setCategoryPosts] = useState<Record<number, ForumPostSummary[]>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await forumAPI.getCategories();
|
||||
setCategories(data);
|
||||
const postsEntries = await Promise.all(
|
||||
data.map(async (category) => {
|
||||
try {
|
||||
const posts = await forumAPI.getPosts(category.id, 10);
|
||||
return [category.id, posts] as const;
|
||||
} catch (err) {
|
||||
console.warn(`加载分类 ${category.name} 的帖子失败:`, err);
|
||||
return [category.id, []] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
setCategoryPosts(Object.fromEntries(postsEntries));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "加载论坛数据失败";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mit-container py-10 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<MessageSquare className="h-7 w-7 text-primary" />
|
||||
课程社区
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
分享课程学习心得,交流系统使用体验,欢迎参与讨论。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载社区分类...
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{categories.map((category) => (
|
||||
<Card key={category.id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>{category.name}</CardTitle>
|
||||
{category.description && (
|
||||
<CardDescription>{category.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 h-full">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{category.post_count} 条讨论</span>
|
||||
<Button asChild size="sm" variant="ghost" className="px-2">
|
||||
<Link href={`/forum/${category.id}`}>进入讨论</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
{categoryPosts[category.id] && categoryPosts[category.id].length > 0 ? (
|
||||
categoryPosts[category.id].map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/forum/post/${post.id}`}
|
||||
className="block rounded-lg border border-border/60 hover:border-primary/50 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<div className="font-medium text-foreground line-clamp-1">
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center text-xs text-muted-foreground gap-2">
|
||||
<span>{post.author_name}</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(new Date(post.created_at).getTime() + 8 * 60 * 60 * 1000).toLocaleString()}</span>
|
||||
<span>·</span>
|
||||
<span>{post.reply_count} 条回复</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
暂无讨论,成为第一个发帖的人吧!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
ArrowLeft,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { formatFileSize, formatDate } from "@/lib/utils";
|
||||
import { knowledgeBaseAPI } from "@/lib/api";
|
||||
import { KnowledgeBaseDetail, Document } from "@/types";
|
||||
|
||||
export default function KnowledgeBaseDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 上传文档对话框状态
|
||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
|
||||
const [uploadTitle, setUploadTitle] = useState("");
|
||||
const [uploadDescription, setUploadDescription] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{[key: string]: number}>({});
|
||||
const [uploadErrors, setUploadErrors] = useState<{[key: string]: string}>({});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const knowledgeBaseId = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated && knowledgeBaseId) {
|
||||
loadKnowledgeBase();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, knowledgeBaseId, router]);
|
||||
|
||||
const loadKnowledgeBase = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const kb = await knowledgeBaseAPI.getKnowledgeBase(knowledgeBaseId);
|
||||
setKnowledgeBase(kb);
|
||||
} catch (err) {
|
||||
console.error("加载知识库失败:", err);
|
||||
setError("加载知识库失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!uploadFiles.length || !knowledgeBase) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setUploadProgress({});
|
||||
setUploadErrors({});
|
||||
|
||||
const uploadPromises = uploadFiles.map(async (file, index) => {
|
||||
const fileId = `${file.name}-${index}`;
|
||||
try {
|
||||
setUploadProgress(prev => ({ ...prev, [fileId]: 0 }));
|
||||
|
||||
await knowledgeBaseAPI.uploadDocument(
|
||||
knowledgeBaseId,
|
||||
file,
|
||||
file.name.replace(/\.[^/.]+$/, ""), // Use filename as title
|
||||
undefined // No description for batch uploads
|
||||
);
|
||||
|
||||
setUploadProgress(prev => ({ ...prev, [fileId]: 100 }));
|
||||
} catch (err) {
|
||||
console.error(`上传文件 ${file.name} 失败:`, err);
|
||||
setUploadErrors(prev => ({
|
||||
...prev,
|
||||
[fileId]: err instanceof Error ? err.message : "上传失败"
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// 重新加载知识库详情
|
||||
await loadKnowledgeBase();
|
||||
|
||||
// 重置表单并关闭对话框
|
||||
setUploadFiles([]);
|
||||
setUploadTitle("");
|
||||
setUploadDescription("");
|
||||
setIsUploadDialogOpen(false);
|
||||
|
||||
} catch (err) {
|
||||
console.error("批量上传失败:", err);
|
||||
setError("批量上传失败");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = Array.from(files);
|
||||
setUploadFiles(fileArray);
|
||||
if (!uploadTitle && fileArray.length === 1) {
|
||||
setUploadTitle(fileArray[0].name.replace(/\.[^/.]+$/, ""));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async (documentId: number) => {
|
||||
if (!confirm("确定要删除这个文档吗?")) return;
|
||||
|
||||
try {
|
||||
await knowledgeBaseAPI.deleteDocument(documentId.toString());
|
||||
await loadKnowledgeBase();
|
||||
} catch (err) {
|
||||
console.error("删除文档失败:", err);
|
||||
setError("删除文档失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDocument = (doc: Document) => {
|
||||
// Use the file_path from the document to construct the download URL
|
||||
// Since backend serves static files from /uploads, we can use the file_path directly
|
||||
const fileUrl = `/api/uploads/${doc.filename}`;
|
||||
window.open(fileUrl, '_blank');
|
||||
};
|
||||
|
||||
const filteredDocuments = knowledgeBase?.documents.filter(doc =>
|
||||
doc.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.filename.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) || [];
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-2">知识库不存在</h2>
|
||||
<p className="text-muted-foreground mb-4">该知识库可能已被删除或您没有访问权限</p>
|
||||
<Button onClick={() => router.push("/knowledge")}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回知识库列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/knowledge")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回
|
||||
</Button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<BookOpen className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">{knowledgeBase.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{knowledgeBase.description || "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>创建时间:{formatDate(knowledgeBase.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>共 {knowledgeBase.document_count} 个文档</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6">
|
||||
<div className="flex items-center space-x-4 mb-4 sm:mb-0">
|
||||
{/* 上传文档按钮 */}
|
||||
<Dialog
|
||||
open={isUploadDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsUploadDialogOpen(open);
|
||||
if (open) {
|
||||
// Reset file input when dialog opens
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
} else {
|
||||
// Reset states when dialog closes
|
||||
setUploadFiles([]);
|
||||
setUploadTitle("");
|
||||
setUploadDescription("");
|
||||
setUploadProgress({});
|
||||
setUploadErrors({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
上传文档
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传文档到"{knowledgeBase.name}"</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择要上传到该知识库的文档
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="file">选择文件(支持多选)</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".pdf,.docx,.txt,.md"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 已选择的文件列表 */}
|
||||
{uploadFiles.length > 0 && (
|
||||
<div>
|
||||
<Label>已选择的文件 ({uploadFiles.length} 个)</Label>
|
||||
<div className="mt-2 space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadFiles.map((file, index) => {
|
||||
const fileId = `${file.name}-${index}`;
|
||||
const progress = uploadProgress[fileId] || 0;
|
||||
const error = uploadErrors[fileId];
|
||||
|
||||
return (
|
||||
<div key={fileId} className="flex items-center justify-between p-2 border rounded-md">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 mt-1">{error}</div>
|
||||
)}
|
||||
{progress > 0 && progress < 100 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-1 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{progress === 100 && !error && (
|
||||
<div className="text-xs text-green-600 mt-1">✓ 上传完成</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUploadFiles(prev => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 单文件上传时显示标题和描述字段 */}
|
||||
{uploadFiles.length <= 1 && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="title">文档标题</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={uploadTitle}
|
||||
onChange={(e) => setUploadTitle(e.target.value)}
|
||||
placeholder="请输入文档标题"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">文档描述(可选)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={uploadDescription}
|
||||
onChange={(e) => setUploadDescription(e.target.value)}
|
||||
placeholder="请输入文档描述"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsUploadDialogOpen(false)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
disabled={!uploadFiles.length || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
上传 {uploadFiles.length} 个文件
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="搜索文档..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {filteredDocuments.length} 个文档
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文档列表 */}
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardContent className="text-center py-12">
|
||||
<FileText className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchQuery ? "没有找到匹配的文档" : "该知识库还没有文档"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchQuery ? "尝试使用其他关键词搜索" : "上传您的第一个文档到此知识库"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={() => setIsUploadDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
上传文档
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">状态</TableHead>
|
||||
<TableHead>文档标题</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>文件类型</TableHead>
|
||||
<TableHead>文件大小</TableHead>
|
||||
<TableHead>上传时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDocuments.map((doc) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell>
|
||||
{doc.is_processed ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<Clock className="w-5 h-5 text-yellow-600" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-4 h-4 text-blue-600" />
|
||||
<span>{doc.title}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-xs truncate">
|
||||
{doc.description || "暂无描述"}
|
||||
</TableCell>
|
||||
<TableCell>{doc.file_type}</TableCell>
|
||||
<TableCell>{formatFileSize(doc.file_size)}</TableCell>
|
||||
<TableCell>{formatDate(doc.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDocument(doc)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDocument(doc.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
Settings,
|
||||
FolderOpen
|
||||
} from "lucide-react";
|
||||
import { formatFileSize, formatDate } from "@/lib/utils";
|
||||
import { knowledgeBaseAPI } from "@/lib/api";
|
||||
import { KnowledgeBase } from "@/types";
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 创建知识库对话框状态
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadKnowledgeBases();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
const loadKnowledgeBases = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const bases = await knowledgeBaseAPI.getKnowledgeBases();
|
||||
// 过滤掉系统知识库,只显示用户创建的知识库
|
||||
const userBases = bases.filter(kb => !kb.is_system);
|
||||
setKnowledgeBases(userBases);
|
||||
} catch (err) {
|
||||
console.error("加载知识库失败:", err);
|
||||
setError("加载知识库失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKnowledgeBase = async () => {
|
||||
if (!createName.trim()) return;
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
await knowledgeBaseAPI.createKnowledgeBase({
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || undefined,
|
||||
});
|
||||
|
||||
// 重新加载知识库列表
|
||||
await loadKnowledgeBases();
|
||||
|
||||
// 重置表单并关闭对话框
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setIsCreateDialogOpen(false);
|
||||
|
||||
} catch (err) {
|
||||
console.error("创建知识库失败:", err);
|
||||
setError("创建知识库失败");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKnowledgeBase = async (knowledgeBaseId: number) => {
|
||||
if (!confirm("确定要删除这个知识库吗?这将删除其中的所有文档。")) return;
|
||||
|
||||
try {
|
||||
await knowledgeBaseAPI.deleteKnowledgeBase(knowledgeBaseId.toString());
|
||||
await loadKnowledgeBases();
|
||||
} catch (err) {
|
||||
console.error("删除知识库失败:", err);
|
||||
setError("删除知识库失败");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredKnowledgeBases = knowledgeBases.filter(kb =>
|
||||
kb.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(kb.description && kb.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">知识库管理</h1>
|
||||
<p className="text-muted-foreground">创建和管理您的知识库,组织文档内容</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6">
|
||||
<div className="flex items-center space-x-4 mb-4 sm:mb-0">
|
||||
{/* 创建知识库按钮 */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建知识库
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新知识库</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建一个新的知识库来组织您的文档
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="kb-name">知识库名称</Label>
|
||||
<Input
|
||||
id="kb-name"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="请输入知识库名称"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kb-description">描述(可选)</Label>
|
||||
<Input
|
||||
id="kb-description"
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="请输入知识库描述"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateKnowledgeBase}
|
||||
disabled={!createName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
"创建"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="搜索知识库..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {filteredKnowledgeBases.length} 个知识库
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 知识库列表 */}
|
||||
{filteredKnowledgeBases.length === 0 ? (
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardContent className="text-center py-12">
|
||||
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchQuery ? "没有找到匹配的知识库" : "还没有创建知识库"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchQuery ? "尝试使用其他关键词搜索" : "创建您的第一个知识库来组织文档"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建知识库
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredKnowledgeBases.map((kb) => (
|
||||
<Card key={kb.id} className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BookOpen className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">{kb.name}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{kb.description || "暂无描述"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{kb.document_count > 0 ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>文档数量</span>
|
||||
<span>{kb.document_count} 个文档</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>创建时间</span>
|
||||
<span>{formatDate(kb.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>状态</span>
|
||||
<span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}>
|
||||
{kb.document_count > 0 ? "已就绪" : "空知识库"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => router.push(`/knowledge/${kb.id}`)}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-1" />
|
||||
管理
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteKnowledgeBase(kb.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import Navbar from "@/components/home/navbar";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar isAuthenticated={isAuthenticated} user={user} />
|
||||
<main className="relative">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Loader2,
|
||||
User as UserIcon,
|
||||
Mail,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
MessageSquare,
|
||||
Database,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import { authAPI, analyticsAPI } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
full_name: z.string().optional(),
|
||||
});
|
||||
|
||||
type ProfileForm = z.infer<typeof profileSchema>;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading, user, setUser } = useAuthStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [statistics, setStatistics] = useState<{
|
||||
total_sessions: number;
|
||||
total_messages: number;
|
||||
total_documents: number;
|
||||
active_days: number;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<ProfileForm>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
full_name: user?.full_name || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadUserData();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setValue("email", user.email || "");
|
||||
setValue("full_name", user.full_name || "");
|
||||
}
|
||||
}, [user, setValue]);
|
||||
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 加载用户信息
|
||||
const userData = await authAPI.getCurrentUser();
|
||||
setUser(userData);
|
||||
|
||||
// 加载统计数据
|
||||
try {
|
||||
const stats = await analyticsAPI.getStatistics();
|
||||
setStatistics({
|
||||
total_sessions: stats.total_sessions || 0,
|
||||
total_messages: stats.total_messages || 0,
|
||||
total_documents: stats.total_documents || 0,
|
||||
active_days: stats.active_days || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("加载统计数据失败:", err);
|
||||
// 统计数据加载失败不影响页面显示
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("加载用户信息失败:", err);
|
||||
setError("加载用户信息失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ProfileForm) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const updatedUser = await authAPI.updateUserInfo({
|
||||
email: data.email,
|
||||
full_name: data.full_name || undefined,
|
||||
});
|
||||
|
||||
setUser(updatedUser);
|
||||
setIsEditing(false);
|
||||
setSuccess("个人信息更新成功");
|
||||
|
||||
// 3秒后清除成功消息
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
console.error("更新用户信息失败:", err);
|
||||
setError(err.message || "更新用户信息失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset({
|
||||
email: user?.email || "",
|
||||
full_name: user?.full_name || "",
|
||||
});
|
||||
setIsEditing(false);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initials = (user.full_name || user.username || "U").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">个人资料</h1>
|
||||
<p className="text-muted-foreground">管理您的个人信息和账户设置</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 成功提示 */}
|
||||
{success && (
|
||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
||||
{success}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左侧:用户头像和基本信息 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* 用户头像卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>头像</CardTitle>
|
||||
<CardDescription>您的账户头像</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="w-24 h-24 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
当前使用用户名首字母作为头像
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未来版本将支持上传自定义头像
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>基本信息</CardTitle>
|
||||
<CardDescription>您的账户基本信息</CardDescription>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 用户名(只读) */}
|
||||
<div>
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={user.username}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
用户名创建后无法修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 邮箱(可编辑) */}
|
||||
<div>
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register("email")}
|
||||
disabled={!isEditing}
|
||||
className={errors.email ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 真实姓名(可编辑) */}
|
||||
<div>
|
||||
<Label htmlFor="full_name">真实姓名</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
{...register("full_name")}
|
||||
disabled={!isEditing}
|
||||
placeholder="请输入您的真实姓名(可选)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 注册时间(只读) */}
|
||||
<div>
|
||||
<Label htmlFor="created_at">注册时间</Label>
|
||||
<Input
|
||||
id="created_at"
|
||||
value={formatDate(user.created_at)}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账户状态(只读) */}
|
||||
<div>
|
||||
<Label htmlFor="is_active">账户状态</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
id="is_active"
|
||||
value={user.is_active ? "已激活" : "未激活"}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
{user.is_active && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑模式下的按钮 */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:学习统计 */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>学习统计</CardTitle>
|
||||
<CardDescription>您的学习数据概览</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{statistics ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-sm font-medium">总对话数</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{statistics.total_sessions}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MessageSquare className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm font-medium">总消息数</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{statistics.total_messages}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Database className="w-5 h-5 text-purple-600" />
|
||||
<span className="text-sm font-medium">知识库</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">-</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileText className="w-5 h-5 text-orange-600" />
|
||||
<span className="text-sm font-medium">文档数</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{statistics.total_documents}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">加载统计数据中...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// 禁用静态生成
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
Shield,
|
||||
Palette,
|
||||
Key
|
||||
} from "lucide-react";
|
||||
import { authAPI } from "@/lib/api";
|
||||
|
||||
const passwordSchema = z.object({
|
||||
old_password: z.string().min(1, "请输入当前密码"),
|
||||
new_password: z.string().min(6, "密码至少6个字符"),
|
||||
confirm_password: z.string().min(1, "请确认新密码"),
|
||||
}).refine((data) => data.new_password === data.confirm_password, {
|
||||
message: "新密码与确认密码不匹配",
|
||||
path: ["confirm_password"],
|
||||
});
|
||||
|
||||
type PasswordForm = z.infer<typeof passwordSchema>;
|
||||
|
||||
// 密码强度检查函数
|
||||
function getPasswordStrength(password: string): {
|
||||
strength: "weak" | "medium" | "strong";
|
||||
label: string;
|
||||
color: string;
|
||||
score: number;
|
||||
} {
|
||||
if (!password) {
|
||||
return { strength: "weak", label: "", color: "", score: 0 };
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
if (password.length >= 6) score++;
|
||||
if (password.length >= 8) score++;
|
||||
if (/[a-z]/.test(password)) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
||||
|
||||
if (score <= 2) {
|
||||
return { strength: "weak", label: "弱", color: "text-red-600", score };
|
||||
} else if (score <= 4) {
|
||||
return { strength: "medium", label: "中", color: "text-yellow-600", score };
|
||||
} else {
|
||||
return { strength: "strong", label: "强", color: "text-green-600", score };
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const [showOldPassword, setShowOldPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<PasswordForm>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
});
|
||||
|
||||
const newPassword = watch("new_password");
|
||||
const passwordStrength = getPasswordStrength(newPassword || "");
|
||||
|
||||
const onSubmit = async (data: PasswordForm) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
await authAPI.changePassword({
|
||||
old_password: data.old_password,
|
||||
new_password: data.new_password,
|
||||
});
|
||||
|
||||
setSuccess("密码修改成功");
|
||||
reset();
|
||||
|
||||
// 3秒后清除成功消息
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
console.error("修改密码失败:", err);
|
||||
setError(err.message || "修改密码失败,请检查当前密码是否正确");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">设置</h1>
|
||||
<p className="text-muted-foreground">管理您的账户设置和偏好</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 成功提示 */}
|
||||
{success && (
|
||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
||||
{success}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 账户安全 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="w-5 h-5 text-blue-600" />
|
||||
<CardTitle>账户安全</CardTitle>
|
||||
</div>
|
||||
<CardDescription>修改您的登录密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 当前密码 */}
|
||||
<div>
|
||||
<Label htmlFor="old_password">当前密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showOldPassword ? "text" : "password"}
|
||||
{...register("old_password")}
|
||||
className={errors.old_password ? "border-red-500" : ""}
|
||||
placeholder="请输入当前密码"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||
>
|
||||
{showOldPassword ? (
|
||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.old_password && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.old_password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<div>
|
||||
<Label htmlFor="new_password">新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="new_password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
{...register("new_password")}
|
||||
className={errors.new_password ? "border-red-500" : ""}
|
||||
placeholder="请输入新密码(至少6个字符)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground">密码强度:</span>
|
||||
<span className={`text-xs font-medium ${passwordStrength.color}`}>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
passwordStrength.strength === "weak"
|
||||
? "bg-red-500 w-1/3"
|
||||
: passwordStrength.strength === "medium"
|
||||
? "bg-yellow-500 w-2/3"
|
||||
: "bg-green-500 w-full"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errors.new_password && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.new_password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认新密码 */}
|
||||
<div>
|
||||
<Label htmlFor="confirm_password">确认新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
{...register("confirm_password")}
|
||||
className={errors.confirm_password ? "border-red-500" : ""}
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.confirm_password && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.confirm_password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码要求提示 */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">密码要求:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• 至少6个字符</li>
|
||||
<li>• 建议包含大小写字母、数字和特殊字符</li>
|
||||
<li>• 避免使用常用密码或个人信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="pt-4">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
修改中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
修改密码
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 偏好设置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Palette className="w-5 h-5 text-purple-600" />
|
||||
<CardTitle>偏好设置</CardTitle>
|
||||
</div>
|
||||
<CardDescription>自定义您的使用偏好</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 主题设置 */}
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-base font-medium">主题</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
切换浅色/深色主题模式
|
||||
</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* 未来可添加其他偏好设置 */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
更多偏好设置功能即将推出
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Loader2, Download, Copy, Upload, Edit3, Wand2, Expand, Palette, Image as ImageIcon, X } from "lucide-react";
|
||||
import { imageAPI } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface EditResult {
|
||||
id: string;
|
||||
url: string;
|
||||
original_filename?: string;
|
||||
edit_prompt: string;
|
||||
mode: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface VariationResult {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface ConfigOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ImageToImagePage() {
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string>("");
|
||||
const [editMode, setEditMode] = useState("optimize");
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const [strength, setStrength] = useState(0.8);
|
||||
const [numVariations, setNumVariations] = useState(3);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [editResult, setEditResult] = useState<EditResult | null>(null);
|
||||
const [variations, setVariations] = useState<VariationResult[]>([]);
|
||||
|
||||
// Canvas 相关状态
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [brushSize, setBrushSize] = useState(20);
|
||||
const [maskData, setMaskData] = useState<string>("");
|
||||
|
||||
// 配置选项
|
||||
const [editModes, setEditModes] = useState<ConfigOption[]>([]);
|
||||
const [stylePresets, setStylePresets] = useState<ConfigOption[]>([]);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 加载配置选项
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const [modesData, presetsData] = await Promise.all([
|
||||
imageAPI.getEditModes(),
|
||||
imageAPI.getStylePresets(),
|
||||
]);
|
||||
|
||||
setEditModes(modesData);
|
||||
setStylePresets(presetsData);
|
||||
} catch (error) {
|
||||
console.error("加载配置失败:", error);
|
||||
toast.error("加载配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
// 处理图像上传
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagePreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理拖拽上传
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
setSelectedImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagePreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Canvas 绘制蒙版
|
||||
const handleCanvasMouseDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
setIsDrawing(true);
|
||||
drawOnCanvas(event);
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
drawOnCanvas(event);
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = () => {
|
||||
setIsDrawing(false);
|
||||
// 导出蒙版数据
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const dataURL = canvas.toDataURL();
|
||||
setMaskData(dataURL);
|
||||
}
|
||||
};
|
||||
|
||||
const drawOnCanvas = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, brushSize / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
// 清除蒙版
|
||||
const clearMask = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setMaskData("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 图像编辑
|
||||
const handleImageEdit = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("请输入编辑描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
let maskFile: File | undefined;
|
||||
if (editMode === "local_edit" && maskData) {
|
||||
// 将蒙版数据转换为文件
|
||||
const response = await fetch(maskData);
|
||||
const blob = await response.blob();
|
||||
maskFile = new File([blob], "mask.png", { type: "image/png" });
|
||||
}
|
||||
|
||||
const result = await imageAPI.imageEdit(
|
||||
selectedImage,
|
||||
editPrompt,
|
||||
editMode,
|
||||
maskFile,
|
||||
strength
|
||||
);
|
||||
|
||||
setEditResult(result);
|
||||
toast.success("图像编辑完成");
|
||||
} catch (error) {
|
||||
console.error("图像编辑失败:", error);
|
||||
toast.error("图像编辑失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成变体
|
||||
const handleGenerateVariations = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await imageAPI.imageVariations(selectedImage, numVariations);
|
||||
setVariations(result.variations);
|
||||
toast.success(`成功生成 ${result.total} 个变体`);
|
||||
} catch (error) {
|
||||
console.error("生成变体失败:", error);
|
||||
toast.error("生成变体失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 风格转换
|
||||
const handleStyleTransfer = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("请输入风格描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await imageAPI.styleTransfer(selectedImage, editPrompt, strength);
|
||||
setEditResult(result);
|
||||
toast.success("风格转换完成");
|
||||
} catch (error) {
|
||||
console.error("风格转换失败:", error);
|
||||
toast.error("风格转换失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 图像优化
|
||||
const handleOptimizeImage = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await imageAPI.optimizeImage(selectedImage, editPrompt || "优化图像质量,增强细节,提高清晰度", strength);
|
||||
setEditResult(result);
|
||||
toast.success("图像优化完成");
|
||||
} catch (error) {
|
||||
console.error("图像优化失败:", error);
|
||||
toast.error("图像优化失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 图像扩展
|
||||
const handleOutpaintImage = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("请输入扩展描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await imageAPI.outpaintImage(selectedImage, editPrompt, strength);
|
||||
setEditResult(result);
|
||||
toast.success("图像扩展完成");
|
||||
} catch (error) {
|
||||
console.error("图像扩展失败:", error);
|
||||
toast.error("图像扩展失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行操作
|
||||
const handleExecute = () => {
|
||||
switch (editMode) {
|
||||
case "optimize":
|
||||
handleOptimizeImage();
|
||||
break;
|
||||
case "style_transfer":
|
||||
handleStyleTransfer();
|
||||
break;
|
||||
case "local_edit":
|
||||
handleImageEdit();
|
||||
break;
|
||||
case "outpaint":
|
||||
handleOutpaintImage();
|
||||
break;
|
||||
default:
|
||||
handleImageEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = imageUrl;
|
||||
link.download = `edited-image-${imageId}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success("图像下载成功");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-blue-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-8"
|
||||
>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent mb-4">
|
||||
图生图 - Image to Image
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
基于已有图像进行编辑、优化、风格转换和局部修改
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 左侧:图像上传和编辑配置 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 图像上传 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-blue-600" />
|
||||
上传图像
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
上传您想要编辑的图像
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedImage ? (
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-2">点击上传或拖拽图像到此处</p>
|
||||
<p className="text-sm text-gray-400">支持 PNG, JPG, JPEG 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Uploaded image"
|
||||
className="w-full h-64 object-cover rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreview("");
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{selectedImage.name}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{(selectedImage.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 编辑配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-green-600" />
|
||||
编辑配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
选择编辑模式和参数
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 编辑模式 */}
|
||||
<div className="space-y-2">
|
||||
<Label>编辑模式</Label>
|
||||
<Select value={editMode} onValueChange={setEditMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择编辑模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{editModes.map((mode) => (
|
||||
<SelectItem key={mode.id} value={mode.id}>
|
||||
<div>
|
||||
<div className="font-medium">{mode.name}</div>
|
||||
<div className="text-sm text-gray-500">{mode.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 编辑描述 */}
|
||||
<div className="space-y-2">
|
||||
<Label>编辑描述</Label>
|
||||
<Textarea
|
||||
placeholder={
|
||||
editMode === "optimize"
|
||||
? "描述您希望如何优化图像..."
|
||||
: editMode === "style_transfer"
|
||||
? "描述您想要的风格,例如:水彩画风格、油画风格..."
|
||||
: editMode === "local_edit"
|
||||
? "描述您想要修改的内容..."
|
||||
: "描述您希望如何扩展图像..."
|
||||
}
|
||||
value={editPrompt}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 编辑强度 */}
|
||||
<div className="space-y-2">
|
||||
<Label>编辑强度: {Math.round(strength * 100)}%</Label>
|
||||
<Slider
|
||||
value={[strength]}
|
||||
onValueChange={([value]) => setStrength(value)}
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 局部编辑蒙版 */}
|
||||
{editMode === "local_edit" && selectedImage && (
|
||||
<div className="space-y-2">
|
||||
<Label>绘制编辑区域</Label>
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="space-y-2 mb-4">
|
||||
<Label>画笔大小: {brushSize}px</Label>
|
||||
<Slider
|
||||
value={[brushSize]}
|
||||
onValueChange={([value]) => setBrushSize(value)}
|
||||
max={50}
|
||||
min={5}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={300}
|
||||
className="border rounded cursor-crosshair w-full h-64 object-cover"
|
||||
style={{ backgroundImage: `url(${imagePreview})`, backgroundSize: "cover" }}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button size="sm" variant="outline" onClick={clearMask}>
|
||||
清除蒙版
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
在图像上绘制需要编辑的区域(红色区域)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行按钮 */}
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={isProcessing || !selectedImage}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
执行编辑
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 生成变体 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-purple-600" />
|
||||
生成变体
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
基于原图生成多个变体
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>变体数量</Label>
|
||||
<Select value={numVariations.toString()} onValueChange={(value) => setNumVariations(parseInt(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num} 个变体
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateVariations}
|
||||
disabled={isProcessing || !selectedImage}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
生成变体
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 右侧:结果展示 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 编辑结果 */}
|
||||
{editResult && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-green-600" />
|
||||
编辑结果
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={editResult.url}
|
||||
alt="Edited image"
|
||||
className="w-full h-64 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 rounded-lg" />
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownload(editResult.url, editResult.id)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{editResult.mode}</Badge>
|
||||
<Badge variant="secondary">
|
||||
强度: {Math.round((editResult.metadata?.strength || 0) * 100)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{editResult.edit_prompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 变体结果 */}
|
||||
{variations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-purple-600" />
|
||||
图像变体
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
生成了 {variations.length} 个变体
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{variations.map((variation, index) => (
|
||||
<motion.div
|
||||
key={variation.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
className="group relative"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={variation.url}
|
||||
alt={`Variation ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 rounded-lg" />
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownload(variation.url, variation.id)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Badge variant="outline">变体 {index + 1}</Badge>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!editResult && variations.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<ImageIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">还没有处理结果</p>
|
||||
<p className="text-sm text-gray-400">上传图像并选择编辑模式开始处理</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Image,
|
||||
ImageIcon,
|
||||
Wand2,
|
||||
Download,
|
||||
Share2,
|
||||
Eye,
|
||||
Loader2,
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Type,
|
||||
Edit3,
|
||||
ArrowRight,
|
||||
Upload,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import { imageAPI } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface GeneratedImage {
|
||||
id: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface EditResult {
|
||||
id: string;
|
||||
url: string;
|
||||
original_filename?: string;
|
||||
edit_prompt: string;
|
||||
mode: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface VariationResult {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface ConfigOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function SpatialPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
|
||||
// Text-to-image states
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("kolors");
|
||||
const [template, setTemplate] = useState("custom");
|
||||
const [style, setStyle] = useState("realistic");
|
||||
const [size, setSize] = useState("1024x1024");
|
||||
const [numImages, setNumImages] = useState(1);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedImages, setGeneratedImages] = useState<GeneratedImage[]>([]);
|
||||
|
||||
// Image-to-image states
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string>("");
|
||||
const [editMode, setEditMode] = useState("optimize");
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const [strength, setStrength] = useState(0.8);
|
||||
const [numVariations, setNumVariations] = useState(3);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [editResult, setEditResult] = useState<EditResult | null>(null);
|
||||
const [variations, setVariations] = useState<VariationResult[]>([]);
|
||||
|
||||
// Canvas states for local editing
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [brushSize, setBrushSize] = useState(20);
|
||||
const [maskData, setMaskData] = useState<string>("");
|
||||
|
||||
// Configuration options
|
||||
const [models, setModels] = useState<ConfigOption[]>([]);
|
||||
const [templates, setTemplates] = useState<ConfigOption[]>([]);
|
||||
const [styles, setStyles] = useState<ConfigOption[]>([]);
|
||||
const [sizes, setSizes] = useState<ConfigOption[]>([]);
|
||||
const [editModes, setEditModes] = useState<ConfigOption[]>([]);
|
||||
const [stylePresets, setStylePresets] = useState<ConfigOption[]>([]);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadConfigs();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const [modelsData, templatesData, stylesData, sizesData, modesData, presetsData] = await Promise.all([
|
||||
imageAPI.getModels(),
|
||||
imageAPI.getTemplates(),
|
||||
imageAPI.getStyles(),
|
||||
imageAPI.getSizes(),
|
||||
imageAPI.getEditModes(),
|
||||
imageAPI.getStylePresets(),
|
||||
]);
|
||||
|
||||
setModels(modelsData);
|
||||
setTemplates(templatesData);
|
||||
setStyles(stylesData);
|
||||
setSizes(sizesData);
|
||||
setEditModes(modesData);
|
||||
setStylePresets(presetsData);
|
||||
} catch (error) {
|
||||
console.error("加载配置失败:", error);
|
||||
toast.error("加载配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
// Text-to-image functions
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("请输入图像描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const response = await imageAPI.textToImage({
|
||||
prompt,
|
||||
model,
|
||||
template,
|
||||
style,
|
||||
size,
|
||||
num_images: numImages,
|
||||
});
|
||||
|
||||
setGeneratedImages(response.images);
|
||||
toast.success(`成功生成 ${response.total} 张图像`);
|
||||
} catch (error) {
|
||||
console.error("生成图像失败:", error);
|
||||
toast.error("生成图像失败,请重试");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Image-to-image functions
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagePreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
setSelectedImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagePreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
switch (editMode) {
|
||||
case "optimize":
|
||||
handleOptimizeImage();
|
||||
break;
|
||||
case "style_transfer":
|
||||
handleStyleTransfer();
|
||||
break;
|
||||
case "local_edit":
|
||||
handleImageEdit();
|
||||
break;
|
||||
case "outpaint":
|
||||
handleOutpaintImage();
|
||||
break;
|
||||
default:
|
||||
handleImageEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizeImage = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await imageAPI.optimizeImage(selectedImage, editPrompt || "优化图像质量,增强细节,提高清晰度", strength);
|
||||
const result = response.result || response; // 兼容包装和非包装的响应
|
||||
console.log("图像优化结果:", result);
|
||||
setEditResult(result);
|
||||
toast.success("图像优化完成");
|
||||
} catch (error) {
|
||||
console.error("图像优化失败:", error);
|
||||
toast.error("图像优化失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStyleTransfer = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("请输入风格描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await imageAPI.styleTransfer(selectedImage, editPrompt, strength);
|
||||
const result = response.result || response; // 兼容包装和非包装的响应
|
||||
console.log("风格转换结果:", result);
|
||||
setEditResult(result);
|
||||
toast.success("风格转换完成");
|
||||
} catch (error) {
|
||||
console.error("风格转换失败:", error);
|
||||
toast.error("风格转换失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageEdit = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("请输入编辑描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
let maskFile: File | undefined;
|
||||
if (editMode === "local_edit" && maskData) {
|
||||
const response = await fetch(maskData);
|
||||
const blob = await response.blob();
|
||||
maskFile = new File([blob], "mask.png", { type: "image/png" });
|
||||
}
|
||||
|
||||
const response = await imageAPI.imageEdit(
|
||||
selectedImage,
|
||||
editPrompt,
|
||||
editMode,
|
||||
maskFile,
|
||||
strength
|
||||
);
|
||||
|
||||
const result = response.result || response; // 兼容包装和非包装的响应
|
||||
console.log("图像编辑结果:", result);
|
||||
setEditResult(result);
|
||||
toast.success("图像编辑完成");
|
||||
} catch (error) {
|
||||
console.error("图像编辑失败:", error);
|
||||
toast.error("图像编辑失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOutpaintImage = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("请输入扩展描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await imageAPI.outpaintImage(selectedImage, editPrompt, strength);
|
||||
const result = response.result || response; // 兼容包装和非包装的响应
|
||||
console.log("图像扩展结果:", result);
|
||||
setEditResult(result);
|
||||
toast.success("图像扩展完成");
|
||||
} catch (error) {
|
||||
console.error("图像扩展失败:", error);
|
||||
toast.error("图像扩展失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateVariations = async () => {
|
||||
if (!selectedImage) {
|
||||
toast.error("请先上传图像");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await imageAPI.imageVariations(selectedImage, numVariations);
|
||||
setVariations(result.variations);
|
||||
toast.success(`成功生成 ${result.total} 个变体`);
|
||||
} catch (error) {
|
||||
console.error("生成变体失败:", error);
|
||||
toast.error("生成变体失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = imageUrl;
|
||||
link.download = `image-${imageId}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success("图像下载成功");
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">空间出图</h1>
|
||||
<p className="text-muted-foreground">使用AI生成国土空间规划相关的设计图和示意图</p>
|
||||
</div>
|
||||
|
||||
{/* Tab导航 */}
|
||||
<Tabs defaultValue="text-to-image" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="text-to-image" className="flex items-center gap-2">
|
||||
<Type className="w-4 h-4" />
|
||||
文生图
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="image-to-image" className="flex items-center gap-2">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
图生图
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 文生图 Tab */}
|
||||
<TabsContent value="text-to-image" className="mt-6">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 左侧:生成配置 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="lg:col-span-1"
|
||||
>
|
||||
<Card className="sticky top-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-blue-600" />
|
||||
生成配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
配置图像生成参数,获得最佳效果
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 模型选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">AI模型</Label>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 提示词模板 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template">提示词模板</Label>
|
||||
<Select value={template} onValueChange={setTemplate}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 图像描述 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt">图像描述</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
placeholder="描述您想要生成的图像,例如:城市中心区商业综合体规划,包含商业区、办公区、绿地..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 风格选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="style">图像风格</Label>
|
||||
<Select value={style} onValueChange={setStyle}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择风格" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{styles.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 尺寸选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size">图像尺寸</Label>
|
||||
<Select value={size} onValueChange={setSize}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择尺寸" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sizes.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 生成数量 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numImages">生成数量</Label>
|
||||
<Select value={numImages.toString()} onValueChange={(value) => setNumImages(parseInt(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数量" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num} 张图像
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
生成图像
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 右侧:生成结果 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="lg:col-span-2"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Image className="h-5 w-5 text-green-600" />
|
||||
生成结果
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{generatedImages.length > 0
|
||||
? `已生成 ${generatedImages.length} 张图像`
|
||||
: "生成图像将显示在这里"
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{generatedImages.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{generatedImages.map((image, index) => (
|
||||
<motion.div
|
||||
key={image.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
className="group relative"
|
||||
>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="aspect-square relative bg-muted">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-all duration-200" />
|
||||
</div>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{image.model}</Badge>
|
||||
<Badge variant="secondary">{image.metadata?.style || style}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{image.prompt}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(image.url, image.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载图片
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">还没有生成图像</p>
|
||||
<p className="text-sm text-muted-foreground/70">配置参数后点击"生成图像"开始创作</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 图生图 Tab */}
|
||||
<TabsContent value="image-to-image" className="mt-6">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 左侧:图像上传和编辑配置 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 图像上传 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-blue-600" />
|
||||
上传图像
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
上传您想要编辑的图像
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedImage ? (
|
||||
<div
|
||||
className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<Upload className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-foreground mb-2">点击上传或拖拽图像到此处</p>
|
||||
<p className="text-sm text-muted-foreground">支持 PNG, JPG, JPEG 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Uploaded image"
|
||||
className="w-full h-64 object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 bg-muted border-2 border-dashed border-border rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">请上传图像</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreview("");
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{selectedImage.name}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{(selectedImage.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 编辑配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-green-600" />
|
||||
编辑配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
选择编辑模式和参数
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 编辑模式 */}
|
||||
<div className="space-y-2">
|
||||
<Label>编辑模式</Label>
|
||||
<Select value={editMode} onValueChange={setEditMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择编辑模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{editModes.map((mode) => (
|
||||
<SelectItem key={mode.id} value={mode.id}>
|
||||
<div>
|
||||
<div className="font-medium">{mode.name}</div>
|
||||
<div className="text-sm text-gray-500">{mode.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 编辑描述 */}
|
||||
<div className="space-y-2">
|
||||
<Label>编辑描述</Label>
|
||||
<Textarea
|
||||
placeholder={
|
||||
editMode === "optimize"
|
||||
? "描述您希望如何优化图像..."
|
||||
: editMode === "style_transfer"
|
||||
? "描述您想要的风格,例如:水彩画风格、油画风格..."
|
||||
: editMode === "local_edit"
|
||||
? "描述您想要修改的内容..."
|
||||
: "描述您希望如何扩展图像..."
|
||||
}
|
||||
value={editPrompt}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 编辑强度 */}
|
||||
<div className="space-y-2">
|
||||
<Label>编辑强度: {Math.round(strength * 100)}%</Label>
|
||||
<Slider
|
||||
value={[strength]}
|
||||
onValueChange={([value]) => setStrength(value)}
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 执行按钮 */}
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={isProcessing || !selectedImage}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
执行编辑
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 生成变体 - 暂时禁用,因为API不支持 */}
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-purple-600" />
|
||||
生成变体
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
基于原图生成多个变体
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>变体数量</Label>
|
||||
<Select value={numVariations.toString()} onValueChange={(value) => setNumVariations(parseInt(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num} 个变体
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateVariations}
|
||||
disabled={isProcessing || !selectedImage}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
生成变体
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
</motion.div>
|
||||
|
||||
{/* 右侧:结果展示 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 编辑结果 */}
|
||||
{editResult && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-green-600" />
|
||||
编辑结果
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="relative group bg-muted rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={editResult.url}
|
||||
alt="Edited image"
|
||||
className="w-full h-64 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-all duration-200" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{editResult.mode}</Badge>
|
||||
<Badge variant="secondary">
|
||||
强度: {Math.round((editResult.metadata?.strength || 0) * 100)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{editResult.edit_prompt}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(editResult.url, editResult.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载图片
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 变体结果 - 暂时禁用 */}
|
||||
{/* {variations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-purple-600" />
|
||||
图像变体
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
生成了 {variations.length} 个变体
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{variations.map((variation, index) => (
|
||||
<motion.div
|
||||
key={variation.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
className="group relative"
|
||||
>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="relative bg-muted">
|
||||
<img
|
||||
src={variation.url}
|
||||
alt={`Variation ${index + 1}`}
|
||||
className="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-all duration-200" />
|
||||
</div>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">变体 {index + 1}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(variation.url, variation.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载图片
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)} */}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!editResult && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">还没有处理结果</p>
|
||||
<p className="text-sm text-muted-foreground/70">上传图像并选择编辑模式开始处理</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Loader2, Download, Copy, RefreshCw, Sparkles, Image as ImageIcon } from "lucide-react";
|
||||
import { imageAPI } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface GeneratedImage {
|
||||
id: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface ConfigOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function TextToImagePage() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("kolors");
|
||||
const [template, setTemplate] = useState("custom");
|
||||
const [style, setStyle] = useState("realistic");
|
||||
const [size, setSize] = useState("1024x1024");
|
||||
const [numImages, setNumImages] = useState(1);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedImages, setGeneratedImages] = useState<GeneratedImage[]>([]);
|
||||
|
||||
// 配置选项
|
||||
const [models, setModels] = useState<ConfigOption[]>([]);
|
||||
const [templates, setTemplates] = useState<ConfigOption[]>([]);
|
||||
const [styles, setStyles] = useState<ConfigOption[]>([]);
|
||||
const [sizes, setSizes] = useState<ConfigOption[]>([]);
|
||||
|
||||
// 加载配置选项
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const [modelsData, templatesData, stylesData, sizesData] = await Promise.all([
|
||||
imageAPI.getModels(),
|
||||
imageAPI.getTemplates(),
|
||||
imageAPI.getStyles(),
|
||||
imageAPI.getSizes(),
|
||||
]);
|
||||
|
||||
setModels(modelsData);
|
||||
setTemplates(templatesData);
|
||||
setStyles(stylesData);
|
||||
setSizes(sizesData);
|
||||
} catch (error) {
|
||||
console.error("加载配置失败:", error);
|
||||
toast.error("加载配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("请输入图像描述");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const response = await imageAPI.textToImage({
|
||||
prompt,
|
||||
model,
|
||||
template,
|
||||
style,
|
||||
size,
|
||||
num_images: numImages,
|
||||
});
|
||||
|
||||
setGeneratedImages(response.images);
|
||||
toast.success(`成功生成 ${response.total} 张图像`);
|
||||
} catch (error) {
|
||||
console.error("生成图像失败:", error);
|
||||
toast.error("生成图像失败,请重试");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = imageUrl;
|
||||
link.download = `generated-image-${imageId}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success("图像下载成功");
|
||||
};
|
||||
|
||||
const handleCopyPrompt = (prompt: string) => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
toast.success("提示词已复制到剪贴板");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-8"
|
||||
>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-4">
|
||||
文生图 - Text to Image
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
使用先进的AI模型,从文字描述生成高质量的国土空间规划图像
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 左侧:生成配置 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="lg:col-span-1"
|
||||
>
|
||||
<Card className="sticky top-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-blue-600" />
|
||||
生成配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
配置图像生成参数,获得最佳效果
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 模型选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">AI模型</Label>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-gray-500">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 提示词模板 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template">提示词模板</Label>
|
||||
<Select value={template} onValueChange={setTemplate}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-gray-500">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 图像描述 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt">图像描述</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
placeholder="描述您想要生成的图像,例如:城市中心区商业综合体规划,包含商业区、办公区、绿地..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 风格选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="style">图像风格</Label>
|
||||
<Select value={style} onValueChange={setStyle}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择风格" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{styles.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-gray-500">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 尺寸选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size">图像尺寸</Label>
|
||||
<Select value={size} onValueChange={setSize}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择尺寸" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sizes.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div>
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-sm text-gray-500">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 生成数量 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numImages">生成数量</Label>
|
||||
<Select value={numImages.toString()} onValueChange={(value) => setNumImages(parseInt(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数量" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num} 张图像
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
生成图像
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 右侧:生成结果 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="lg:col-span-2"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="h-5 w-5 text-green-600" />
|
||||
生成结果
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{generatedImages.length > 0
|
||||
? `已生成 ${generatedImages.length} 张图像`
|
||||
: "生成图像将显示在这里"
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{generatedImages.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{generatedImages.map((image, index) => (
|
||||
<motion.div
|
||||
key={image.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
className="group relative"
|
||||
>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="aspect-square relative">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200" />
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownload(image.url, image.id)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleCopyPrompt(image.prompt)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{image.model}</Badge>
|
||||
<Badge variant="secondary">{image.metadata?.style || style}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{image.prompt}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<ImageIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">还没有生成图像</p>
|
||||
<p className="text-sm text-gray-400">配置参数后点击"生成图像"开始创作</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { knowledgeBaseAPI } from "@/lib/api";
|
||||
import { KnowledgeBase } from "@/types";
|
||||
import { BookOpen, CheckCircle, Clock, Loader2 } from "lucide-react";
|
||||
|
||||
export default function TestKnowledgeBasePage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadKnowledgeBases();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
const loadKnowledgeBases = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const bases = await knowledgeBaseAPI.getKnowledgeBases();
|
||||
setKnowledgeBases(bases);
|
||||
addTestResult("✅ 成功加载知识库列表");
|
||||
} catch (err) {
|
||||
console.error("加载知识库失败:", err);
|
||||
setError("加载知识库失败");
|
||||
addTestResult("❌ 加载知识库失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTestResult = (result: string) => {
|
||||
setTestResults(prev => [...prev, `${new Date().toLocaleTimeString()}: ${result}`]);
|
||||
};
|
||||
|
||||
const testCreateKnowledgeBase = async () => {
|
||||
try {
|
||||
addTestResult("🔄 开始创建测试知识库...");
|
||||
const newKB = await knowledgeBaseAPI.createKnowledgeBase({
|
||||
name: `测试知识库_${Date.now()}`,
|
||||
description: "这是一个测试知识库"
|
||||
});
|
||||
addTestResult(`✅ 成功创建知识库: ${newKB.name}`);
|
||||
await loadKnowledgeBases();
|
||||
} catch (err) {
|
||||
console.error("创建知识库失败:", err);
|
||||
addTestResult("❌ 创建知识库失败");
|
||||
}
|
||||
};
|
||||
|
||||
const testDeleteKnowledgeBase = async (kbId: number) => {
|
||||
try {
|
||||
addTestResult(`🔄 开始删除知识库 ${kbId}...`);
|
||||
await knowledgeBaseAPI.deleteKnowledgeBase(kbId.toString());
|
||||
addTestResult(`✅ 成功删除知识库 ${kbId}`);
|
||||
await loadKnowledgeBases();
|
||||
} catch (err) {
|
||||
console.error("删除知识库失败:", err);
|
||||
addTestResult(`❌ 删除知识库失败: ${kbId}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">知识库系统测试</h1>
|
||||
<p className="text-muted-foreground">测试知识库的创建、管理和API调用</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 测试操作 */}
|
||||
<Card className="mb-8 backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>测试操作</CardTitle>
|
||||
<CardDescription>执行各种知识库操作来测试系统</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-4">
|
||||
<Button onClick={testCreateKnowledgeBase}>
|
||||
创建测试知识库
|
||||
</Button>
|
||||
<Button onClick={loadKnowledgeBases} variant="outline">
|
||||
刷新知识库列表
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 测试结果 */}
|
||||
<Card className="mb-8 backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>测试结果</CardTitle>
|
||||
<CardDescription>实时显示测试操作的结果</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{testResults.length === 0 ? (
|
||||
<p className="text-muted-foreground">暂无测试结果</p>
|
||||
) : (
|
||||
testResults.map((result, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
{result}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 知识库列表 */}
|
||||
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>知识库列表</CardTitle>
|
||||
<CardDescription>当前系统中的所有知识库</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{knowledgeBases.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">还没有知识库</h3>
|
||||
<p className="text-muted-foreground">点击"创建测试知识库"来开始测试</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{knowledgeBases.map((kb) => (
|
||||
<Card key={kb.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BookOpen className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">{kb.name}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{kb.description || "暂无描述"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{kb.document_count > 0 ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>文档数量</span>
|
||||
<span>{kb.document_count} 个文档</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>创建时间</span>
|
||||
<span>{new Date(new Date(kb.created_at).getTime() + 8 * 60 * 60 * 1000).toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>状态</span>
|
||||
<span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}>
|
||||
{kb.document_count > 0 ? "已就绪" : "空知识库"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => router.push(`/knowledge/${kb.id}`)}
|
||||
>
|
||||
管理
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => testDeleteKnowledgeBase(kb.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { forumAPI } from "@/lib/api";
|
||||
import type { ForumCategory, ForumPostSummary } from "@/types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, MessageCircle } from "lucide-react";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
|
||||
export default function ForumCategoryPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const categoryId = Number(params?.categoryId);
|
||||
|
||||
const [categories, setCategories] = useState<ForumCategory[]>([]);
|
||||
const [posts, setPosts] = useState<ForumPostSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
const currentCategory = useMemo(
|
||||
() => categories.find((cat) => cat.id === categoryId),
|
||||
[categories, categoryId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!categoryId) {
|
||||
setError("无效的分类编号");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [cats, postList] = await Promise.all([
|
||||
forumAPI.getCategories(),
|
||||
forumAPI.getPosts(categoryId),
|
||||
]);
|
||||
setCategories(cats);
|
||||
setPosts(postList);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "加载帖子失败";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [categoryId]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!title.trim() || !content.trim()) {
|
||||
setSubmitError("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
await forumAPI.createPost(categoryId, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
});
|
||||
setTitle("");
|
||||
setContent("");
|
||||
const updatedPosts = await forumAPI.getPosts(categoryId);
|
||||
setPosts(updatedPosts);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "发布帖子失败";
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app py-10">
|
||||
<div className="max-w-5xl mx-auto px-4 space-y-6">
|
||||
<Button variant="ghost" onClick={() => router.push("/forum")} className="px-0">
|
||||
← 返回论坛
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<MessageCircle className="h-6 w-6 text-primary" />
|
||||
{currentCategory ? currentCategory.name : "论坛分类"}
|
||||
</h1>
|
||||
{currentCategory?.description && (
|
||||
<p className="text-muted-foreground">{currentCategory.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载帖子...
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,3fr]">
|
||||
<div className="space-y-4">
|
||||
{isAuthenticated ? (
|
||||
<Card className="shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>发起讨论</CardTitle>
|
||||
<CardDescription>发表新的帖子,与大家交流想法。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{submitError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground/80">标题</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="写一个简洁的标题"
|
||||
maxLength={200}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground/80">内容</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="详细描述你的想法或问题..."
|
||||
rows={8}
|
||||
className="min-h-[220px] resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
发布中...
|
||||
</>
|
||||
) : (
|
||||
"发布帖子"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
需要登录后才能发表帖子。请先
|
||||
<Link href="/login" className="underline ml-1">
|
||||
登录
|
||||
</Link>
|
||||
或
|
||||
<Link href="/register" className="underline ml-1">
|
||||
注册
|
||||
</Link>
|
||||
。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{posts.length === 0 ? (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="py-6 text-sm text-muted-foreground text-center">
|
||||
暂时还没有帖子,快来抢先发帖吧!
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<Card key={post.id} className="shadow-sm transition hover:shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
<Link href={`/forum/post/${post.id}`} className="hover:underline">
|
||||
{post.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
由 {post.author_name} 发布 · {post.reply_count} 条回复 ·{" "}
|
||||
{new Date(new Date(post.created_at).getTime() + 8 * 60 * 60 * 1000).toLocaleString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { forumAPI } from "@/lib/api";
|
||||
import type { ForumPostDetail } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, MessageCircleReply } from "lucide-react";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
|
||||
export default function ForumPostPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const postId = Number(params?.postId);
|
||||
|
||||
const [post, setPost] = useState<ForumPostDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) {
|
||||
setError("无效的帖子编号");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const detail = await forumAPI.getPost(postId);
|
||||
setPost(detail);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "加载帖子详情失败";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPost();
|
||||
}, [postId]);
|
||||
|
||||
const handleReply = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!replyContent.trim()) {
|
||||
setSubmitError("回复内容不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
await forumAPI.createReply(postId, replyContent.trim());
|
||||
setReplyContent("");
|
||||
const detail = await forumAPI.getPost(postId);
|
||||
setPost(detail);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "回复失败";
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app py-10">
|
||||
<div className="max-w-4xl mx-auto px-4 space-y-6">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="px-0">
|
||||
← 返回列表
|
||||
</Button>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载帖子...
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : post ? (
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-semibold">{post.title}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
由 {post.author_name} 发布 ·{" "}
|
||||
{new Date(new Date(post.created_at).getTime() + 8 * 60 * 60 * 1000).toLocaleString()}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
{post.content}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium flex items-center gap-2">
|
||||
<MessageCircleReply className="h-5 w-5 text-primary" />
|
||||
回复({post.replies.length})
|
||||
</h2>
|
||||
|
||||
{post.replies.length === 0 ? (
|
||||
<Card className="text-sm text-muted-foreground">
|
||||
<CardContent className="py-4">
|
||||
还没有回复,欢迎分享你的观点。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
post.replies.map((reply) => (
|
||||
<Card key={reply.id} className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="text-sm font-medium text-foreground">{reply.author_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(new Date(reply.created_at).getTime() + 8 * 60 * 60 * 1000).toLocaleString()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||
{reply.content}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="py-6">
|
||||
{isAuthenticated ? (
|
||||
<form onSubmit={handleReply} className="space-y-4">
|
||||
{submitError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
value={replyContent}
|
||||
onChange={(event) => setReplyContent(event.target.value)}
|
||||
placeholder="写下你的想法..."
|
||||
rows={5}
|
||||
className="resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="text-right">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
发布中...
|
||||
</>
|
||||
) : (
|
||||
"提交回复"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
登录后才能回复帖子。前往
|
||||
<Link href="/login" className="underline mx-1">
|
||||
登录
|
||||
</Link>
|
||||
或
|
||||
<Link href="/register" className="underline ml-1">
|
||||
注册
|
||||
</Link>
|
||||
。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
|
||||
--animate-fade-in: fade-in 1s;
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-spotlight: spotlight 2s ease 0.75s 1 forwards;
|
||||
@keyframes spotlight {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-72%, -62%) scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -40%) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-app: var(--app-background);
|
||||
--color-brand: var(--brand);
|
||||
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
||||
@keyframes aurora {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
transform: rotate(-5deg) scale(0.9);
|
||||
}
|
||||
25% {
|
||||
background-position: 50% 100%;
|
||||
transform: rotate(5deg) scale(1.1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
transform: rotate(-3deg) scale(0.95);
|
||||
}
|
||||
75% {
|
||||
background-position: 50% 0%;
|
||||
transform: rotate(3deg) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
transform: rotate(-5deg) scale(0.9);
|
||||
}
|
||||
}
|
||||
--animate-shine: shine var(--duration) infinite linear;
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--app-background: #ffffff;
|
||||
|
||||
/* MIT 风格配色 - 简洁的灰度系统 */
|
||||
--background: #ffffff;
|
||||
--foreground: #000000;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #000000;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #000000;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f5f5f5;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #6b7280;
|
||||
--accent: #f3f4f6;
|
||||
--accent-foreground: #111827;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--input: #ffffff;
|
||||
--ring: #2563eb;
|
||||
--chart-1: #2563eb;
|
||||
--chart-2: #059669;
|
||||
--chart-3: #dc2626;
|
||||
--chart-4: #7c3aed;
|
||||
--chart-5: #ea580c;
|
||||
--brand: #2563eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--app-background: #111827;
|
||||
|
||||
/* 深色模式保持同样的简洁风格 */
|
||||
--background: #111827;
|
||||
--foreground: #f9fafb;
|
||||
--card: #1f2937;
|
||||
--card-foreground: #f9fafb;
|
||||
--popover: #1f2937;
|
||||
--popover-foreground: #f9fafb;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #374151;
|
||||
--secondary-foreground: #f9fafb;
|
||||
--muted: #1f2937;
|
||||
--muted-foreground: #9ca3af;
|
||||
--accent: #374151;
|
||||
--accent-foreground: #f9fafb;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #374151;
|
||||
--input: #1f2937;
|
||||
--ring: #3b82f6;
|
||||
--chart-1: #3b82f6;
|
||||
--chart-2: #10b981;
|
||||
--chart-3: #ef4444;
|
||||
--chart-4: #8b5cf6;
|
||||
--chart-5: #f97316;
|
||||
--brand: #3b82f6;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
body {
|
||||
background-color: hsl(var(--app-background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* MIT 风格的字体层次 */
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* MIT 风格实用工具类 */
|
||||
@layer utilities {
|
||||
/* 移动端优化 */
|
||||
.mobile-safe-area {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* MIT 风格布局 */
|
||||
.mit-container {
|
||||
max-width: 80rem; /* 1280px */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mit-container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mit-container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* MIT 风格卡片 */
|
||||
.mit-card {
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mit-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* MIT 风格网格 */
|
||||
.mit-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mit-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mit-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* MIT 风格按钮 */
|
||||
.mit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mit-button-primary {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.mit-button-primary:hover {
|
||||
background-color: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.mit-button-secondary {
|
||||
background-color: hsl(var(--secondary));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.mit-button-secondary:hover {
|
||||
background-color: hsl(var(--secondary) / 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[role="button"],
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "国土空间规划课程智能体",
|
||||
description: "基于大模型的国土空间规划课程智能问答系统",
|
||||
keywords: ["国土空间规划", "智能问答", "AI", "教育"],
|
||||
authors: [{ name: "哈尔滨工业大学建筑与设计学院 国土空间与区域发展研究所" }],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
expand={true}
|
||||
richColors={true}
|
||||
closeButton={true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import HomePageContent from "@/components/home/home-page-content";
|
||||
|
||||
export default function HomePage() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
// 如果正在加载认证状态,显示加载动画
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <HomePageContent />;
|
||||
}
|
||||
Reference in New Issue
Block a user