You've already forked DataMate
init datamate
This commit is contained in:
480
frontend/src/pages/Agent/Agent.tsx
Normal file
480
frontend/src/pages/Agent/Agent.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Card, Input, Button, Badge } from "antd";
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
Bot,
|
||||
User,
|
||||
Sparkles,
|
||||
Database,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
type: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
actions?: Array<{
|
||||
type:
|
||||
| "create_dataset"
|
||||
| "run_analysis"
|
||||
| "start_synthesis"
|
||||
| "export_report";
|
||||
label: string;
|
||||
data?: any;
|
||||
}>;
|
||||
status?: "pending" | "completed" | "error";
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
prompt: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
id: "create_dataset",
|
||||
label: "创建数据集",
|
||||
icon: Database,
|
||||
prompt: "帮我创建一个新的数据集",
|
||||
category: "数据管理",
|
||||
},
|
||||
{
|
||||
id: "analyze_quality",
|
||||
label: "质量分析",
|
||||
icon: BarChart3,
|
||||
prompt: "分析我的数据集质量",
|
||||
category: "数据评估",
|
||||
},
|
||||
{
|
||||
id: "start_synthesis",
|
||||
label: "数据合成",
|
||||
icon: Sparkles,
|
||||
prompt: "启动数据合成任务",
|
||||
category: "数据合成",
|
||||
},
|
||||
{
|
||||
id: "process_data",
|
||||
label: "数据清洗",
|
||||
icon: Settings,
|
||||
prompt: "对数据集进行预处理",
|
||||
category: "数据清洗",
|
||||
},
|
||||
{
|
||||
id: "export_report",
|
||||
label: "导出报告",
|
||||
icon: Download,
|
||||
prompt: "导出最新的分析报告",
|
||||
category: "报告导出",
|
||||
},
|
||||
{
|
||||
id: "check_status",
|
||||
label: "查看状态",
|
||||
icon: Clock,
|
||||
prompt: "查看所有任务的运行状态",
|
||||
category: "状态查询",
|
||||
},
|
||||
];
|
||||
|
||||
const mockResponses = {
|
||||
创建数据集: {
|
||||
content:
|
||||
"我来帮您创建一个新的数据集。请告诉我以下信息:\n\n1. 数据集名称\n2. 数据类型(图像、文本、问答对等)\n3. 预期数据量\n4. 数据来源\n\n您也可以直接说出您的需求,我会为您推荐最适合的配置。",
|
||||
actions: [
|
||||
{ type: "create_dataset", label: "开始创建", data: { step: "config" } },
|
||||
],
|
||||
},
|
||||
质量分析: {
|
||||
content:
|
||||
"正在为您分析数据集质量...\n\n📊 **分析结果概览:**\n- 图像分类数据集:质量分 92/100\n- 问答对数据集:质量分 87/100\n- 多模态数据集:质量分 78/100\n\n🔍 **发现的主要问题:**\n- 23个重复图像\n- 156个格式不正确的问答对\n- 78个图文不匹配项\n\n💡 **改进建议:**\n- 建议进行去重处理\n- 优化问答对格式\n- 重新标注图文匹配项",
|
||||
actions: [
|
||||
{
|
||||
type: "run_analysis",
|
||||
label: "查看详细报告",
|
||||
data: { type: "detailed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
数据合成: {
|
||||
content:
|
||||
"我可以帮您启动数据合成任务。目前支持以下合成类型:\n\n🖼️ **图像数据合成**\n- 数据增强(旋转、翻转、亮度调整)\n- 风格迁移\n- GAN生成\n\n📝 **文本数据合成**\n- 同义词替换\n- 回译增强\n- GPT生成\n\n❓ **问答对合成**\n- 基于知识库生成\n- 模板变换\n- 多轮对话生成\n\n请告诉我您需要合成什么类型的数据,以及目标数量。",
|
||||
actions: [
|
||||
{
|
||||
type: "start_synthesis",
|
||||
label: "配置合成任务",
|
||||
data: { step: "config" },
|
||||
},
|
||||
],
|
||||
},
|
||||
导出报告: {
|
||||
content:
|
||||
"正在为您准备最新的分析报告...\n\n📋 **可用报告:**\n- 数据质量评估报告(PDF)\n- 数据分布统计报告(Excel)\n- 模型性能评估报告(PDF)\n- 偏见检测报告(PDF)\n- 综合分析报告(PDF + Excel)\n\n✅ 报告已生成完成,您可以选择下载格式。",
|
||||
actions: [
|
||||
{ type: "export_report", label: "下载报告", data: { format: "pdf" } },
|
||||
],
|
||||
},
|
||||
查看状态: {
|
||||
content:
|
||||
"📊 **当前任务状态概览:**\n\n🟢 **运行中的任务:**\n- 问答对生成任务:65% 完成\n- 图像质量分析:运行中\n- 知识库构建:等待中\n\n✅ **已完成的任务:**\n- 图像分类数据集创建:已完成\n- PDF文档提取:已完成\n- 训练集配比任务:已完成\n\n⚠️ **需要关注的任务:**\n- 多模态数据合成:暂停(需要用户确认参数)\n\n所有任务运行正常,预计2小时内全部完成。",
|
||||
actions: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default function AgentPage() {
|
||||
return <DevelopmentInProgress />;
|
||||
const navigate = useNavigate();
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: "welcome",
|
||||
type: "assistant",
|
||||
content:
|
||||
"👋 您好!我是 Data Agent,您的AI数据助手。\n\n我可以帮您:\n• 创建和管理数据集\n• 分析数据质量\n• 启动处理任务\n• 生成分析报告\n• 回答数据相关问题\n\n请告诉我您需要什么帮助,或者点击下方的快捷操作开始。",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
type: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue("");
|
||||
setIsTyping(true);
|
||||
|
||||
// 模拟AI响应
|
||||
setTimeout(() => {
|
||||
const response = generateResponse(content);
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
type: "assistant",
|
||||
content: response.content,
|
||||
timestamp: new Date(),
|
||||
actions: response.actions,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsTyping(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const generateResponse = (
|
||||
input: string
|
||||
): { content: string; actions?: any[] } => {
|
||||
const lowerInput = input.toLowerCase();
|
||||
|
||||
if (lowerInput.includes("创建") && lowerInput.includes("数据集")) {
|
||||
return mockResponses["创建数据集"];
|
||||
} else if (lowerInput.includes("质量") || lowerInput.includes("分析")) {
|
||||
return mockResponses["质量分析"];
|
||||
} else if (lowerInput.includes("合成") || lowerInput.includes("生成")) {
|
||||
return mockResponses["数据合成"];
|
||||
} else if (lowerInput.includes("导出") || lowerInput.includes("报告")) {
|
||||
return mockResponses["导出报告"];
|
||||
} else if (lowerInput.includes("状态") || lowerInput.includes("任务")) {
|
||||
return mockResponses["查看状态"];
|
||||
} else if (lowerInput.includes("你好") || lowerInput.includes("帮助")) {
|
||||
return {
|
||||
content:
|
||||
"很高兴为您服务!我是专门为数据集管理设计的AI助手。\n\n我的主要能力包括:\n\n🔧 **数据集操作**\n- 创建、导入、导出数据集\n- 数据预处理和清洗\n- 批量操作和自动化\n\n📊 **智能分析**\n- 数据质量评估\n- 分布统计分析\n- 性能和偏见检测\n\n🤖 **AI增强**\n- 智能数据合成\n- 自动标注建议\n- 知识库构建\n\n请告诉我您的具体需求,我会为您提供最合适的解决方案!",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: `我理解您想要「${input}」。让我为您分析一下...\n\n基于您的需求,我建议:\n\n1. 首先确认具体的操作目标\n2. 选择合适的数据集和参数\n3. 执行相应的处理流程\n\n您可以提供更多详细信息,或者选择下方的快捷操作来开始。如果需要帮助,请说"帮助"获取完整功能列表。`,
|
||||
actions: [
|
||||
{ type: "run_analysis", label: "开始分析", data: { query: input } },
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAction = (action: QuickAction) => {
|
||||
handleSendMessage(action.prompt);
|
||||
};
|
||||
|
||||
const handleActionClick = (action: any) => {
|
||||
const actionMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
type: "assistant",
|
||||
content: `✅ 正在执行「${action.label}」...\n\n操作已启动,您可以在相应的功能模块中查看详细进度。`,
|
||||
timestamp: new Date(),
|
||||
status: "completed",
|
||||
};
|
||||
setMessages((prev) => [...prev, actionMessage]);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessage = (content: string) => {
|
||||
return content.split("\n").map((line, index) => (
|
||||
<div key={index} className="mb-1">
|
||||
{line || <br />}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-pink-50">
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-500 to-pink-500 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Data Agent</h1>
|
||||
<p className="text-purple-100">
|
||||
AI驱动的智能数据助手,通过对话完成复杂数据操作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ArrowLeft className="w-4 h-4 mr-2" />}
|
||||
onClick={onBack}
|
||||
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:border-white/30"
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-7xl mx-auto h-full w-full p-6">
|
||||
<div className="h-full flex gap-6">
|
||||
{/* Chat Area */}
|
||||
<div className="lg:col-span-3 flex flex-1 flex-col h-full">
|
||||
<div className="flex-1 flex flex-col h-full shadow-lg">
|
||||
<div className="pb-3 bg-white rounded-t-lg">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<span className="text-lg font-semibold">对话窗口</span>
|
||||
<div>
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-1 inline-block" />
|
||||
在线
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between h-full p-0 min-h-0">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-white">
|
||||
<div className="space-y-4 pb-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${
|
||||
message.type === "user"
|
||||
? "justify-end"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{message.type === "assistant" && (
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-3 ${
|
||||
message.type === "user"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-white text-gray-900 shadow-sm border border-gray-100"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm whitespace-pre-wrap">
|
||||
{formatMessage(message.content)}
|
||||
</div>
|
||||
{message.actions && message.actions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{message.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
type="default"
|
||||
size="small"
|
||||
className="mr-2 mb-2"
|
||||
onClick={() => handleActionClick(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs opacity-70 mt-2">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
{message.type === "user" && (
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isTyping && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg px-4 py-3 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-4 bg-white rounded-b-lg">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="输入您的需求,例如:创建一个图像分类数据集..."
|
||||
disabled={isTyping}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleSendMessage(inputValue)}
|
||||
disabled={!inputValue.trim() || isTyping}
|
||||
className="bg-gradient-to-r from-purple-400 to-pink-400 border-none hover:from-purple-500 hover:to-pink-500"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Sidebar */}
|
||||
<div className="w-72 flex flex-col gap-6">
|
||||
<Card className="shadow-lg">
|
||||
<div className="">
|
||||
<span className="text-lg font-semibold">快捷操作</span>
|
||||
<div className="text-sm text-gray-500">
|
||||
点击快速开始常用操作
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 p-4">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
type="default"
|
||||
className="w-full justify-start h-auto p-3 text-left"
|
||||
onClick={() => handleQuickAction(action)}
|
||||
>
|
||||
<action.icon className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">
|
||||
{action.label}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<div className="pb-3">
|
||||
<span className="text-lg font-semibold">系统状态</span>
|
||||
</div>
|
||||
<div className="space-y-3 p-4 pt-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span>AI服务正常</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<span>3个任务运行中</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Database className="w-4 h-4 text-purple-500" />
|
||||
<span>12个数据集就绪</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
<span>响应时间: 0.8s</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<div className="pb-3">
|
||||
<span className="text-lg font-semibold">使用提示</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-600 p-4 pt-0">
|
||||
<div>💡 您可以用自然语言描述需求</div>
|
||||
<div>🔍 支持复杂的多步骤操作</div>
|
||||
<div>📊 可以询问数据统计和分析</div>
|
||||
<div>⚡ 使用快捷操作提高效率</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<div className="pt-6 p-4">
|
||||
<Button
|
||||
type="default"
|
||||
className="w-full"
|
||||
icon={<HomeOutlined />}
|
||||
onClick={onBack}
|
||||
>
|
||||
返回主应用
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, message } from "antd";
|
||||
import { Button, Badge, Progress, Checkbox } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Save,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { mockTasks } from "@/mock/annotation";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
|
||||
export default function AnnotationWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const [task, setTask] = useState(mockTasks[0]);
|
||||
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [annotationProgress, setAnnotationProgress] = useState({
|
||||
completed: task.completedCount,
|
||||
skipped: task.skippedCount,
|
||||
total: task.totalCount,
|
||||
});
|
||||
|
||||
const handleSaveAndNext = () => {
|
||||
setAnnotationProgress((prev) => ({
|
||||
...prev,
|
||||
completed: prev.completed + 1,
|
||||
}));
|
||||
|
||||
if (currentFileIndex < task.totalCount - 1) {
|
||||
setCurrentFileIndex(currentFileIndex + 1);
|
||||
}
|
||||
|
||||
message({
|
||||
title: "标注已保存",
|
||||
description: "标注结果已保存,自动跳转到下一个",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkipAndNext = () => {
|
||||
setAnnotationProgress((prev) => ({
|
||||
...prev,
|
||||
skipped: prev.skipped + 1,
|
||||
}));
|
||||
|
||||
if (currentFileIndex < task.totalCount - 1) {
|
||||
setCurrentFileIndex(currentFileIndex + 1);
|
||||
}
|
||||
|
||||
message({
|
||||
title: "已跳过",
|
||||
description: "已跳过当前项目,自动跳转到下一个",
|
||||
});
|
||||
};
|
||||
|
||||
const getDatasetTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "text":
|
||||
return <FileText className="w-4 h-4 text-blue-500" />;
|
||||
case "image":
|
||||
return <ImageIcon className="w-4 h-4 text-green-500" />;
|
||||
case "video":
|
||||
return <Video className="w-4 h-4 text-purple-500" />;
|
||||
case "audio":
|
||||
return <Music className="w-4 h-4 text-orange-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const currentProgress = Math.round(
|
||||
((annotationProgress.completed + annotationProgress.skipped) /
|
||||
annotationProgress.total) *
|
||||
100
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate("/data/annotation")}
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
></Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getDatasetTypeIcon(task.datasetType)}
|
||||
<span className="text-xl font-bold">{task.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{currentFileIndex + 1} / {task.totalCount}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 min-w-56">
|
||||
<span className="text-sm text-gray-600">进度:</span>
|
||||
<Progress
|
||||
percent={currentProgress}
|
||||
showInfo={false}
|
||||
className="w-24 h-2"
|
||||
/>
|
||||
<span className="text-sm font-medium">{currentProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex-1 flex">
|
||||
{/* Annotation Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Only show for text and image types */}
|
||||
{(task.datasetType === "text" || task.datasetType === "image") && (
|
||||
<div className="w-80 border-l border-gray-200 p-4 space-y-4">
|
||||
{/* Progress Stats */}
|
||||
<Card>
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">已完成</span>
|
||||
<span className="font-medium text-green-500">
|
||||
{annotationProgress.completed}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">已跳过</span>
|
||||
<span className="font-medium text-red-500">
|
||||
{annotationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">剩余</span>
|
||||
<span className="font-medium text-gray-600">
|
||||
{annotationProgress.total -
|
||||
annotationProgress.completed -
|
||||
annotationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 my-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">总进度</span>
|
||||
<span className="font-medium">{currentProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-2">
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={handleSaveAndNext}
|
||||
className="bg-green-500 border-green-500 hover:bg-green-600 hover:border-green-600"
|
||||
icon={<CheckCircle className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
保存并下一个
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
onClick={handleSkipAndNext}
|
||||
icon={<SkipForward className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
跳过并下一个
|
||||
</Button>
|
||||
<Button block icon={<Save className="w-4 h-4 mr-2" />}>
|
||||
仅保存
|
||||
</Button>
|
||||
<Button block icon={<Eye className="w-4 h-4 mr-2" />}>
|
||||
预览结果
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
block
|
||||
disabled={currentFileIndex === 0}
|
||||
onClick={() => setCurrentFileIndex(currentFileIndex - 1)}
|
||||
>
|
||||
上一个
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
disabled={currentFileIndex === task.totalCount - 1}
|
||||
onClick={() => setCurrentFileIndex(currentFileIndex + 1)}
|
||||
>
|
||||
下一个
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
当前: {currentFileIndex + 1} / {task.totalCount}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">自动保存</span>
|
||||
<Checkbox defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">快捷键提示</span>
|
||||
<Checkbox defaultChecked />
|
||||
</div>
|
||||
<Button block icon={<Settings className="w-4 h-4 mr-2" />}>
|
||||
更多设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Card, Button, Badge, Slider, message } from "antd";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Scissors,
|
||||
Save,
|
||||
CheckCircle,
|
||||
Trash2,
|
||||
Edit,
|
||||
Mic,
|
||||
AudioWaveformIcon as Waveform,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AudioSegment {
|
||||
id: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
transcription: string;
|
||||
label: string;
|
||||
confidence?: number;
|
||||
speaker?: string;
|
||||
}
|
||||
|
||||
interface AudioAnnotationWorkspaceProps {
|
||||
task: any;
|
||||
currentFileIndex: number;
|
||||
onSaveAndNext: () => void;
|
||||
onSkipAndNext: () => void;
|
||||
}
|
||||
|
||||
// 模拟音频数据
|
||||
const mockAudioFiles = [
|
||||
{
|
||||
id: "1",
|
||||
name: "interview_001.wav",
|
||||
url: "/placeholder-audio.mp3", // 这里应该是实际的音频文件URL
|
||||
duration: 180, // 3分钟
|
||||
segments: [
|
||||
{
|
||||
id: "1",
|
||||
startTime: 0,
|
||||
endTime: 15,
|
||||
transcription: "你好,欢迎参加今天的访谈。请先介绍一下自己。",
|
||||
label: "问题",
|
||||
confidence: 0.95,
|
||||
speaker: "主持人",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
startTime: 15,
|
||||
endTime: 45,
|
||||
transcription:
|
||||
"大家好,我是张三,目前在一家科技公司担任产品经理,有五年的工作经验。",
|
||||
label: "回答",
|
||||
confidence: 0.88,
|
||||
speaker: "受访者",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
startTime: 45,
|
||||
endTime: 60,
|
||||
transcription: "很好,那么请谈谈你对人工智能发展的看法。",
|
||||
label: "问题",
|
||||
confidence: 0.92,
|
||||
speaker: "主持人",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 预定义标签
|
||||
const audioLabels = [
|
||||
{ name: "问题", color: "#3B82F6" },
|
||||
{ name: "回答", color: "#10B981" },
|
||||
{ name: "讨论", color: "#F59E0B" },
|
||||
{ name: "总结", color: "#EF4444" },
|
||||
{ name: "背景音", color: "#8B5CF6" },
|
||||
{ name: "其他", color: "#6B7280" },
|
||||
];
|
||||
|
||||
export default function AudioAnnotationWorkspace({
|
||||
task,
|
||||
currentFileIndex,
|
||||
onSaveAndNext,
|
||||
onSkipAndNext,
|
||||
}: AudioAnnotationWorkspaceProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [currentAudio] = useState(mockAudioFiles[0]);
|
||||
const [segments, setSegments] = useState<AudioSegment[]>(
|
||||
currentAudio.segments
|
||||
);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(currentAudio.duration);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
|
||||
const [isCreatingSegment, setIsCreatingSegment] = useState(false);
|
||||
const [newSegmentStart, setNewSegmentStart] = useState(0);
|
||||
const [editingSegment, setEditingSegment] = useState<AudioSegment | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
|
||||
audio.addEventListener("timeupdate", updateTime);
|
||||
audio.addEventListener("loadedmetadata", updateDuration);
|
||||
audio.addEventListener("ended", handleEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", updateTime);
|
||||
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||
audio.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const handleSeek = (time: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (value: number[]) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const newVolume = value[0];
|
||||
audio.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isMuted) {
|
||||
audio.volume = volume;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
audio.volume = 0;
|
||||
setIsMuted(true);
|
||||
}
|
||||
};
|
||||
|
||||
const startCreatingSegment = () => {
|
||||
setIsCreatingSegment(true);
|
||||
setNewSegmentStart(currentTime);
|
||||
toast({
|
||||
title: "开始创建片段",
|
||||
description: `片段起始时间: ${formatTime(currentTime)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const finishCreatingSegment = () => {
|
||||
if (!isCreatingSegment) return;
|
||||
|
||||
const newSegment: AudioSegment = {
|
||||
id: Date.now().toString(),
|
||||
startTime: newSegmentStart,
|
||||
endTime: currentTime,
|
||||
transcription: "",
|
||||
label: audioLabels[0].name,
|
||||
speaker: "",
|
||||
};
|
||||
|
||||
setSegments([...segments, newSegment]);
|
||||
setIsCreatingSegment(false);
|
||||
setEditingSegment(newSegment);
|
||||
|
||||
toast({
|
||||
title: "片段已创建",
|
||||
description: `时长: ${formatTime(currentTime - newSegmentStart)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSegment = (id: string) => {
|
||||
setSegments(segments.filter((s) => s.id !== id));
|
||||
setSelectedSegment(null);
|
||||
toast({
|
||||
title: "片段已删除",
|
||||
description: "音频片段已被删除",
|
||||
});
|
||||
};
|
||||
|
||||
const updateSegment = (updatedSegment: AudioSegment) => {
|
||||
setSegments(
|
||||
segments.map((s) => (s.id === updatedSegment.id ? updatedSegment : s))
|
||||
);
|
||||
setEditingSegment(null);
|
||||
toast({
|
||||
title: "片段已更新",
|
||||
description: "转录内容已保存",
|
||||
});
|
||||
};
|
||||
|
||||
const playSegment = (segment: AudioSegment) => {
|
||||
handleSeek(segment.startTime);
|
||||
setSelectedSegment(segment.id);
|
||||
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.play();
|
||||
setIsPlaying(true);
|
||||
|
||||
// 在片段结束时暂停
|
||||
const checkEnd = () => {
|
||||
if (audio.currentTime >= segment.endTime) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
audio.removeEventListener("timeupdate", checkEnd);
|
||||
}
|
||||
};
|
||||
audio.addEventListener("timeupdate", checkEnd);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getSegmentColor = (label: string) => {
|
||||
const labelConfig = audioLabels.find((l) => l.name === label);
|
||||
return labelConfig?.color || "#6B7280";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Audio Player */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Audio Element */}
|
||||
<audio ref={audioRef} src={currentAudio.url} preload="metadata" />
|
||||
|
||||
{/* Player Controls */}
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSeek(Math.max(0, currentTime - 10))}
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-6 h-6" />
|
||||
) : (
|
||||
<Play className="w-6 h-6" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSeek(Math.min(duration, currentTime + 10))}
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration}
|
||||
step={0.1}
|
||||
onValueChange={(value) => handleSeek(value[0])}
|
||||
className="w-full"
|
||||
/>
|
||||
{/* Segment Visualization */}
|
||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||
{segments.map((segment) => {
|
||||
const left = (segment.startTime / duration) * 100;
|
||||
const width =
|
||||
((segment.endTime - segment.startTime) / duration) * 100;
|
||||
return (
|
||||
<div
|
||||
key={segment.id}
|
||||
className="absolute top-0 h-full opacity-30 rounded"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: getSegmentColor(segment.label),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Current Creating Segment */}
|
||||
{isCreatingSegment && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-red-400 opacity-50 rounded"
|
||||
style={{
|
||||
left: `${(newSegmentStart / duration) * 100}%`,
|
||||
width: `${
|
||||
((currentTime - newSegmentStart) / duration) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={toggleMute}>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-4 h-4" />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{isCreatingSegment ? (
|
||||
<Button
|
||||
onClick={finishCreatingSegment}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
完成片段
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={startCreatingSegment} variant="outline">
|
||||
<Scissors className="w-4 h-4 mr-2" />
|
||||
创建片段
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex">
|
||||
{/* Segments List */}
|
||||
<div className="w-96 border-r bg-gray-50 p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">音频片段</h3>
|
||||
<Badge variant="outline">{segments.length} 个片段</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-96">
|
||||
<div className="space-y-2">
|
||||
{segments.map((segment) => (
|
||||
<Card
|
||||
key={segment.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedSegment === segment.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setSelectedSegment(segment.id)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{
|
||||
backgroundColor: getSegmentColor(segment.label),
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{segment.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playSegment(segment);
|
||||
}}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSegment(segment);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSegment(segment.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTime(segment.startTime)} -{" "}
|
||||
{formatTime(segment.endTime)}
|
||||
{segment.speaker && ` | ${segment.speaker}`}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 line-clamp-2">
|
||||
{segment.transcription || "未转录"}
|
||||
</p>
|
||||
{segment.confidence && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
置信度:
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{(segment.confidence * 100).toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcription Editor */}
|
||||
<div className="flex-1 p-6">
|
||||
{editingSegment ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Mic className="w-5 h-5" />
|
||||
<span>编辑转录</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">开始时间</label>
|
||||
<Input
|
||||
value={formatTime(editingSegment.startTime)}
|
||||
readOnly
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">结束时间</label>
|
||||
<Input
|
||||
value={formatTime(editingSegment.endTime)}
|
||||
readOnly
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">标签</label>
|
||||
<select
|
||||
value={editingSegment.label}
|
||||
onChange={(e) =>
|
||||
setEditingSegment({
|
||||
...editingSegment,
|
||||
label: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||
>
|
||||
{audioLabels.map((label) => (
|
||||
<option key={label.name} value={label.name}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">说话人</label>
|
||||
<Input
|
||||
value={editingSegment.speaker || ""}
|
||||
onChange={(e) =>
|
||||
setEditingSegment({
|
||||
...editingSegment,
|
||||
speaker: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="输入说话人名称"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">转录内容</label>
|
||||
<Textarea
|
||||
value={editingSegment.transcription}
|
||||
onChange={(e) =>
|
||||
setEditingSegment({
|
||||
...editingSegment,
|
||||
transcription: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="输入或编辑转录内容..."
|
||||
rows={6}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingSegment(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateSegment(editingSegment)}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : selectedSegment ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Waveform className="w-5 h-5" />
|
||||
<span>片段详情</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const segment = segments.find(
|
||||
(s) => s.id === selectedSegment
|
||||
);
|
||||
if (!segment) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">
|
||||
时间范围
|
||||
</span>
|
||||
<p className="font-medium">
|
||||
{formatTime(segment.startTime)} -{" "}
|
||||
{formatTime(segment.endTime)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">时长</span>
|
||||
<p className="font-medium">
|
||||
{formatTime(segment.endTime - segment.startTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">标签</span>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{
|
||||
backgroundColor: getSegmentColor(segment.label),
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">{segment.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{segment.speaker && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">说话人</span>
|
||||
<p className="font-medium">{segment.speaker}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">转录内容</span>
|
||||
<p className="mt-1 p-3 bg-gray-50 rounded text-sm">
|
||||
{segment.transcription || "暂无转录内容"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{segment.confidence && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">置信度</span>
|
||||
<p className="font-medium">
|
||||
{(segment.confidence * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => playSegment(segment)}
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
播放片段
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setEditingSegment(segment)}
|
||||
variant="outline"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Mic className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
音频标注工作区
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
选择一个音频片段开始编辑转录内容
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<p>• 点击"创建片段"开始标记音频片段</p>
|
||||
<p>• 选择片段进行转录和标注</p>
|
||||
<p>• 使用播放控件精确定位音频位置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
文件: {currentAudio.name} | 片段: {segments.length} | 总时长:{" "}
|
||||
{formatTime(duration)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={onSkipAndNext} variant="outline">
|
||||
跳过
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSaveAndNext}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
保存并下一个
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import type React from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Button, Badge, Checkbox, message } from "antd";
|
||||
import {
|
||||
Square,
|
||||
Circle,
|
||||
MousePointer,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCcw,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Annotation {
|
||||
id: string;
|
||||
type: "rectangle" | "circle" | "polygon";
|
||||
label: string;
|
||||
color: string;
|
||||
coordinates: number[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface ImageAnnotationWorkspaceProps {
|
||||
task: any;
|
||||
currentFileIndex: number;
|
||||
onSaveAndNext: () => void;
|
||||
onSkipAndNext: () => void;
|
||||
}
|
||||
|
||||
// 模拟医学图像数据
|
||||
const mockMedicalImages = [
|
||||
{
|
||||
id: "1",
|
||||
name: "2024-123456",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide1",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_e6dd5540-9ca4-4277-ad2b-4debaa1c8ddg.jpg-oibLbUmFpZMkLTmwZB7lT1UWKFlOLA.jpeg",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "2024-234567",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide2",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 2",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "2025-345678",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide3",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 3",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "1234-123456",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide4",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 4",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "2025-456789",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide5",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 5",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "2025-567890",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide6",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 6",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "2025-678901",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide7",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 7",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "2025-789012",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide8",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 8",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "2025-890123",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide9",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 9",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "2025-901234",
|
||||
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide10",
|
||||
url: "/placeholder.svg?height=600&width=800&text=Medical Image 10",
|
||||
},
|
||||
];
|
||||
|
||||
// 医学标注选项
|
||||
const medicalAnnotationOptions = [
|
||||
{
|
||||
id: "tumor_present",
|
||||
label: "是否有肿瘤",
|
||||
type: "radio",
|
||||
options: ["是", "否"],
|
||||
},
|
||||
{
|
||||
id: "tumor_type",
|
||||
label: "肿瘤形成",
|
||||
type: "checkbox",
|
||||
options: ["腺管形成"],
|
||||
},
|
||||
{ id: "grade_1", label: "1级", type: "checkbox", options: ["1[x]"] },
|
||||
{ id: "grade_2", label: "2级", type: "checkbox", options: ["2[x]"] },
|
||||
{ id: "remarks", label: "备注", type: "textarea" },
|
||||
{
|
||||
id: "nuclear_polymorphism",
|
||||
label: "核多形性",
|
||||
type: "checkbox",
|
||||
options: ["核分裂象"],
|
||||
},
|
||||
{
|
||||
id: "histological_type",
|
||||
label: "组织学类型",
|
||||
type: "checkbox",
|
||||
options: ["1[b]", "2[y]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "small_time_lesion",
|
||||
label: "小时病位置[3]",
|
||||
type: "checkbox",
|
||||
options: ["1[b]", "2[y]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "ductal_position",
|
||||
label: "导管原位置[4]",
|
||||
type: "checkbox",
|
||||
options: ["1[o]", "2[p]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "ductal_position_large",
|
||||
label: "导管原位置件大于腺分",
|
||||
type: "checkbox",
|
||||
options: ["腺分裂象"],
|
||||
},
|
||||
{
|
||||
id: "mitosis",
|
||||
label: "化[5]",
|
||||
type: "checkbox",
|
||||
options: ["1[o]", "2[p]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "original_position",
|
||||
label: "原位实性乳头状[6]",
|
||||
type: "checkbox",
|
||||
options: ["1[o]", "2[p]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "infiltrating_lesion",
|
||||
label: "浸润性病(非特殊型)[7]",
|
||||
type: "checkbox",
|
||||
options: ["1[o]", "2[p]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "infiltrating_small",
|
||||
label: "浸润性小叶癌[8]",
|
||||
type: "checkbox",
|
||||
options: ["脉管侵犯"],
|
||||
},
|
||||
{
|
||||
id: "infiltrating_real",
|
||||
label: "浸润实性乳头状癌[9]",
|
||||
type: "checkbox",
|
||||
options: ["1[o]", "2[p]", "3[t]"],
|
||||
},
|
||||
{
|
||||
id: "other_lesion",
|
||||
label: "其他病[0]",
|
||||
type: "checkbox",
|
||||
options: ["+[k]"],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ImageAnnotationWorkspace({
|
||||
currentFileIndex,
|
||||
}: ImageAnnotationWorkspaceProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(
|
||||
currentFileIndex || 0
|
||||
);
|
||||
const [currentImage, setCurrentImage] = useState(
|
||||
mockMedicalImages[selectedImageIndex]
|
||||
);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [selectedTool, setSelectedTool] = useState<
|
||||
"select" | "rectangle" | "circle"
|
||||
>("select");
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [startPoint, setStartPoint] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [annotationValues, setAnnotationValues] = useState<Record<string, any>>(
|
||||
{}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentImage(mockMedicalImages[selectedImageIndex]);
|
||||
drawCanvas();
|
||||
}, [selectedImageIndex, annotations, zoom, pan]);
|
||||
|
||||
const drawCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
ctx.save();
|
||||
ctx.scale(zoom, zoom);
|
||||
ctx.translate(pan.x, pan.y);
|
||||
ctx.drawImage(img, 0, 0, canvas.width / zoom, canvas.height / zoom);
|
||||
|
||||
// 绘制标注
|
||||
annotations.forEach((annotation) => {
|
||||
if (!annotation.visible) return;
|
||||
|
||||
ctx.strokeStyle = annotation.color;
|
||||
ctx.fillStyle = annotation.color + "20";
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
if (annotation.type === "rectangle") {
|
||||
const [x, y, width, height] = annotation.coordinates;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
ctx.fillRect(x, y, width, height);
|
||||
} else if (annotation.type === "circle") {
|
||||
const [centerX, centerY, radius] = annotation.coordinates;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (selectedAnnotation === annotation.id) {
|
||||
ctx.strokeStyle = "#FF0000";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([5, 5]);
|
||||
|
||||
if (annotation.type === "rectangle") {
|
||||
const [x, y, width, height] = annotation.coordinates;
|
||||
ctx.strokeRect(x - 2, y - 2, width + 4, height + 4);
|
||||
} else if (annotation.type === "circle") {
|
||||
const [centerX, centerY, radius] = annotation.coordinates;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius + 2, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
img.src = currentImage.url;
|
||||
};
|
||||
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left - pan.x) / zoom;
|
||||
const y = (e.clientY - rect.top - pan.y) / zoom;
|
||||
|
||||
if (selectedTool === "rectangle" || selectedTool === "circle") {
|
||||
setIsDrawing(true);
|
||||
setStartPoint({ x, y });
|
||||
} else if (selectedTool === "select") {
|
||||
const clickedAnnotation = annotations.find((annotation) => {
|
||||
if (annotation.type === "rectangle") {
|
||||
const [ax, ay, width, height] = annotation.coordinates;
|
||||
return x >= ax && x <= ax + width && y >= ay && y <= ay + height;
|
||||
} else if (annotation.type === "circle") {
|
||||
const [centerX, centerY, radius] = annotation.coordinates;
|
||||
const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
||||
return distance <= radius;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
setSelectedAnnotation(clickedAnnotation?.id || null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left - pan.x) / zoom;
|
||||
const y = (e.clientY - rect.top - pan.y) / zoom;
|
||||
|
||||
drawCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(zoom, zoom);
|
||||
ctx.translate(pan.x, pan.y);
|
||||
ctx.strokeStyle = "#3B82F6";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
|
||||
if (selectedTool === "rectangle") {
|
||||
const width = x - startPoint.x;
|
||||
const height = y - startPoint.y;
|
||||
ctx.strokeRect(startPoint.x, startPoint.y, width, height);
|
||||
} else if (selectedTool === "circle") {
|
||||
const radius = Math.sqrt(
|
||||
(x - startPoint.x) ** 2 + (y - startPoint.y) ** 2
|
||||
);
|
||||
ctx.beginPath();
|
||||
ctx.arc(startPoint.x, startPoint.y, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left - pan.x) / zoom;
|
||||
const y = (e.clientY - rect.top - pan.y) / zoom;
|
||||
|
||||
let coordinates: number[] = [];
|
||||
|
||||
if (selectedTool === "rectangle") {
|
||||
const width = x - startPoint.x;
|
||||
const height = y - startPoint.y;
|
||||
coordinates = [startPoint.x, startPoint.y, width, height];
|
||||
} else if (selectedTool === "circle") {
|
||||
const radius = Math.sqrt(
|
||||
(x - startPoint.x) ** 2 + (y - startPoint.y) ** 2
|
||||
);
|
||||
coordinates = [startPoint.x, startPoint.y, radius];
|
||||
}
|
||||
|
||||
if (coordinates.length > 0) {
|
||||
const newAnnotation: Annotation = {
|
||||
id: Date.now().toString(),
|
||||
type: selectedTool as "rectangle" | "circle",
|
||||
label: "标注",
|
||||
color: "#3B82F6",
|
||||
coordinates,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
setAnnotations([...annotations, newAnnotation]);
|
||||
}
|
||||
|
||||
setIsDrawing(false);
|
||||
};
|
||||
|
||||
const handleAnnotationValueChange = (optionId: string, value: any) => {
|
||||
setAnnotationValues((prev) => ({
|
||||
...prev,
|
||||
[optionId]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
message({
|
||||
title: "标注已更新",
|
||||
description: "医学标注信息已保存",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex">
|
||||
{/* Left Sidebar - Image List */}
|
||||
<div className="w-80 border-r bg-white">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline">image</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800">img</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">case_id</span>
|
||||
<span className="text-sm font-mono">#13754</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-6 h-6 bg-purple-500 rounded-full flex items-center justify-center text-white text-xs">
|
||||
DE
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">de #14803</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">11 days ago</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image List */}
|
||||
<div className="p-2">
|
||||
{mockMedicalImages.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`flex items-center p-3 mb-2 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedImageIndex === index
|
||||
? "bg-blue-50 border border-blue-200"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setSelectedImageIndex(index)}
|
||||
>
|
||||
<div className="w-8 h-8 bg-gray-200 rounded flex items-center justify-center text-sm font-medium mr-3">
|
||||
{index + 1}
|
||||
</div>
|
||||
<img
|
||||
src={image.thumbnail || "/placeholder.svg"}
|
||||
alt={`Slide ${index + 1}`}
|
||||
className="w-12 h-12 rounded border mr-3"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{image.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Main Image Display */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">WSI图像预览</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
病理号: <span className="font-mono">1234-123456</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
取材部位: <span>余乳</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 border rounded-lg overflow-hidden bg-gray-100 relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-full object-contain cursor-crosshair"
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
/>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 left-4 flex items-center space-x-2 bg-white rounded-lg shadow-lg p-2">
|
||||
<Button onClick={() => setZoom(Math.max(zoom / 1.2, 0.1))}>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm px-2">{Math.round(zoom * 100)}%</span>
|
||||
<Button onClick={() => setZoom(Math.min(zoom * 1.2, 5))}>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tool Selection */}
|
||||
<div className="absolute top-4 left-4 flex items-center space-x-2 bg-white rounded-lg shadow-lg p-2">
|
||||
<Button
|
||||
variant={selectedTool === "select" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTool("select")}
|
||||
>
|
||||
<MousePointer className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTool === "rectangle" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTool("rectangle")}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTool === "circle" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTool("circle")}
|
||||
>
|
||||
<Circle className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<div className="flex items-center justify-center mt-4 space-x-2">
|
||||
<Button>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600 mx-4">手</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Annotation Panel */}
|
||||
<div className="w-80 border-l bg-gray-50 p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-4">标注</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{medicalAnnotationOptions.map((option) => (
|
||||
<div key={option.id} className="space-y-2">
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
|
||||
{option.type === "radio" && (
|
||||
<div className="space-y-1">
|
||||
{option.options?.map((opt) => (
|
||||
<div key={opt} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={option.id}
|
||||
value={opt}
|
||||
onChange={(e) =>
|
||||
handleAnnotationValueChange(
|
||||
option.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">{opt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{option.type === "checkbox" && (
|
||||
<div className="space-y-1">
|
||||
{option.options?.map((opt) => (
|
||||
<div key={opt} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
annotationValues[`${option.id}_${opt}`] || false
|
||||
}
|
||||
onChange={(checked) =>
|
||||
handleAnnotationValueChange(
|
||||
`${option.id}_${opt}`,
|
||||
checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{opt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{option.type === "textarea" && (
|
||||
<textarea
|
||||
className="w-full p-2 border rounded-md text-sm resize-none"
|
||||
rows={3}
|
||||
placeholder={`请输入${option.label}`}
|
||||
value={annotationValues[option.id] || ""}
|
||||
onChange={(e) =>
|
||||
handleAnnotationValueChange(option.id, e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
className="w-full bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState } from "react";
|
||||
import { Card, Button, Badge, Input, Checkbox } from "antd";
|
||||
|
||||
import {
|
||||
File,
|
||||
Search,
|
||||
CheckCircle,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
MessageSquare,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface QAPair {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
id: string;
|
||||
name: string;
|
||||
qaPairs: QAPair[];
|
||||
}
|
||||
|
||||
interface TextAnnotationWorkspaceProps {
|
||||
task: any;
|
||||
currentFileIndex: number;
|
||||
onSaveAndNext: () => void;
|
||||
onSkipAndNext: () => void;
|
||||
}
|
||||
|
||||
// 模拟文件数据
|
||||
const mockFiles: FileData[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "document_001.txt",
|
||||
qaPairs: [
|
||||
{
|
||||
id: "1",
|
||||
question: "什么是人工智能?",
|
||||
answer:
|
||||
"人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。",
|
||||
status: "pending",
|
||||
confidence: 0.85,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
question: "机器学习和深度学习有什么区别?",
|
||||
answer:
|
||||
"机器学习是人工智能的一个子集,而深度学习是机器学习的一个子集。深度学习使用神经网络来模拟人脑的工作方式。",
|
||||
status: "pending",
|
||||
confidence: 0.92,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
question: "什么是神经网络?",
|
||||
answer:
|
||||
"神经网络是一种受生物神经网络启发的计算模型,由相互连接的节点(神经元)组成,能够学习和识别模式。",
|
||||
status: "pending",
|
||||
confidence: 0.78,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "document_002.txt",
|
||||
qaPairs: [
|
||||
{
|
||||
id: "4",
|
||||
question: "什么是自然语言处理?",
|
||||
answer:
|
||||
"自然语言处理(NLP)是人工智能的一个分支,专注于使计算机能够理解、解释和生成人类语言。",
|
||||
status: "pending",
|
||||
confidence: 0.88,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
question: "计算机视觉的应用有哪些?",
|
||||
answer:
|
||||
"计算机视觉广泛应用于图像识别、人脸识别、自动驾驶、医学影像分析、安防监控等领域。",
|
||||
status: "pending",
|
||||
confidence: 0.91,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TextAnnotationWorkspace({
|
||||
onSaveAndNext,
|
||||
onSkipAndNext,
|
||||
}: TextAnnotationWorkspaceProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<FileData | null>(
|
||||
mockFiles[0]
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [selectedQAs, setSelectedQAs] = useState<string[]>([]);
|
||||
|
||||
const handleFileSelect = (file: FileData) => {
|
||||
setSelectedFile(file);
|
||||
setSelectedQAs([]);
|
||||
};
|
||||
|
||||
const handleQAStatusChange = (
|
||||
qaId: string,
|
||||
status: "approved" | "rejected"
|
||||
) => {
|
||||
if (selectedFile) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
qa.id === qaId ? { ...qa, status } : qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
|
||||
message({
|
||||
title: status === "approved" ? "已标记为留用" : "已标记为不留用",
|
||||
description: `QA对 "${qaId}" 状态已更新`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchApprove = () => {
|
||||
if (selectedFile && selectedQAs.length > 0) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
selectedQAs.includes(qa.id)
|
||||
? { ...qa, status: "approved" as const }
|
||||
: qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
setSelectedQAs([]);
|
||||
|
||||
message({
|
||||
title: "批量操作完成",
|
||||
description: `已将 ${selectedQAs.length} 个QA对标记为留用`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchReject = () => {
|
||||
if (selectedFile && selectedQAs.length > 0) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
selectedQAs.includes(qa.id)
|
||||
? { ...qa, status: "rejected" as const }
|
||||
: qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
setSelectedQAs([]);
|
||||
|
||||
message({
|
||||
title: "批量操作完成",
|
||||
description: `已将 ${selectedQAs.length} 个QA对标记为不留用`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleQASelect = (qaId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedQAs([...selectedQAs, qaId]);
|
||||
} else {
|
||||
setSelectedQAs(selectedQAs.filter((id) => id !== qaId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked && selectedFile) {
|
||||
setSelectedQAs(selectedFile.qaPairs.map((qa) => qa.id));
|
||||
} else {
|
||||
setSelectedQAs([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return <Badge className="bg-green-100 text-green-800">留用</Badge>;
|
||||
case "rejected":
|
||||
return <Badge className="bg-red-100 text-red-800">不留用</Badge>;
|
||||
default:
|
||||
return <Badge>待标注</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return "text-gray-500";
|
||||
if (confidence >= 0.8) return "text-green-600";
|
||||
if (confidence >= 0.6) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const filteredQAs =
|
||||
selectedFile?.qaPairs.filter((qa) => {
|
||||
const matchesSearch =
|
||||
qa.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
qa.answer.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || qa.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex">
|
||||
{/* File List */}
|
||||
<div className="w-80 border-r bg-gray-50 p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">文件列表</h3>
|
||||
<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="搜索文件..." className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-96">
|
||||
<div className="space-y-2">
|
||||
{mockFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedFile?.id === file.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<File className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{file.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{file.qaPairs.length} 个QA对
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QA Annotation Area */}
|
||||
<div className="flex-1 p-6">
|
||||
{selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{selectedFile.name}</h2>
|
||||
<p className="text-gray-500">
|
||||
共 {selectedFile.qaPairs.length} 个QA对
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={onSaveAndNext}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
保存并下一个
|
||||
</Button>
|
||||
<Button onClick={onSkipAndNext}>跳过</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Batch Actions */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<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="搜索QA对..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待标注</option>
|
||||
<option value="approved">已留用</option>
|
||||
<option value="rejected">不留用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedQAs.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
已选择 {selectedQAs.length} 个
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBatchApprove}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
批量留用
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchReject}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
批量不留用
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* QA List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedQAs.length === filteredQAs.length &&
|
||||
filteredQAs.length > 0
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm font-medium">全选</span>
|
||||
</div>
|
||||
|
||||
<div className="h-500">
|
||||
<div className="space-y-4">
|
||||
{filteredQAs.map((qa) => (
|
||||
<Card
|
||||
key={qa.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedQAs.includes(qa.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleQASelect(qa.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<MessageSquare className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
QA-{qa.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{qa.confidence && (
|
||||
<span
|
||||
className={`text-xs ${getConfidenceColor(
|
||||
qa.confidence
|
||||
)}`}
|
||||
>
|
||||
置信度: {(qa.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{getStatusBadge(qa.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<HelpCircle className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
问题
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm bg-blue-50 p-3 rounded">
|
||||
{qa.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<MessageSquare className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
答案
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm bg-green-50 p-3 rounded">
|
||||
{qa.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleQAStatusChange(qa.id, "approved")
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
qa.status === "approved" ? "default" : "outline"
|
||||
}
|
||||
className={
|
||||
qa.status === "approved"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
留用
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleQAStatusChange(qa.id, "rejected")
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
qa.status === "rejected"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
不留用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<File className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
选择文件开始标注
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
从左侧文件列表中选择一个文件开始标注工作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Card, Button, Badge, Slider, message } from "antd";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
MousePointer,
|
||||
CheckCircle,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Target,
|
||||
Maximize,
|
||||
} from "lucide-react";
|
||||
|
||||
interface VideoAnnotation {
|
||||
id: string;
|
||||
frameTime: number;
|
||||
type: "rectangle" | "point" | "polygon";
|
||||
coordinates: number[];
|
||||
label: string;
|
||||
color: string;
|
||||
trackId?: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface VideoTrack {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
annotations: VideoAnnotation[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface VideoAnnotationWorkspaceProps {
|
||||
task: any;
|
||||
currentFileIndex: number;
|
||||
onSaveAndNext: () => void;
|
||||
onSkipAndNext: () => void;
|
||||
}
|
||||
|
||||
// 模拟视频数据
|
||||
const mockVideoFiles = [
|
||||
{
|
||||
id: "1",
|
||||
name: "traffic_scene_001.mp4",
|
||||
url: "/placeholder-video.mp4", // 这里应该是实际的视频文件URL
|
||||
duration: 120, // 2分钟
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
];
|
||||
|
||||
// 预定义标签
|
||||
const videoLabels = [
|
||||
{ name: "车辆", color: "#3B82F6" },
|
||||
{ name: "行人", color: "#10B981" },
|
||||
{ name: "自行车", color: "#F59E0B" },
|
||||
{ name: "交通灯", color: "#EF4444" },
|
||||
{ name: "路标", color: "#8B5CF6" },
|
||||
{ name: "其他", color: "#6B7280" },
|
||||
];
|
||||
|
||||
export default function VideoAnnotationWorkspace({
|
||||
task,
|
||||
currentFileIndex,
|
||||
onSaveAndNext,
|
||||
onSkipAndNext,
|
||||
}: VideoAnnotationWorkspaceProps) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [currentVideo] = useState(mockVideoFiles[0]);
|
||||
const [tracks, setTracks] = useState<VideoTrack[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(currentVideo.duration);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [selectedTool, setSelectedTool] = useState<
|
||||
"select" | "rectangle" | "point"
|
||||
>("select");
|
||||
const [selectedLabel, setSelectedLabel] = useState(videoLabels[0]);
|
||||
const [selectedTrack, setSelectedTrack] = useState<string | null>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [startPoint, setStartPoint] = useState({ x: 0, y: 0 });
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(video.currentTime);
|
||||
const updateDuration = () => setDuration(video.duration);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
|
||||
video.addEventListener("timeupdate", updateTime);
|
||||
video.addEventListener("loadedmetadata", updateDuration);
|
||||
video.addEventListener("ended", handleEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", updateTime);
|
||||
video.removeEventListener("loadedmetadata", updateDuration);
|
||||
video.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
drawCanvas();
|
||||
}, [currentTime, tracks, selectedTrack]);
|
||||
|
||||
const drawCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
if (!canvas || !video) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制当前帧的标注
|
||||
tracks.forEach((track) => {
|
||||
if (!track.annotations.length) return;
|
||||
|
||||
// 找到当前时间最近的标注
|
||||
const currentAnnotation = track.annotations
|
||||
.filter((ann) => Math.abs(ann.frameTime - currentTime) < 0.1)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Math.abs(a.frameTime - currentTime) -
|
||||
Math.abs(b.frameTime - currentTime)
|
||||
)[0];
|
||||
|
||||
if (!currentAnnotation || !currentAnnotation.visible) return;
|
||||
|
||||
ctx.strokeStyle = track.color;
|
||||
ctx.fillStyle = track.color + "20";
|
||||
ctx.lineWidth = selectedTrack === track.id ? 3 : 2;
|
||||
|
||||
if (currentAnnotation.type === "rectangle") {
|
||||
const [x, y, width, height] = currentAnnotation.coordinates;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
ctx.fillRect(x, y, width, height);
|
||||
|
||||
// 绘制标签
|
||||
ctx.fillStyle = track.color;
|
||||
ctx.fillRect(x, y - 20, ctx.measureText(track.label).width + 8, 20);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(track.label, x + 4, y - 6);
|
||||
} else if (currentAnnotation.type === "point") {
|
||||
const [x, y] = currentAnnotation.coordinates;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制标签
|
||||
ctx.fillStyle = track.color;
|
||||
ctx.fillRect(
|
||||
x + 10,
|
||||
y - 10,
|
||||
ctx.measureText(track.label).width + 8,
|
||||
20
|
||||
);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(track.label, x + 14, y + 4);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const handleSeek = (time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (value: number[]) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const newVolume = value[0];
|
||||
video.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isMuted) {
|
||||
video.volume = volume;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
video.volume = 0;
|
||||
setIsMuted(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeedChange = (speed: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.playbackRate = speed;
|
||||
setPlaybackSpeed(speed);
|
||||
};
|
||||
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (selectedTool === "select") return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
if (selectedTool === "point") {
|
||||
createPointAnnotation(x, y);
|
||||
} else if (selectedTool === "rectangle") {
|
||||
setIsDrawing(true);
|
||||
setStartPoint({ x, y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || selectedTool !== "rectangle") return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// 实时预览
|
||||
drawCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.strokeStyle = selectedLabel.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(
|
||||
startPoint.x,
|
||||
startPoint.y,
|
||||
x - startPoint.x,
|
||||
y - startPoint.y
|
||||
);
|
||||
ctx.setLineDash([]);
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || selectedTool !== "rectangle") return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const width = x - startPoint.x;
|
||||
const height = y - startPoint.y;
|
||||
|
||||
if (Math.abs(width) > 10 && Math.abs(height) > 10) {
|
||||
createRectangleAnnotation(startPoint.x, startPoint.y, width, height);
|
||||
}
|
||||
|
||||
setIsDrawing(false);
|
||||
};
|
||||
|
||||
const createPointAnnotation = (x: number, y: number) => {
|
||||
const newAnnotation: VideoAnnotation = {
|
||||
id: Date.now().toString(),
|
||||
frameTime: currentTime,
|
||||
type: "point",
|
||||
coordinates: [x, y],
|
||||
label: selectedLabel.name,
|
||||
color: selectedLabel.color,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const newTrack: VideoTrack = {
|
||||
id: Date.now().toString(),
|
||||
label: selectedLabel.name,
|
||||
color: selectedLabel.color,
|
||||
annotations: [newAnnotation],
|
||||
startTime: currentTime,
|
||||
endTime: currentTime,
|
||||
};
|
||||
|
||||
setTracks([...tracks, newTrack]);
|
||||
messageApi({
|
||||
title: "点标注已添加",
|
||||
description: `在时间 ${formatTime(currentTime)} 添加了点标注`,
|
||||
});
|
||||
};
|
||||
|
||||
const createRectangleAnnotation = (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const newAnnotation: VideoAnnotation = {
|
||||
id: Date.now().toString(),
|
||||
frameTime: currentTime,
|
||||
type: "rectangle",
|
||||
coordinates: [x, y, width, height],
|
||||
label: selectedLabel.name,
|
||||
color: selectedLabel.color,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const newTrack: VideoTrack = {
|
||||
id: Date.now().toString(),
|
||||
label: selectedLabel.name,
|
||||
color: selectedLabel.color,
|
||||
annotations: [newAnnotation],
|
||||
startTime: currentTime,
|
||||
endTime: currentTime,
|
||||
};
|
||||
|
||||
setTracks([...tracks, newTrack]);
|
||||
messageApi.success(`在时间 ${formatTime(currentTime)} 添加了矩形标注`);
|
||||
};
|
||||
|
||||
const deleteTrack = (trackId: string) => {
|
||||
setTracks(tracks.filter((t) => t.id !== trackId));
|
||||
setSelectedTrack(null);
|
||||
messageApi.success("标注轨迹已被删除");
|
||||
};
|
||||
|
||||
const toggleTrackVisibility = (trackId: string) => {
|
||||
setTracks(
|
||||
tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
annotations: track.annotations.map((ann) => ({
|
||||
...ann,
|
||||
visible: !ann.visible,
|
||||
})),
|
||||
}
|
||||
: track
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (video.requestFullscreen) {
|
||||
video.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex">
|
||||
{/* Tools Panel */}
|
||||
<div className="w-64 border-r bg-gray-50 p-4 space-y-4">
|
||||
{/* Tool Selection */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">工具</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button
|
||||
variant={selectedTool === "select" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setSelectedTool("select")}
|
||||
>
|
||||
<MousePointer className="w-4 h-4 mr-2" />
|
||||
选择
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTool === "rectangle" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setSelectedTool("rectangle")}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
矩形
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTool === "point" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setSelectedTool("point")}
|
||||
>
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
点标注
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Labels */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">标签</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{videoLabels.map((label) => (
|
||||
<Button
|
||||
key={label.name}
|
||||
variant={
|
||||
selectedLabel.name === label.name ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setSelectedLabel(label)}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 mr-2 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">播放速度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{[0.25, 0.5, 1, 1.5, 2].map((speed) => (
|
||||
<Button
|
||||
key={speed}
|
||||
variant={playbackSpeed === speed ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleSpeedChange(speed)}
|
||||
>
|
||||
{speed}x
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tracks List */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">标注轨迹</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-2">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={`p-2 border rounded cursor-pointer ${
|
||||
selectedTrack === track.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
onClick={() => setSelectedTrack(track.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: track.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{track.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTrackVisibility(track.id);
|
||||
}}
|
||||
>
|
||||
{track.annotations[0]?.visible ? (
|
||||
<Eye className="w-3 h-3" />
|
||||
) : (
|
||||
<EyeOff className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteTrack(track.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{track.annotations.length} 个关键帧
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{tracks.length === 0 && (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
暂无轨迹
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Video Player and Canvas */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Video Container */}
|
||||
<div className="flex-1 relative bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentVideo.url}
|
||||
className="w-full h-full object-contain"
|
||||
preload="metadata"
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={450}
|
||||
className="absolute top-0 left-0 w-full h-full cursor-crosshair"
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
/>
|
||||
|
||||
{/* Video Info Overlay */}
|
||||
<div className="absolute top-4 left-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm">
|
||||
{currentVideo.name} | {formatTime(currentTime)} /{" "}
|
||||
{formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{/* Tool Info Overlay */}
|
||||
<div className="absolute top-4 right-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm">
|
||||
{selectedTool === "select"
|
||||
? "选择模式"
|
||||
: selectedTool === "rectangle"
|
||||
? "矩形标注"
|
||||
: "点标注"}{" "}
|
||||
| {selectedLabel.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Controls */}
|
||||
<div className="border-t bg-white p-4 space-y-4">
|
||||
{/* Timeline */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
max={duration}
|
||||
step={0.1}
|
||||
onValueChange={(value) => handleSeek(value[0])}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSeek(Math.max(0, currentTime - 10))}
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-6 h-6" />
|
||||
) : (
|
||||
<Play className="w-6 h-6" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSeek(Math.min(duration, currentTime + 10))}
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={toggleMute}>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-4 h-4" />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={toggleFullscreen}>
|
||||
<Maximize className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline">{playbackSpeed}x</Badge>
|
||||
<Badge variant="outline">{tracks.length} 轨迹</Badge>
|
||||
<Button onClick={onSkipAndNext} variant="outline">
|
||||
跳过
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSaveAndNext}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
保存并下一个
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
346
frontend/src/pages/DataAnnotation/Create/CreateTask.tsx
Normal file
346
frontend/src/pages/DataAnnotation/Create/CreateTask.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Button, Input, Select, Divider, Form, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
CheckOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { mockTemplates } from "@/mock/annotation";
|
||||
import CustomTemplateDialog from "./components/CustomTemplateDialog";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
|
||||
import {
|
||||
DatasetType,
|
||||
type Dataset,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
type: "text" | "image";
|
||||
preview?: string;
|
||||
icon: React.ReactNode;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
const templateCategories = ["Computer Vision", "Natural Language Processing"];
|
||||
|
||||
export default function AnnotationTaskCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
|
||||
useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [datasetFilter, setDatasetFilter] = useState("all");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
|
||||
|
||||
// 用于Form的受控数据
|
||||
const [formValues, setFormValues] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
datasetId: "",
|
||||
templateId: "",
|
||||
});
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet();
|
||||
setDatasets(data.results || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = mockTemplates.filter(
|
||||
(template) => template.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleDatasetSelect = (datasetId: string) => {
|
||||
const dataset = datasets.find((ds) => ds.id === datasetId) || null;
|
||||
setSelectedDataset(dataset);
|
||||
setFormValues((prev) => ({ ...prev, datasetId }));
|
||||
if (dataset?.type === DatasetType.PRETRAIN_IMAGE) {
|
||||
setSelectedCategory("Computer Vision");
|
||||
} else if (dataset?.type === DatasetType.PRETRAIN_TEXT) {
|
||||
setSelectedCategory("Natural Language Processing");
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setFormValues((prev) => ({ ...prev, templateId: "" }));
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setFormValues((prev) => ({ ...prev, templateId: template.id }));
|
||||
};
|
||||
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setFormValues({ ...formValues, ...allValues });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const dataset = datasets.find((ds) => ds.id === values.datasetId);
|
||||
const template = mockTemplates.find(
|
||||
(tpl) => tpl.id === values.templateId
|
||||
);
|
||||
if (!dataset) {
|
||||
message.error("请选择数据集");
|
||||
return;
|
||||
}
|
||||
if (!template) {
|
||||
message.error("请选择标注模板");
|
||||
return;
|
||||
}
|
||||
const taskData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dataset,
|
||||
template,
|
||||
};
|
||||
// onCreateTask(taskData); // 实际创建逻辑
|
||||
message.success("标注任务创建成功");
|
||||
navigate("/data/annotation");
|
||||
} catch (e) {
|
||||
// 校验失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomTemplate = (templateData: any) => {
|
||||
setSelectedTemplate(templateData);
|
||||
setFormValues((prev) => ({ ...prev, templateId: templateData.id }));
|
||||
message.success(`自定义模板 "${templateData.name}" 已创建`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Link to="/data/annotation">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold bg-clip-text">创建标注任务</h1>
|
||||
</div>
|
||||
|
||||
<div className="h-full flex-1 overflow-y-auto flex flex-col bg-white rounded-lg shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2">基本信息</h2>
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input placeholder="输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="任务描述"
|
||||
name="description"
|
||||
rules={[{ required: true, message: "请输入任务描述" }]}
|
||||
>
|
||||
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="选择数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
optionFilterProp="children"
|
||||
value={formValues.datasetId}
|
||||
onChange={handleDatasetSelect}
|
||||
placeholder="请选择数据集"
|
||||
size="large"
|
||||
options={datasets.map((dataset) => ({
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="font-medium text-gray-900">
|
||||
{dataset?.icon || <DatabaseOutlined className="mr-2" />}
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{dataset?.fileCount} 文件 • {dataset.size}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板选择 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mt-6 mb-2 flex items-center gap-2">
|
||||
模板选择
|
||||
</h2>
|
||||
<Form.Item
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Category Sidebar */}
|
||||
<div className="w-64 pr-6 border-r border-gray-200">
|
||||
<div className="space-y-2">
|
||||
{templateCategories.map((category) => {
|
||||
const isAvailable =
|
||||
selectedDataset?.type === "image"
|
||||
? category === "Computer Vision"
|
||||
: category === "Natural Language Processing";
|
||||
return (
|
||||
<Button
|
||||
key={category}
|
||||
type={
|
||||
selectedCategory === category && isAvailable
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
block
|
||||
disabled={!isAvailable}
|
||||
onClick={() =>
|
||||
isAvailable && setSelectedCategory(category)
|
||||
}
|
||||
style={{ textAlign: "left", marginBottom: 8 }}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
自定义模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Template Grid */}
|
||||
<div className="flex-1 pl-6">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`border rounded-lg cursor-pointer transition-all hover:shadow-md ${
|
||||
formValues.templateId === template.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template.preview && (
|
||||
<div className="aspect-video bg-gray-100 rounded-t-lg overflow-hidden">
|
||||
<img
|
||||
src={template.preview || "/placeholder.svg"}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{template.icon}
|
||||
<span className="font-medium text-sm">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Custom Template Option */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg cursor-pointer transition-all hover:border-gray-400 ${
|
||||
selectedTemplate?.isCustom
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
<div className="aspect-video bg-gray-50 rounded-t-lg flex items-center justify-center">
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 32, color: "#bbb" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<PlusOutlined />
|
||||
<span className="font-medium text-sm">
|
||||
自定义模板
|
||||
</span>
|
||||
</div>
|
||||
{selectedTemplate?.isCustom && (
|
||||
<CheckOutlined style={{ color: "#1677ff" }} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
创建符合特定需求的标注模板
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplate && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
已选择模板
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "#1677ff", marginTop: 4 }}
|
||||
>
|
||||
{selectedTemplate.name} - {selectedTemplate.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end border-t border-gray-200 p-6">
|
||||
<Button onClick={() => navigate("/data/annotation")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Template Dialog */}
|
||||
<CustomTemplateDialog
|
||||
open={showCustomTemplateDialog}
|
||||
onOpenChange={setShowCustomTemplateDialog}
|
||||
onSaveTemplate={handleSaveCustomTemplate}
|
||||
datasetType={selectedDataset?.type || "image"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { datasetTypeMap } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { Database } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
size: 1000,
|
||||
});
|
||||
setDatasets(data.content || []);
|
||||
};
|
||||
fetchDatasets();
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
await createAnnotationTaskUsingPost(values);
|
||||
onClose();
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input placeholder="输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="描述"
|
||||
name="description"
|
||||
rules={[{ required: true, message: "请输入任务描述" }]}
|
||||
>
|
||||
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => ({
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span>
|
||||
{dataset.icon || <Database className="w-4 h-4 mr-2" />}
|
||||
</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{datasetTypeMap[dataset?.datasetType]?.label}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Input,
|
||||
Card,
|
||||
message,
|
||||
Divider,
|
||||
Radio,
|
||||
Form,
|
||||
} from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BorderOutlined,
|
||||
DotChartOutlined,
|
||||
EditOutlined,
|
||||
CheckSquareOutlined,
|
||||
BarsOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
interface CustomTemplateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaveTemplate: (templateData: any) => void;
|
||||
datasetType: "text" | "image";
|
||||
}
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const defaultImageTemplate = `<View style="display: flex; flex-direction: column; height: 100vh; overflow: auto;">
|
||||
<View style="display: flex; height: 100%; gap: 10px;">
|
||||
<View style="height: 100%; width: 85%; display: flex; flex-direction: column; gap: 5px;">
|
||||
<Header value="WSI图像预览" />
|
||||
<View style="min-height: 100%;">
|
||||
<Image name="image" value="$image" zoom="true" />
|
||||
</View>
|
||||
</View>
|
||||
<View style="height: 100%; width: auto;">
|
||||
<View style="width: auto; display: flex;">
|
||||
<Text name="case_id_title" toName="image" value="病例号: $case_id" />
|
||||
</View>
|
||||
<Text name="part_title" toName="image" value="取材部位: $part" />
|
||||
<Header value="标注" />
|
||||
<View style="display: flex; gap: 5px;">
|
||||
<View>
|
||||
<Text name="cancer_or_not_title" value="是否有肿瘤" />
|
||||
<Choices name="cancer_or_not" toName="image">
|
||||
<Choice value="是" alias="1" />
|
||||
<Choice value="否" alias="0" />
|
||||
</Choices>
|
||||
<Text name="remark_title" value="备注" />
|
||||
<TextArea name="remark" toName="image" editable="true"/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>`;
|
||||
|
||||
const defaultTextTemplate = `<View style="display: flex; flex-direction: column; height: 100vh;">
|
||||
<Header value="文本标注界面" />
|
||||
<View style="display: flex; height: 100%; gap: 10px;">
|
||||
<View style="flex: 1; padding: 10px;">
|
||||
<Text name="content" value="$text" />
|
||||
<Labels name="label" toName="content">
|
||||
<Label value="正面" background="green" />
|
||||
<Label value="负面" background="red" />
|
||||
<Label value="中性" background="gray" />
|
||||
</Labels>
|
||||
</View>
|
||||
<View style="width: 300px; padding: 10px; border-left: 1px solid #ccc;">
|
||||
<Header value="标注选项" />
|
||||
<Text name="sentiment_title" value="情感分类" />
|
||||
<Choices name="sentiment" toName="content">
|
||||
<Choice value="正面" />
|
||||
<Choice value="负面" />
|
||||
<Choice value="中性" />
|
||||
</Choices>
|
||||
<Text name="confidence_title" value="置信度" />
|
||||
<Rating name="confidence" toName="content" maxRating="5" />
|
||||
<Text name="comment_title" value="备注" />
|
||||
<TextArea name="comment" toName="content" placeholder="添加备注..." />
|
||||
</View>
|
||||
</View>
|
||||
</View>`;
|
||||
|
||||
const annotationTools = [
|
||||
{ id: "rectangle", label: "矩形框", icon: <BorderOutlined />, type: "image" },
|
||||
{
|
||||
id: "polygon",
|
||||
label: "多边形",
|
||||
icon: <DeploymentUnitOutlined />,
|
||||
type: "image",
|
||||
},
|
||||
{ id: "circle", label: "圆形", icon: <DotChartOutlined />, type: "image" },
|
||||
{ id: "point", label: "关键点", icon: <AppstoreOutlined />, type: "image" },
|
||||
{ id: "text", label: "文本", icon: <EditOutlined />, type: "both" },
|
||||
{ id: "choices", label: "选择题", icon: <BarsOutlined />, type: "both" },
|
||||
{
|
||||
id: "checkbox",
|
||||
label: "多选框",
|
||||
icon: <CheckSquareOutlined />,
|
||||
type: "both",
|
||||
},
|
||||
{ id: "textarea", label: "文本域", icon: <BarsOutlined />, type: "both" },
|
||||
];
|
||||
|
||||
export default function CustomTemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaveTemplate,
|
||||
datasetType,
|
||||
}: CustomTemplateDialogProps) {
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [templateDescription, setTemplateDescription] = useState("");
|
||||
const [templateCode, setTemplateCode] = useState(
|
||||
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!templateName.trim()) {
|
||||
message.error("请输入模板名称");
|
||||
return;
|
||||
}
|
||||
if (!templateCode.trim()) {
|
||||
message.error("请输入模板代码");
|
||||
return;
|
||||
}
|
||||
const templateData = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: templateName,
|
||||
description: templateDescription,
|
||||
code: templateCode,
|
||||
type: datasetType,
|
||||
isCustom: true,
|
||||
};
|
||||
onSaveTemplate(templateData);
|
||||
onOpenChange(false);
|
||||
message.success("自定义模板已保存");
|
||||
setTemplateName("");
|
||||
setTemplateDescription("");
|
||||
setTemplateCode(
|
||||
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
okText={"保存模板"}
|
||||
onOk={handleSave}
|
||||
width={1200}
|
||||
className="max-h-[80vh] overflow-auto"
|
||||
title="自定义标注模板"
|
||||
>
|
||||
<div className="flex min-h-[500px]">
|
||||
<div className="flex-1 pl-6">
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="模板名称 *" required>
|
||||
<Input
|
||||
placeholder="输入模板名称"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="模板描述">
|
||||
<Input
|
||||
placeholder="输入模板描述"
|
||||
value={templateDescription}
|
||||
onChange={(e) => setTemplateDescription(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 font-medium">代码</div>
|
||||
<Card>
|
||||
<TextArea
|
||||
rows={20}
|
||||
value={templateCode}
|
||||
onChange={(e) => setTemplateCode(e.target.value)}
|
||||
placeholder="输入模板代码"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-96 border-l border-gray-100 pl-6">
|
||||
<div className="mb-2 font-medium">预览</div>
|
||||
<Card
|
||||
cover={
|
||||
<img
|
||||
alt="预览图像"
|
||||
src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_9b855efe-ce37-4387-a845-d8ef9aaa1a8g.jpg-GhkhlenJlzOQLSDqyBm2iaC6jbv7VA.jpeg"
|
||||
className="object-cover h-48"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500">病例号:</span>
|
||||
<span>undefined</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500">取材部位:</span>
|
||||
<span>undefined</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<div className="font-medium mb-2">标注</div>
|
||||
<div className="mb-2 text-gray-500">是否有肿瘤</div>
|
||||
<Radio.Group>
|
||||
<Radio value="1">是[1]</Radio>
|
||||
<Radio value="0">否[2]</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4">
|
||||
<div className="text-gray-500 mb-1">备注</div>
|
||||
<TextArea rows={3} placeholder="添加备注..." />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
181
frontend/src/pages/DataAnnotation/Home/DataAnnotation.tsx
Normal file
181
frontend/src/pages/DataAnnotation/Home/DataAnnotation.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from "react";
|
||||
import { Card, Button, Table, message } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { AnnotationTask } from "../annotation.model";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
deleteAnnotationTaskByIdUsingDelete,
|
||||
queryAnnotationTasksUsingGet,
|
||||
syncAnnotationTaskUsingPost,
|
||||
} from "../annotation.api";
|
||||
import { mapAnnotationTask } from "../annotation.const";
|
||||
import CreateAnnotationTask from "../Create/components/CreateAnnptationTaskDialog";
|
||||
import { ColumnType } from "antd/es/table";
|
||||
|
||||
export default function DataAnnotation() {
|
||||
const navigate = useNavigate();
|
||||
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask);
|
||||
|
||||
const handleAnnotate = (task: AnnotationTask) => {
|
||||
navigate(`/data/annotation/task-annotate/${task.datasetType}/${task.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (task: AnnotationTask) => {
|
||||
await deleteAnnotationTaskByIdUsingDelete({
|
||||
m: task.id,
|
||||
proj: task.projId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSync = async (task: AnnotationTask, format: string) => {
|
||||
await syncAnnotationTaskUsingPost({ task, format });
|
||||
message.success("任务同步请求已发送");
|
||||
};
|
||||
|
||||
const operations = [
|
||||
{
|
||||
key: "annotate",
|
||||
label: "标注",
|
||||
icon: (
|
||||
<EditOutlined
|
||||
className="w-4 h-4 text-green-400"
|
||||
style={{ color: "#52c41a" }}
|
||||
/>
|
||||
),
|
||||
onClick: handleAnnotate,
|
||||
},
|
||||
{
|
||||
key: "sync",
|
||||
label: "同步",
|
||||
icon: <SyncOutlined className="w-4 h-4" style={{ color: "#722ed1" }} />,
|
||||
onClick: handleSync,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnType[] = [
|
||||
{
|
||||
title: "任务名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
fixed: "left" as const,
|
||||
},
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
},
|
||||
{
|
||||
title: "数据集",
|
||||
dataIndex: "datasetName",
|
||||
key: "datasetName",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updatedAt",
|
||||
key: "updatedAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
fixed: "right" as const,
|
||||
width: 150,
|
||||
dataIndex: "actions",
|
||||
render: (_: any, task: AnnotationTask) => (
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
{operations.map((operation) => (
|
||||
<Button
|
||||
key={operation.key}
|
||||
type="text"
|
||||
icon={operation.icon}
|
||||
onClick={() => operation?.onClick?.(task)}
|
||||
title={operation.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">数据标注</h1>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
>
|
||||
创建标注任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters Toolbar */}
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={(keyword) =>
|
||||
setSearchParams({ ...searchParams, keyword })
|
||||
}
|
||||
searchPlaceholder="搜索任务名称、描述"
|
||||
onFiltersChange={handleFiltersChange}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
onReload={fetchData}
|
||||
/>
|
||||
{/* Task List/Card */}
|
||||
{viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table
|
||||
key="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={pagination}
|
||||
scroll={{ x: "max-content", y: "calc(100vh - 20rem)" }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<CardView data={tableData} operations={operations} />
|
||||
)}
|
||||
<CreateAnnotationTask
|
||||
open={showCreateDialog}
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
frontend/src/pages/DataAnnotation/annotation.api.ts
Normal file
262
frontend/src/pages/DataAnnotation/annotation.api.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { get, post, put, del, download } from "@/utils/request";
|
||||
|
||||
// 标注任务管理相关接口
|
||||
export function queryAnnotationTasksUsingGet(params?: any) {
|
||||
return get("/api/project/mappings/list", params);
|
||||
}
|
||||
|
||||
export function createAnnotationTaskUsingPost(data: any) {
|
||||
return post("/api/project/create", data);
|
||||
}
|
||||
|
||||
export function syncAnnotationTaskUsingPost(data: any) {
|
||||
return post(`/api/project/sync`, data);
|
||||
}
|
||||
|
||||
export function queryAnnotationTaskByIdUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}`);
|
||||
}
|
||||
export function deleteAnnotationTaskByIdUsingDelete(params?: any) {
|
||||
return del(`/api/project/mappings`, params);
|
||||
}
|
||||
|
||||
// 智能预标注相关接口
|
||||
export function preAnnotateUsingPost(data: any) {
|
||||
return post("/api/v1/annotation/pre-annotate", data);
|
||||
}
|
||||
|
||||
// 标注数据管理接口
|
||||
export function queryAnnotationDataUsingGet(
|
||||
taskId: string | number,
|
||||
params?: any
|
||||
) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/data`, params);
|
||||
}
|
||||
|
||||
export function submitAnnotationUsingPost(taskId: string | number, data: any) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/annotations`, data);
|
||||
}
|
||||
|
||||
export function updateAnnotationUsingPut(
|
||||
taskId: string | number,
|
||||
annotationId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(
|
||||
`/api/v1/annotation/tasks/${taskId}/annotations/${annotationId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAnnotationUsingDelete(
|
||||
taskId: string | number,
|
||||
annotationId: string | number
|
||||
) {
|
||||
return del(`/api/v1/annotation/tasks/${taskId}/annotations/${annotationId}`);
|
||||
}
|
||||
|
||||
// 标注任务执行控制
|
||||
export function startAnnotationTaskUsingPost(taskId: string | number) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/start`);
|
||||
}
|
||||
|
||||
export function pauseAnnotationTaskUsingPost(taskId: string | number) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/pause`);
|
||||
}
|
||||
|
||||
export function resumeAnnotationTaskUsingPost(taskId: string | number) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/resume`);
|
||||
}
|
||||
|
||||
export function completeAnnotationTaskUsingPost(taskId: string | number) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/complete`);
|
||||
}
|
||||
|
||||
// 标注任务统计信息
|
||||
export function getAnnotationTaskStatisticsUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/statistics`);
|
||||
}
|
||||
|
||||
export function getAnnotationStatisticsUsingGet(params?: any) {
|
||||
return get("/api/v1/annotation/statistics", params);
|
||||
}
|
||||
|
||||
// 标注模板管理
|
||||
export function queryAnnotationTemplatesUsingGet(params?: any) {
|
||||
return get("/api/v1/annotation/templates", params);
|
||||
}
|
||||
|
||||
export function createAnnotationTemplateUsingPost(data: any) {
|
||||
return post("/api/v1/annotation/templates", data);
|
||||
}
|
||||
|
||||
export function queryAnnotationTemplateByIdUsingGet(
|
||||
templateId: string | number
|
||||
) {
|
||||
return get(`/api/v1/annotation/templates/${templateId}`);
|
||||
}
|
||||
|
||||
export function updateAnnotationTemplateByIdUsingPut(
|
||||
templateId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/v1/annotation/templates/${templateId}`, data);
|
||||
}
|
||||
|
||||
export function deleteAnnotationTemplateByIdUsingDelete(
|
||||
templateId: string | number
|
||||
) {
|
||||
return del(`/api/v1/annotation/templates/${templateId}`);
|
||||
}
|
||||
|
||||
// 主动学习相关接口
|
||||
export function queryActiveLearningCandidatesUsingGet(
|
||||
taskId: string | number,
|
||||
params?: any
|
||||
) {
|
||||
return get(
|
||||
`/api/v1/annotation/tasks/${taskId}/active-learning/candidates`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function submitActiveLearningFeedbackUsingPost(
|
||||
taskId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return post(
|
||||
`/api/v1/annotation/tasks/${taskId}/active-learning/feedback`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
export function updateActiveLearningModelUsingPost(
|
||||
taskId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return post(
|
||||
`/api/v1/annotation/tasks/${taskId}/active-learning/update-model`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
// 标注质量控制
|
||||
export function validateAnnotationsUsingPost(
|
||||
taskId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/validate`, data);
|
||||
}
|
||||
|
||||
export function getAnnotationQualityReportUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/quality-report`);
|
||||
}
|
||||
|
||||
// 标注数据导入导出
|
||||
export function exportAnnotationsUsingPost(taskId: string | number, data: any) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/export`, data);
|
||||
}
|
||||
|
||||
export function importAnnotationsUsingPost(taskId: string | number, data: any) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/import`, data);
|
||||
}
|
||||
|
||||
export function downloadAnnotationsUsingGet(
|
||||
taskId: string | number,
|
||||
filename?: string
|
||||
) {
|
||||
return download(
|
||||
`/api/v1/annotation/tasks/${taskId}/download`,
|
||||
null,
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
// 标注者管理
|
||||
export function queryAnnotatorsUsingGet(params?: any) {
|
||||
return get("/api/v1/annotation/annotators", params);
|
||||
}
|
||||
|
||||
export function assignAnnotatorUsingPost(taskId: string | number, data: any) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/assign`, data);
|
||||
}
|
||||
|
||||
export function getAnnotatorStatisticsUsingGet(annotatorId: string | number) {
|
||||
return get(`/api/v1/annotation/annotators/${annotatorId}/statistics`);
|
||||
}
|
||||
|
||||
// 标注配置管理
|
||||
export function getAnnotationConfigUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/config`);
|
||||
}
|
||||
|
||||
export function updateAnnotationConfigUsingPut(
|
||||
taskId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/v1/annotation/tasks/${taskId}/config`, data);
|
||||
}
|
||||
|
||||
// 标注类型和标签管理
|
||||
export function queryAnnotationTypesUsingGet() {
|
||||
return get("/api/v1/annotation/types");
|
||||
}
|
||||
|
||||
export function queryAnnotationLabelsUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/labels`);
|
||||
}
|
||||
|
||||
export function createAnnotationLabelUsingPost(
|
||||
taskId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/labels`, data);
|
||||
}
|
||||
|
||||
export function updateAnnotationLabelUsingPut(
|
||||
taskId: string | number,
|
||||
labelId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/v1/annotation/tasks/${taskId}/labels/${labelId}`, data);
|
||||
}
|
||||
|
||||
export function deleteAnnotationLabelUsingDelete(
|
||||
taskId: string | number,
|
||||
labelId: string | number
|
||||
) {
|
||||
return del(`/api/v1/annotation/tasks/${taskId}/labels/${labelId}`);
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
export function batchAssignAnnotatorsUsingPost(data: any) {
|
||||
return post("/api/v1/annotation/tasks/batch-assign", data);
|
||||
}
|
||||
|
||||
export function batchUpdateTaskStatusUsingPost(data: any) {
|
||||
return post("/api/v1/annotation/tasks/batch-update-status", data);
|
||||
}
|
||||
|
||||
export function batchDeleteTasksUsingPost(data: { taskIds: string[] }) {
|
||||
return post("/api/v1/annotation/tasks/batch-delete", data);
|
||||
}
|
||||
|
||||
// 标注进度跟踪
|
||||
export function getAnnotationProgressUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/progress`);
|
||||
}
|
||||
|
||||
// 标注审核
|
||||
export function submitAnnotationReviewUsingPost(
|
||||
taskId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return post(`/api/v1/annotation/tasks/${taskId}/review`, data);
|
||||
}
|
||||
|
||||
export function getAnnotationReviewResultsUsingGet(
|
||||
taskId: string | number,
|
||||
params?: any
|
||||
) {
|
||||
return get(`/api/v1/annotation/tasks/${taskId}/reviews`, params);
|
||||
}
|
||||
56
frontend/src/pages/DataAnnotation/annotation.const.tsx
Normal file
56
frontend/src/pages/DataAnnotation/annotation.const.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { AnnotationTask, AnnotationTaskStatus } from "./annotation.model";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CustomerServiceOutlined,
|
||||
FileTextOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export const AnnotationTaskStatusMap = {
|
||||
[AnnotationTaskStatus.ACTIVE]: {
|
||||
label: "活跃",
|
||||
value: AnnotationTaskStatus.ACTIVE,
|
||||
color: "#409f17ff",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
[AnnotationTaskStatus.PROCESSING]: {
|
||||
label: "处理中",
|
||||
value: AnnotationTaskStatus.PROCESSING,
|
||||
color: "#2673e5",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
[AnnotationTaskStatus.INACTIVE]: {
|
||||
label: "未激活",
|
||||
value: AnnotationTaskStatus.INACTIVE,
|
||||
color: "#4f4444ff",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
export function mapAnnotationTask(task: AnnotationTask) {
|
||||
return {
|
||||
...task,
|
||||
id: task.mapping_id,
|
||||
projId: task.labelling_project_id,
|
||||
name: task.labelling_project_name,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.last_updated_at,
|
||||
icon: <StickyNote />,
|
||||
iconColor: "bg-blue-100",
|
||||
status: {
|
||||
label:
|
||||
task.status === "completed"
|
||||
? "已完成"
|
||||
: task.status === "in_progress"
|
||||
? "进行中"
|
||||
: task.status === "skipped"
|
||||
? "已跳过"
|
||||
: "待开始",
|
||||
color: "bg-blue-100",
|
||||
},
|
||||
};
|
||||
}
|
||||
27
frontend/src/pages/DataAnnotation/annotation.model.ts
Normal file
27
frontend/src/pages/DataAnnotation/annotation.model.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DatasetType } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
export enum AnnotationTaskStatus {
|
||||
ACTIVE = "active",
|
||||
PROCESSING = "processing",
|
||||
INACTIVE = "inactive",
|
||||
}
|
||||
|
||||
export interface AnnotationTask {
|
||||
id: string;
|
||||
name: string;
|
||||
annotationCount: number;
|
||||
createdAt: string;
|
||||
datasetId: string;
|
||||
description?: string;
|
||||
assignedTo?: string;
|
||||
progress: number;
|
||||
statistics: {
|
||||
accuracy: number;
|
||||
averageTime: number;
|
||||
reviewCount: number;
|
||||
};
|
||||
status: AnnotationTaskStatus;
|
||||
totalDataCount: number;
|
||||
type: DatasetType;
|
||||
updatedAt: string;
|
||||
}
|
||||
131
frontend/src/pages/DataCleansing/Create/CreateTask.tsx
Normal file
131
frontend/src/pages/DataCleansing/Create/CreateTask.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import { Steps, Button, message, Form } from "antd";
|
||||
import { SaveOutlined } from "@ant-design/icons";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createCleaningTaskUsingPost } from "../cleansing.api";
|
||||
import CreateTaskStepOne from "./components/CreateTaskStepOne";
|
||||
import { useCreateStepTwo } from "./hooks/useCreateStepTwo";
|
||||
import { DatasetType } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
export default function CleansingTaskCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [taskConfig, setTaskConfig] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
srcDatasetId: "",
|
||||
srcDatasetName: "",
|
||||
destDatasetName: "",
|
||||
destDatasetType: DatasetType.TEXT,
|
||||
type: DatasetType.TEXT,
|
||||
});
|
||||
|
||||
const {
|
||||
renderStepTwo,
|
||||
selectedOperators,
|
||||
currentStep,
|
||||
handlePrev,
|
||||
handleNext,
|
||||
} = useCreateStepTwo();
|
||||
|
||||
const handleSave = async () => {
|
||||
const task = {
|
||||
...taskConfig,
|
||||
instance: selectedOperators.map((item) => ({
|
||||
id: item.id,
|
||||
overrides: {
|
||||
...item.defaultParams,
|
||||
...item.overrides,
|
||||
},
|
||||
})),
|
||||
};
|
||||
navigate("/data/cleansing?view=task");
|
||||
await createCleaningTaskUsingPost(task);
|
||||
message.success("任务已创建");
|
||||
};
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 1: {
|
||||
const values = form.getFieldsValue();
|
||||
return (
|
||||
values.name &&
|
||||
values.srcDatasetId &&
|
||||
values.destDatasetName &&
|
||||
values.destDatasetType
|
||||
);
|
||||
}
|
||||
case 2:
|
||||
return selectedOperators.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<CreateTaskStepOne
|
||||
form={form}
|
||||
taskConfig={taskConfig}
|
||||
setTaskConfig={setTaskConfig}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return renderStepTwo;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Link to="/data/cleansing">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">创建清洗任务</h1>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<Steps
|
||||
size="small"
|
||||
current={currentStep - 1}
|
||||
items={[{ title: "基本信息" }, { title: "算子编排" }]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Step Content */}
|
||||
<div className="h-full mb-4 flex flex-col overflow-auto flex-1 bg-white rounded shadow-sm">
|
||||
<div className="flex-1 overflow-auto m-6">{renderStepContent()}</div>
|
||||
<div className="flex justify-end p-6 gap-3 border-t border-gray-200">
|
||||
<Button onClick={() => navigate("/data/cleansing")}>取消</Button>
|
||||
{currentStep > 1 && <Button onClick={handlePrev}>上一步</Button>}
|
||||
{currentStep === 2 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
disabled={!canProceed()}
|
||||
>
|
||||
创建任务
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/DataCleansing/Create/CreateTempate.tsx
Normal file
119
frontend/src/pages/DataCleansing/Create/CreateTempate.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from "react";
|
||||
import { Card, Button, Steps, Form, Divider } from "antd";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createCleaningTemplateUsingPost } from "../cleansing.api";
|
||||
import CleansingTemplateStepOne from "./components/CreateTemplateStepOne";
|
||||
import { useCreateStepTwo } from "./hooks/useCreateStepTwo";
|
||||
|
||||
export default function CleansingTemplateCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [templateConfig, setTemplateConfig] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
const template = {
|
||||
...templateConfig,
|
||||
instance: selectedOperators.map((item) => ({
|
||||
id: item.id,
|
||||
overrides: {
|
||||
...item.defaultParams,
|
||||
...item.overrides,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await createCleaningTemplateUsingPost(template);
|
||||
navigate("/data/cleansing?view=template");
|
||||
};
|
||||
|
||||
const {
|
||||
renderStepTwo,
|
||||
selectedOperators,
|
||||
currentStep,
|
||||
handlePrev,
|
||||
handleNext,
|
||||
} = useCreateStepTwo();
|
||||
|
||||
const canProceed = () => {
|
||||
const values = form.getFieldsValue();
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return values.name;
|
||||
case 2:
|
||||
return selectedOperators.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<CleansingTemplateStepOne
|
||||
form={form}
|
||||
templateConfig={templateConfig}
|
||||
setTemplateConfig={setTemplateConfig}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return renderStepTwo;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Link to="/data/cleansing">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">创建清洗模板</h1>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<Steps
|
||||
size="small"
|
||||
current={currentStep}
|
||||
items={[{ title: "基本信息" }, { title: "算子编排" }]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-full mb-4 flex flex-col overflow-auto flex-1 bg-white rounded shadow-sm">
|
||||
<div className="flex-1 overflow-auto m-6">{renderStepContent()}</div>
|
||||
<div className="flex justify-end p-6 gap-3 border-t border-gray-200">
|
||||
<Button onClick={() => navigate("/data/cleansing")}>取消</Button>
|
||||
{currentStep > 1 && <Button onClick={handlePrev}>上一步</Button>}
|
||||
{currentStep === 2 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canProceed()}
|
||||
>
|
||||
创建模板
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
frontend/src/pages/DataCleansing/Create/DragDrop.css
Normal file
411
frontend/src/pages/DataCleansing/Create/DragDrop.css
Normal file
@@ -0,0 +1,411 @@
|
||||
/* PreciseDragDrop.css */
|
||||
.precise-drag-drop {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.containers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.container.drag-over {
|
||||
border-color: #3498db;
|
||||
background-color: #f8fafc;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.container-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.items-list {
|
||||
padding: 20px;
|
||||
min-height: 500px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--item-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.item.dragging {
|
||||
opacity: 0.6;
|
||||
cursor: grabbing;
|
||||
transform: rotate(3deg) scale(1.05);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.item.drag-over.insert-above {
|
||||
border-top: 2px dashed var(--item-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.item.drag-over.insert-below {
|
||||
border-bottom: 2px dashed var(--item-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: var(--item-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.priority-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
background: #f1f3f4;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #bdc3c7;
|
||||
font-size: 16px;
|
||||
cursor: grab;
|
||||
padding: 8px;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #7f8c8d;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 插入位置指示器 */
|
||||
.insert-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.insert-indicator.above {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.insert-indicator.below {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.indicator-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--item-color, #3498db), transparent);
|
||||
}
|
||||
|
||||
.indicator-arrow {
|
||||
color: var(--item-color, #3498db);
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #f8f9fa;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.instruction-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.instruction:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.instruction .icon {
|
||||
font-size: 1.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instruction strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.instruction p {
|
||||
margin: 0;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.items-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.items-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.items-list::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.items-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.containers {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.precise-drag-drop {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.instruction-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
430
frontend/src/pages/DataCleansing/Create/DragExample.tsx
Normal file
430
frontend/src/pages/DataCleansing/Create/DragExample.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import React, { useState } from "react";
|
||||
import "./DragDrop.css";
|
||||
|
||||
const PreciseDragDrop = () => {
|
||||
// 初始数据
|
||||
const [leftItems, setLeftItems] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: "需求分析",
|
||||
type: "analysis",
|
||||
color: "#4CAF50",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "UI设计",
|
||||
type: "design",
|
||||
color: "#2196F3",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "前端开发",
|
||||
type: "development",
|
||||
color: "#FF9800",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "后端开发",
|
||||
type: "development",
|
||||
color: "#9C27B0",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "功能测试",
|
||||
type: "testing",
|
||||
color: "#3F51B5",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "部署上线",
|
||||
type: "deployment",
|
||||
color: "#009688",
|
||||
priority: "low",
|
||||
},
|
||||
]);
|
||||
|
||||
const [rightItems, setRightItems] = useState([
|
||||
{
|
||||
id: 7,
|
||||
title: "项目启动",
|
||||
type: "planning",
|
||||
color: "#E91E63",
|
||||
priority: "high",
|
||||
},
|
||||
]);
|
||||
|
||||
const [draggingItem, setDraggingItem] = useState(null);
|
||||
const [insertPosition, setInsertPosition] = useState(null); // 'above' 或 'below'
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleDragStart = (e, item, source) => {
|
||||
setDraggingItem({ ...item, source });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
|
||||
setTimeout(() => {
|
||||
e.target.classList.add("dragging");
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = (e) => {
|
||||
setDraggingItem(null);
|
||||
setInsertPosition(null);
|
||||
e.target.classList.remove("dragging");
|
||||
};
|
||||
|
||||
// 处理容器拖拽经过
|
||||
const handleContainerDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// 处理容器拖拽离开
|
||||
const handleContainerDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setInsertPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理项目拖拽经过(用于精确插入)
|
||||
const handleItemDragOver = (e, itemId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mouseY = e.clientY;
|
||||
const elementMiddle = rect.top + rect.height;
|
||||
|
||||
// 判断鼠标在元素的上半部分还是下半部分
|
||||
const newPosition = mouseY < elementMiddle ? "above" : "below";
|
||||
|
||||
setInsertPosition(newPosition);
|
||||
};
|
||||
|
||||
// 处理项目拖拽离开
|
||||
const handleItemDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setInsertPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理放置到右侧容器空白区域
|
||||
const handleDropToRightContainer = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggingItem) return;
|
||||
|
||||
// 如果是从左侧拖拽过来的
|
||||
if (draggingItem.source === "left") {
|
||||
// 检查是否已存在
|
||||
const exists = rightItems.some((item) => item.id === draggingItem.id);
|
||||
if (!exists) {
|
||||
setRightItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...draggingItem,
|
||||
source: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
setLeftItems((prev) =>
|
||||
prev.filter((item) => item.id !== draggingItem.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 处理放置到右侧容器的特定位置
|
||||
const handleDropToRightItem = (e, targetItemId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!draggingItem) return;
|
||||
|
||||
// 从左侧拖拽到右侧的精确插入
|
||||
if (draggingItem.source === "left") {
|
||||
const targetIndex = rightItems.findIndex(
|
||||
(item) => item.id === targetItemId
|
||||
);
|
||||
|
||||
if (targetIndex !== -1) {
|
||||
const insertIndex =
|
||||
insertPosition === "above" ? targetIndex : targetIndex + 1;
|
||||
|
||||
// 检查是否已存在
|
||||
const exists = rightItems.some((item) => item.id === draggingItem.id);
|
||||
if (!exists) {
|
||||
const newRightItems = [...rightItems];
|
||||
newRightItems.splice(insertIndex, 0, {
|
||||
...draggingItem,
|
||||
source: "right",
|
||||
});
|
||||
|
||||
setRightItems(newRightItems);
|
||||
setLeftItems((prev) =>
|
||||
prev.filter((item) => item.id !== draggingItem.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 右侧容器内的重新排序
|
||||
else if (draggingItem.source === "right") {
|
||||
const draggedIndex = rightItems.findIndex(
|
||||
(item) => item.id === draggingItem.id
|
||||
);
|
||||
const targetIndex = rightItems.findIndex(
|
||||
(item) => item.id === targetItemId
|
||||
);
|
||||
|
||||
if (
|
||||
draggedIndex !== -1 &&
|
||||
targetIndex !== -1 &&
|
||||
draggedIndex !== targetIndex
|
||||
) {
|
||||
const newItems = [...rightItems];
|
||||
const [draggedItem] = newItems.splice(draggedIndex, 1);
|
||||
|
||||
// 计算正确的插入位置
|
||||
let insertIndex =
|
||||
insertPosition === "above" ? targetIndex : targetIndex + 1;
|
||||
if (draggedIndex < insertIndex) {
|
||||
insertIndex--; // 调整插入位置,因为已经移除了原元素
|
||||
}
|
||||
|
||||
newItems.splice(insertIndex, 0, draggedItem);
|
||||
setRightItems(newItems);
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 处理拖拽回左侧容器
|
||||
const handleDropToLeft = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggingItem || draggingItem.source !== "right") return;
|
||||
|
||||
setRightItems((prev) => prev.filter((item) => item.id !== draggingItem.id));
|
||||
setLeftItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...draggingItem,
|
||||
source: "left",
|
||||
},
|
||||
]);
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 重置拖拽状态
|
||||
const resetDragState = () => {
|
||||
setDraggingItem(null);
|
||||
setInsertPosition(null);
|
||||
};
|
||||
|
||||
// 清空右侧容器
|
||||
const clearRightContainer = () => {
|
||||
setLeftItems((prev) => [
|
||||
...prev,
|
||||
...rightItems.map((item) => ({
|
||||
...item,
|
||||
source: "left",
|
||||
})),
|
||||
]);
|
||||
setRightItems([]);
|
||||
};
|
||||
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case "analysis":
|
||||
return "📊";
|
||||
case "design":
|
||||
return "🎨";
|
||||
case "development":
|
||||
return "💻";
|
||||
case "testing":
|
||||
return "🧪";
|
||||
case "deployment":
|
||||
return "🚀";
|
||||
case "planning":
|
||||
return "📋";
|
||||
default:
|
||||
return "📌";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级标签
|
||||
const getPriorityLabel = (priority) => {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return { label: "高优先级", class: "priority-high" };
|
||||
case "medium":
|
||||
return { label: "中优先级", class: "priority-medium" };
|
||||
case "low":
|
||||
return { label: "低优先级", class: "priority-low" };
|
||||
default:
|
||||
return { label: "普通", class: "priority-medium" };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="precise-drag-drop">
|
||||
<div className="header">
|
||||
<h1>精确位置拖拽排序</h1>
|
||||
<p>拖拽时悬停在项目上方或下方可选择精确插入位置</p>
|
||||
</div>
|
||||
|
||||
<div className="containers">
|
||||
{/* 左侧容器 - 待办事项 */}
|
||||
<div
|
||||
className={`container left-container `}
|
||||
onDragOver={(e) => handleContainerDragOver(e, "left")}
|
||||
onDragLeave={handleContainerDragLeave}
|
||||
onDrop={handleDropToLeft}
|
||||
>
|
||||
<div className="container-header">
|
||||
<h2>📋 待办事项</h2>
|
||||
<span className="count">{leftItems.length} 项</span>
|
||||
</div>
|
||||
<div className="items-list">
|
||||
{leftItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="item"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, "left")}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{ "--item-color": item.color }}
|
||||
>
|
||||
<div className="item-content">
|
||||
<span className="item-icon">{getTypeIcon(item.type)}</span>
|
||||
<div className="item-info">
|
||||
<span className="item-title">{item.title}</span>
|
||||
<span
|
||||
className={`priority-tag ${
|
||||
getPriorityLabel(item.priority).class
|
||||
}`}
|
||||
>
|
||||
{getPriorityLabel(item.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-type">{item.type}</div>
|
||||
</div>
|
||||
))}
|
||||
{leftItems.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>🎉 所有任务已完成!</p>
|
||||
<span>从右侧拖拽项目回来重新安排</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧容器 - 进行中的任务 */}
|
||||
<div
|
||||
className={`container right-container`}
|
||||
onDragOver={(e) => handleContainerDragOver(e, "right")}
|
||||
onDragLeave={handleContainerDragLeave}
|
||||
onDrop={handleDropToRightContainer}
|
||||
>
|
||||
<div className="container-header">
|
||||
<h2>🚀 进行中的任务</h2>
|
||||
<div className="header-actions">
|
||||
<span className="count">{rightItems.length} 项</span>
|
||||
{rightItems.length > 0 && (
|
||||
<button className="clear-btn" onClick={clearRightContainer}>
|
||||
清空所有
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="items-list">
|
||||
{rightItems.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>📥 暂无进行中的任务</p>
|
||||
<span>从左侧拖拽项目过来开始工作</span>
|
||||
</div>
|
||||
) : (
|
||||
rightItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`item `}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, "right")}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
||||
onDragLeave={handleItemDragLeave}
|
||||
onDrop={(e) => handleDropToRightItem(e, item.id)}
|
||||
style={{ "--item-color": item.color }}
|
||||
>
|
||||
<div className="item-content">
|
||||
<span className="item-index">{index + 1}</span>
|
||||
<span className="item-icon">{getTypeIcon(item.type)}</span>
|
||||
<div className="item-info">
|
||||
<span className="item-title">{item.title}</span>
|
||||
<span
|
||||
className={`priority-tag ${
|
||||
getPriorityLabel(item.priority).class
|
||||
}`}
|
||||
>
|
||||
{getPriorityLabel(item.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<span className="drag-handle">⋮⋮</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="instructions">
|
||||
<h3>🎯 操作指南</h3>
|
||||
<div className="instruction-grid">
|
||||
<div className="instruction">
|
||||
<span className="icon">🎯</span>
|
||||
<div>
|
||||
<strong>精确插入</strong>
|
||||
<p>拖拽时悬停在项目上方或下方选择插入位置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction">
|
||||
<span className="icon">🔄</span>
|
||||
<div>
|
||||
<strong>重新排序</strong>
|
||||
<p>在右侧容器内拖拽调整任务顺序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction">
|
||||
<span className="icon">📤</span>
|
||||
<div>
|
||||
<strong>移回待办</strong>
|
||||
<p>从右侧拖拽任务回左侧容器</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction">
|
||||
<span className="icon">🧹</span>
|
||||
<div>
|
||||
<strong>批量操作</strong>
|
||||
<p>使用"清空所有"按钮快速重置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreciseDragDrop;
|
||||
@@ -0,0 +1,119 @@
|
||||
import RadioCard from "@/components/RadioCard";
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import {
|
||||
datasetTypeMap,
|
||||
datasetTypes,
|
||||
} from "@/pages/DataManagement/dataset.const";
|
||||
import {
|
||||
Dataset,
|
||||
DatasetSubType,
|
||||
DatasetType,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
import { Input, Select, Form } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { Database } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export default function CreateTaskStepOne({
|
||||
form,
|
||||
taskConfig,
|
||||
setTaskConfig,
|
||||
}: {
|
||||
form: any;
|
||||
taskConfig: {
|
||||
name: string;
|
||||
description: string;
|
||||
datasetId: string;
|
||||
destDatasetName: string;
|
||||
type: DatasetType;
|
||||
destDatasetType: DatasetSubType;
|
||||
};
|
||||
setTaskConfig: (config: any) => void;
|
||||
}) {
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet({ page: 0, size: 1000 });
|
||||
setDatasets(data.content || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, []);
|
||||
|
||||
const handleValuesChange = (currentValue, allValues) => {
|
||||
const [key, value] = Object.entries(currentValue)[0];
|
||||
let dataset = null;
|
||||
if (key === "srcDatasetId") {
|
||||
dataset = datasets.find((d) => d.id === value);
|
||||
setTaskConfig({
|
||||
...taskConfig,
|
||||
...allValues,
|
||||
srcDatasetName: dataset?.name || "",
|
||||
});
|
||||
} else {
|
||||
setTaskConfig({ ...taskConfig, ...allValues });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={taskConfig}
|
||||
onValuesChange={handleValuesChange}
|
||||
>
|
||||
<h2 className="font-medium text-gray-900 text-base mb-2">任务信息</h2>
|
||||
<Form.Item label="名称" name="name" required>
|
||||
<Input placeholder="输入清洗任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="描述清洗任务的目标和要求" rows={4} />
|
||||
</Form.Item>
|
||||
<h2 className="font-medium text-gray-900 pt-6 mb-2 text-base">
|
||||
数据源选择
|
||||
</h2>
|
||||
<Form.Item label="源数据集" name="srcDatasetId" required>
|
||||
<Select
|
||||
placeholder="请选择源数据集"
|
||||
options={datasets.map((dataset) => ({
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span>
|
||||
{dataset.icon || <Database className="w-4 h-4 mr-2" />}
|
||||
</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{datasetTypeMap[dataset?.datasetType]?.label}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标数据集名称" name="destDatasetName" required>
|
||||
<Input placeholder="输入目标数据集名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="目标数据集类型"
|
||||
name="destDatasetType"
|
||||
rules={[{ required: true, message: "请选择目标数据集类型" }]}
|
||||
>
|
||||
<RadioCard
|
||||
options={datasetTypes}
|
||||
value={taskConfig.destDatasetType}
|
||||
onChange={(type) => {
|
||||
form.setFieldValue("destDatasetType", type);
|
||||
setTaskConfig({
|
||||
...taskConfig,
|
||||
destDatasetType: type as DatasetSubType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Input, Form } from "antd";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function CreateTemplateStepOne({
|
||||
form,
|
||||
templateConfig,
|
||||
setTemplateConfig,
|
||||
}: {
|
||||
form: any;
|
||||
templateConfig: { name: string; description: string; type: string };
|
||||
setTemplateConfig: React.Dispatch<
|
||||
React.SetStateAction<{ name: string; description: string; type: string }>
|
||||
>;
|
||||
}) {
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setTemplateConfig({ ...templateConfig, ...allValues });
|
||||
};
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={templateConfig}
|
||||
onValuesChange={handleValuesChange}
|
||||
>
|
||||
<Form.Item
|
||||
label="模板名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入模板名称" }]}
|
||||
>
|
||||
<Input placeholder="输入模板名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="模板描述" name="description">
|
||||
<TextArea placeholder="描述模板的用途和特点" rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { Tag, Divider, Form } from "antd";
|
||||
import ParamConfig from "./ParamConfig";
|
||||
import { Settings } from "lucide-react";
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
|
||||
// OperatorConfig/OperatorTemplate 类型需根据主文件实际导入
|
||||
interface OperatorConfigProps {
|
||||
selectedOp: OperatorI;
|
||||
renderParamConfig?: (
|
||||
operator: OperatorI,
|
||||
paramKey: string,
|
||||
param: any
|
||||
) => React.ReactNode;
|
||||
handleConfigChange?: (
|
||||
operatorId: string,
|
||||
paramKey: string,
|
||||
value: any
|
||||
) => void;
|
||||
}
|
||||
|
||||
const OperatorConfig: React.FC<OperatorConfigProps> = ({
|
||||
selectedOp,
|
||||
renderParamConfig,
|
||||
handleConfigChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-1/4 min-w-3xs flex flex-col h-full">
|
||||
<div className="px-4 pb-4 border-b border-gray-200">
|
||||
<span className="font-semibold text-base flex items-center gap-2">
|
||||
<Settings />
|
||||
参数配置
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{selectedOp ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{selectedOp.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{selectedOp.description}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{selectedOp?.tags?.map((tag: string) => (
|
||||
<Tag key={tag} color="default">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<Form layout="vertical">
|
||||
{Object.entries(selectedOp.configs).map(([key, param]) =>
|
||||
renderParamConfig ? (
|
||||
renderParamConfig(selectedOp, key, param)
|
||||
) : (
|
||||
<ParamConfig
|
||||
key={key}
|
||||
operator={selectedOp}
|
||||
paramKey={key}
|
||||
param={param}
|
||||
onParamChange={handleConfigChange}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<Settings className="w-full w-10 h-10 mb-4 opacity-50" />
|
||||
<div>请选择一个算子进行参数配置</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperatorConfig;
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
Tag,
|
||||
Checkbox,
|
||||
Button,
|
||||
} from "antd";
|
||||
import { StarFilled, StarOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { CategoryI, OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
interface OperatorListProps {
|
||||
operators: OperatorI[];
|
||||
favorites: Set<string>;
|
||||
showPoppular?: boolean;
|
||||
toggleFavorite: (id: string) => void;
|
||||
toggleOperator: (operator: OperatorI) => void;
|
||||
selectedOperators: OperatorI[];
|
||||
onDragOperator: (
|
||||
e: React.DragEvent,
|
||||
item: OperatorI,
|
||||
source: "library"
|
||||
) => void;
|
||||
}
|
||||
|
||||
const OperatorList: React.FC<OperatorListProps> = ({
|
||||
operators,
|
||||
favorites,
|
||||
toggleFavorite,
|
||||
toggleOperator,
|
||||
showPoppular,
|
||||
selectedOperators,
|
||||
onDragOperator,
|
||||
}) => (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{operators.map((operator) => {
|
||||
// 判断是否已选
|
||||
const isSelected = selectedOperators.some((op) => op.id === operator.id);
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
key={operator.id}
|
||||
draggable
|
||||
hoverable
|
||||
onDragStart={(e) => onDragOperator(e, operator, "library")}
|
||||
onClick={() => toggleOperator(operator)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
<Checkbox checked={isSelected} />
|
||||
<span className="flex-1 min-w-0 font-medium text-sm overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{operator.name}
|
||||
</span>
|
||||
</div>
|
||||
{showPoppular && operator.isStar && (
|
||||
<Tag color="gold" className="text-xs">
|
||||
热门
|
||||
</Tag>
|
||||
)}
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(operator.id);
|
||||
}}
|
||||
>
|
||||
{favorites.has(operator.id) ? (
|
||||
<StarFilled style={{ color: "#FFD700" }} />
|
||||
) : (
|
||||
<StarOutlined />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface OperatorLibraryProps {
|
||||
selectedOperators: OperatorI[];
|
||||
operatorList: OperatorI[];
|
||||
categoryOptions: CategoryI[];
|
||||
setSelectedOperators: (operators: OperatorI[]) => void;
|
||||
toggleOperator: (template: OperatorI) => void;
|
||||
handleDragStart: (
|
||||
e: React.DragEvent,
|
||||
item: OperatorI,
|
||||
source: "library"
|
||||
) => void;
|
||||
}
|
||||
|
||||
const OperatorLibrary: React.FC<OperatorLibraryProps> = ({
|
||||
selectedOperators,
|
||||
operatorList,
|
||||
categoryOptions,
|
||||
setSelectedOperators,
|
||||
toggleOperator,
|
||||
handleDragStart,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set([])
|
||||
);
|
||||
|
||||
// 按分类分组
|
||||
const groupedOperators = useMemo(() => {
|
||||
const groups: { [key: string]: OperatorI[] } = {};
|
||||
categoryOptions.forEach((cat: any) => {
|
||||
groups[cat.name] = {
|
||||
...cat,
|
||||
operators: operatorList.filter((op) => op.categories?.includes(cat.id)),
|
||||
};
|
||||
});
|
||||
|
||||
if (selectedCategory && selectedCategory !== "all") {
|
||||
Object.keys(groups).forEach((key) => {
|
||||
if (groups[key].id !== selectedCategory) {
|
||||
delete groups[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
Object.keys(groups).forEach((key) => {
|
||||
groups[key].operators = groups[key].operators.filter((operator) =>
|
||||
operator.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
if (groups[key].operators.length === 0) {
|
||||
delete groups[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (showFavorites) {
|
||||
Object.keys(groups).forEach((key) => {
|
||||
groups[key].operators = groups[key].operators.filter((operator) =>
|
||||
favorites.has(operator.id)
|
||||
);
|
||||
if (groups[key].operators.length === 0) {
|
||||
delete groups[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setExpandedCategories(new Set(Object.keys(groups)));
|
||||
return groups;
|
||||
}, [categoryOptions, selectedCategory, searchTerm, showFavorites]);
|
||||
|
||||
// 过滤算子
|
||||
const filteredOperators = useMemo(() => {
|
||||
const filtered = Object.values(groupedOperators).flatMap(
|
||||
(category) => category.operators
|
||||
);
|
||||
return filtered;
|
||||
}, [groupedOperators]);
|
||||
|
||||
// 收藏切换
|
||||
const toggleFavorite = (operatorId: string) => {
|
||||
const newFavorites = new Set(favorites);
|
||||
if (newFavorites.has(operatorId)) {
|
||||
newFavorites.delete(operatorId);
|
||||
} else {
|
||||
newFavorites.add(operatorId);
|
||||
}
|
||||
setFavorites(newFavorites);
|
||||
};
|
||||
|
||||
// 全选分类算子
|
||||
const handleSelectAll = (operators: OperatorI[]) => {
|
||||
const newSelected = [...selectedOperators];
|
||||
operators.forEach((operator) => {
|
||||
if (!newSelected.some((op) => op.id === operator.id)) {
|
||||
newSelected.push(operator);
|
||||
}
|
||||
});
|
||||
setSelectedOperators(newSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-1/4 h-full min-w-3xs flex flex-col">
|
||||
<div className="pb-4 border-b border-gray-200">
|
||||
<span className="flex items-center font-semibold text-base">
|
||||
<Layers className="w-4 h-4 mr-2" />
|
||||
算子库({filteredOperators.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-full pt-4 pr-4 overflow-hidden">
|
||||
{/* 过滤器 */}
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-4">
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索算子名称..."
|
||||
value={searchTerm}
|
||||
allowClear
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
options={[{ label: "全部分类", value: "all" }, ...categoryOptions]}
|
||||
onChange={setSelectedCategory}
|
||||
className="flex-1"
|
||||
placeholder="选择分类"
|
||||
></Select>
|
||||
<Tooltip title="只看收藏">
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowFavorites(!showFavorites)}
|
||||
>
|
||||
{showFavorites ? (
|
||||
<StarFilled style={{ color: "#FFD700" }} />
|
||||
) : (
|
||||
<StarOutlined />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* 算子列表 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 分类算子 */}
|
||||
<Collapse
|
||||
ghost
|
||||
activeKey={Array.from(expandedCategories)}
|
||||
onChange={(keys) =>
|
||||
setExpandedCategories(
|
||||
new Set(Array.isArray(keys) ? keys : [keys])
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(groupedOperators).map(([key, category]) => (
|
||||
<Collapse.Panel
|
||||
key={key}
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{category.name}</span>
|
||||
<Tag>{category.operators.length}</Tag>
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectAll(category.operators);
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<OperatorList
|
||||
showPoppular
|
||||
selectedOperators={selectedOperators}
|
||||
operators={category.operators}
|
||||
favorites={favorites}
|
||||
toggleOperator={toggleOperator}
|
||||
onDragOperator={handleDragStart}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
{filteredOperators.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<SearchOutlined className="text-3xl mb-2 opacity-50" />
|
||||
<div>未找到匹配的算子</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default OperatorLibrary;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, Input, Tag, Select, Button } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { CleansingTemplate } from "../../cleansing.model";
|
||||
import { Workflow } from "lucide-react";
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
|
||||
interface OperatorFlowProps {
|
||||
selectedOperators: OperatorI[];
|
||||
configOperator: OperatorI | null;
|
||||
templates: CleansingTemplate[];
|
||||
currentTemplate: CleansingTemplate | null;
|
||||
setCurrentTemplate: (template: CleansingTemplate | null) => void;
|
||||
removeOperator: (id: string) => void;
|
||||
setSelectedOperators: (operators: OperatorI[]) => void;
|
||||
setConfigOperator: (operator: OperatorI | null) => void;
|
||||
handleDragStart: (
|
||||
e: React.DragEvent,
|
||||
operator: OperatorI,
|
||||
source: "sort"
|
||||
) => void;
|
||||
handleItemDragOver: (e: React.DragEvent, itemId: string) => void;
|
||||
handleItemDragLeave: (e: React.DragEvent) => void;
|
||||
handleItemDrop: (e: React.DragEvent, index: number) => void;
|
||||
handleContainerDragOver: (e: React.DragEvent) => void;
|
||||
handleContainerDragLeave: (e: React.DragEvent) => void;
|
||||
handleDragEnd: (e: React.DragEvent) => void;
|
||||
handleDropToContainer: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
const OperatorFlow: React.FC<OperatorFlowProps> = ({
|
||||
selectedOperators,
|
||||
configOperator,
|
||||
templates,
|
||||
currentTemplate,
|
||||
setSelectedOperators,
|
||||
setConfigOperator,
|
||||
removeOperator,
|
||||
setCurrentTemplate,
|
||||
handleDragStart,
|
||||
handleItemDragLeave,
|
||||
handleItemDragOver,
|
||||
handleItemDrop,
|
||||
handleContainerDragLeave,
|
||||
handleDropToContainer,
|
||||
handleDragEnd,
|
||||
}) => {
|
||||
const [editingIndex, setEditingIndex] = useState<string | null>(null);
|
||||
|
||||
// 添加编号修改处理函数
|
||||
const handleIndexChange = (operatorId: string, newIndex: string) => {
|
||||
const index = Number.parseInt(newIndex);
|
||||
if (isNaN(index) || index < 1 || index > selectedOperators.length) {
|
||||
return; // 无效输入,不处理
|
||||
}
|
||||
|
||||
const currentIndex = selectedOperators.findIndex(
|
||||
(op) => op.id === operatorId
|
||||
);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const targetIndex = index - 1; // 转换为0基索引
|
||||
if (currentIndex === targetIndex) return; // 位置没有变化
|
||||
|
||||
const newOperators = [...selectedOperators];
|
||||
const [movedOperator] = newOperators.splice(currentIndex, 1);
|
||||
newOperators.splice(targetIndex, 0, movedOperator);
|
||||
|
||||
setSelectedOperators(newOperators);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-1/2 h-full min-w-xs flex-1 flex flex-col border-x border-gray-200">
|
||||
{/* 工具栏 */}
|
||||
<div className="px-4 pb-2 border-b border-gray-200">
|
||||
<div className="flex flex-wrap gap-2 justify-between items-start">
|
||||
<span className="font-semibold text-base flex items-center gap-2">
|
||||
<Workflow className="w-5 h-5" />
|
||||
算子编排({selectedOperators.length}){" "}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setConfigOperator(null);
|
||||
setSelectedOperators([]);
|
||||
}}
|
||||
disabled={selectedOperators.length === 0}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</span>
|
||||
<Select
|
||||
placeholder="选择模板"
|
||||
className="min-w-64"
|
||||
options={templates}
|
||||
value={currentTemplate?.value}
|
||||
onChange={(value) =>
|
||||
setCurrentTemplate(
|
||||
templates.find((t) => t.value === value) || null
|
||||
)
|
||||
}
|
||||
></Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 编排区域 */}
|
||||
<div
|
||||
className="flex-1 overflow-auto p-4 flex flex-col gap-2"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={handleContainerDragLeave}
|
||||
onDrop={handleDropToContainer}
|
||||
>
|
||||
{selectedOperators.map((operator, index) => (
|
||||
<Card
|
||||
size="small"
|
||||
key={operator.id}
|
||||
style={
|
||||
configOperator?.id === operator.id
|
||||
? { borderColor: "#1677ff" }
|
||||
: {}
|
||||
}
|
||||
hoverable
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, operator, "sort")}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleItemDragOver(e, operator.id)}
|
||||
onDragLeave={handleItemDragLeave}
|
||||
onDrop={(e) => handleItemDrop(e, index)}
|
||||
onClick={() => setConfigOperator(operator)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 可编辑编号 */}
|
||||
<span>⋮⋮</span>
|
||||
{editingIndex === operator.id ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={selectedOperators.length}
|
||||
defaultValue={index + 1}
|
||||
className="w-10 h-6 text-xs text-center"
|
||||
autoFocus
|
||||
onBlur={(e) => handleIndexChange(operator.id, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleIndexChange(
|
||||
operator.id,
|
||||
(e.target as HTMLInputElement).value
|
||||
);
|
||||
else if (e.key === "Escape") setEditingIndex(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<Tag
|
||||
color="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingIndex(operator.id);
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</Tag>
|
||||
)}
|
||||
{/* 算子图标和名称 */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{operator.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* 分类标签 */}
|
||||
<Tag color="default">分类</Tag>
|
||||
{/* 参数状态指示 */}
|
||||
{Object.values(operator.configs).some(
|
||||
(param: any) =>
|
||||
(param.type === "input" && !param.value) ||
|
||||
(param.type === "checkbox" &&
|
||||
Array.isArray(param.value) &&
|
||||
param.value.length === 0)
|
||||
) && <Tag color="red">待配置</Tag>}
|
||||
{/* 操作按钮 */}
|
||||
<span
|
||||
className="cursor-pointer text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeOperator(operator.id);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{selectedOperators.length === 0 && (
|
||||
<div className="text-center py-16 text-gray-400 border-2 border-dashed border-gray-100 rounded-lg">
|
||||
<Workflow className="w-full w-10 h-10 mb-4 opacity-50" />
|
||||
<div className="text-lg font-medium mb-2">开始构建您的算子流程</div>
|
||||
<div className="text-sm">
|
||||
从左侧算子库拖拽算子到此处,或点击算子添加
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperatorFlow;
|
||||
@@ -0,0 +1,234 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Input,
|
||||
Select,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Form,
|
||||
InputNumber,
|
||||
Slider,
|
||||
Space,
|
||||
} from "antd";
|
||||
import { ConfigI, OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
|
||||
interface ParamConfigProps {
|
||||
operator: OperatorI;
|
||||
paramKey: string;
|
||||
param: ConfigI;
|
||||
onParamChange?: (operatorId: string, paramKey: string, value: any) => void;
|
||||
}
|
||||
|
||||
const ParamConfig: React.FC<ParamConfigProps> = ({
|
||||
operator,
|
||||
paramKey,
|
||||
param,
|
||||
onParamChange,
|
||||
}) => {
|
||||
if (!param) return null;
|
||||
const [value, setValue] = React.useState(param.value || param.defaultVal);
|
||||
const updateValue = (newValue: any) => {
|
||||
setValue(newValue);
|
||||
return onParamChange && onParamChange(operator.id, paramKey, newValue);
|
||||
};
|
||||
|
||||
switch (param.type) {
|
||||
case "input":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={`请输入${param.name}`}
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
options={(param.options || []).map((option: any) =>
|
||||
typeof option === "string"
|
||||
? { label: option, value: option }
|
||||
: option
|
||||
)}
|
||||
placeholder={`请选择${param.name}`}
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "radio":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Radio.Group
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
>
|
||||
{(param.options || []).map((option: any) => (
|
||||
<Radio
|
||||
key={typeof option === "string" ? option : option.value}
|
||||
value={typeof option === "string" ? option : option.value}
|
||||
>
|
||||
{typeof option === "string" ? option : option.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
);
|
||||
case "checkbox":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Checkbox.Group
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
options={param.options || []}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case "slider":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
tooltip={{ open: true }}
|
||||
marks={{
|
||||
[param.min || 0]: `${param.min || 0}`,
|
||||
[param.min + (param.max - param.min) / 2]: `${
|
||||
(param.min + param.max) / 2
|
||||
}`,
|
||||
[param.max || 100]: `${param.max || 100}`,
|
||||
}}
|
||||
min={param.min || 0}
|
||||
max={param.max || 100}
|
||||
step={param.step || 1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<InputNumber
|
||||
min={param.min || 0}
|
||||
max={param.max || 100}
|
||||
step={param.step || 1}
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
);
|
||||
case "range": {
|
||||
const min = param.min || 0;
|
||||
const max = param.max || 100;
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Slider
|
||||
value={Array.isArray(value) ? value : [value, value]}
|
||||
onChange={(val) =>
|
||||
updateValue(Array.isArray(val) ? val : [val, val])
|
||||
}
|
||||
range
|
||||
min={min}
|
||||
max={max}
|
||||
step={param.step || 1}
|
||||
className="w-full"
|
||||
/>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={min}
|
||||
max={max}
|
||||
value={value[0]}
|
||||
onChange={(val1) => updateValue([val1, value[1]])}
|
||||
changeOnWheel
|
||||
/>
|
||||
~
|
||||
<InputNumber
|
||||
min={min}
|
||||
max={max}
|
||||
value={value[1]}
|
||||
onChange={(val2) => updateValue([value[0], val2])}
|
||||
changeOnWheel
|
||||
/>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
case "inputNumber":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(val) => updateValue(val)}
|
||||
placeholder={`请输入${param.name}`}
|
||||
className="w-full"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step || 1}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
case "switch":
|
||||
return (
|
||||
<Form.Item
|
||||
label={param.name}
|
||||
tooltip={param.description}
|
||||
key={paramKey}
|
||||
>
|
||||
<Checkbox
|
||||
checked={value as boolean}
|
||||
onChange={(e) => updateValue(e.target.checked)}
|
||||
>
|
||||
{param.name}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
);
|
||||
case "multiple":
|
||||
return (
|
||||
<div className="pl-4 border-l border-gray-300">
|
||||
{param.properties.map((subParam) => (
|
||||
<Config
|
||||
key={subParam.key}
|
||||
operator={operator}
|
||||
paramKey={subParam.key}
|
||||
param={subParam}
|
||||
onParamChange={onParamChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ParamConfig;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useDragOperators } from "./useDragOperators";
|
||||
import { useOperatorOperations } from "./useOperatorOperations";
|
||||
import OperatorConfig from "../components/OperatorConfig";
|
||||
import OperatorLibrary from "../components/OperatorLibrary";
|
||||
import OperatorOrchestration from "../components/OperatorOrchestration";
|
||||
|
||||
export function useCreateStepTwo() {
|
||||
const {
|
||||
operators,
|
||||
selectedOperators,
|
||||
templates,
|
||||
currentTemplate,
|
||||
configOperator,
|
||||
currentStep,
|
||||
categoryOptions,
|
||||
handlePrev,
|
||||
handleNext,
|
||||
setCurrentTemplate,
|
||||
setConfigOperator,
|
||||
setSelectedOperators,
|
||||
handleConfigChange,
|
||||
toggleOperator,
|
||||
removeOperator,
|
||||
} = useOperatorOperations();
|
||||
|
||||
const {
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleContainerDragOver,
|
||||
handleContainerDragLeave,
|
||||
handleItemDragOver,
|
||||
handleItemDragLeave,
|
||||
handleItemDrop,
|
||||
handleDropToContainer,
|
||||
} = useDragOperators({
|
||||
operators: selectedOperators,
|
||||
setOperators: setSelectedOperators,
|
||||
});
|
||||
|
||||
const renderStepTwo = (
|
||||
<div className="flex w-full h-full">
|
||||
{/* 左侧算子库 */}
|
||||
<OperatorLibrary
|
||||
categoryOptions={categoryOptions}
|
||||
selectedOperators={selectedOperators}
|
||||
operatorList={operators}
|
||||
setSelectedOperators={setSelectedOperators}
|
||||
toggleOperator={toggleOperator}
|
||||
handleDragStart={handleDragStart}
|
||||
/>
|
||||
|
||||
{/* 中间算子编排区域 */}
|
||||
<OperatorOrchestration
|
||||
selectedOperators={selectedOperators}
|
||||
configOperator={configOperator}
|
||||
templates={templates}
|
||||
currentTemplate={currentTemplate}
|
||||
setSelectedOperators={setSelectedOperators}
|
||||
setConfigOperator={setConfigOperator}
|
||||
setCurrentTemplate={setCurrentTemplate}
|
||||
removeOperator={removeOperator}
|
||||
handleDragStart={handleDragStart}
|
||||
handleContainerDragLeave={handleContainerDragLeave}
|
||||
handleContainerDragOver={handleContainerDragOver}
|
||||
handleItemDragOver={handleItemDragOver}
|
||||
handleItemDragLeave={handleItemDragLeave}
|
||||
handleItemDrop={handleItemDrop}
|
||||
handleDropToContainer={handleDropToContainer}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
|
||||
{/* 右侧参数配置面板 */}
|
||||
<OperatorConfig
|
||||
selectedOp={configOperator}
|
||||
handleConfigChange={handleConfigChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
renderStepTwo,
|
||||
selectedOperators,
|
||||
currentStep,
|
||||
handlePrev,
|
||||
handleNext,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export function useDragOperators({
|
||||
operators,
|
||||
setOperators,
|
||||
}: {
|
||||
operators: OperatorI[];
|
||||
setOperators: (operators: OperatorI[]) => void;
|
||||
}) {
|
||||
const [draggingItem, setDraggingItem] = useState<OperatorI | null>(null);
|
||||
const [draggingSource, setDraggingSource] = useState<
|
||||
"library" | "sort" | null
|
||||
>(null);
|
||||
const [insertPosition, setInsertPosition] = useState<
|
||||
"above" | "below" | null
|
||||
>(null);
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleDragStart = (
|
||||
e: React.DragEvent,
|
||||
item: OperatorI,
|
||||
source: "library" | "sort"
|
||||
) => {
|
||||
setDraggingItem({
|
||||
...item,
|
||||
originalId: item.id,
|
||||
});
|
||||
setDraggingSource(source);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = () => {
|
||||
setDraggingItem(null);
|
||||
setInsertPosition(null);
|
||||
};
|
||||
|
||||
// 处理容器拖拽经过
|
||||
const handleContainerDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// 处理容器拖拽离开
|
||||
const handleContainerDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setInsertPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理项目拖拽经过
|
||||
const handleItemDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mouseY = e.clientY;
|
||||
const elementMiddle = rect.top + rect.height / 2;
|
||||
|
||||
// 判断鼠标在元素的上半部分还是下半部分
|
||||
const newPosition = mouseY < elementMiddle ? "above" : "below";
|
||||
|
||||
setInsertPosition(newPosition);
|
||||
};
|
||||
|
||||
// 处理项目拖拽离开
|
||||
const handleItemDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setInsertPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理放置到空白区域
|
||||
const handleDropToContainer = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggingItem) return;
|
||||
|
||||
// 如果是从算子库拖拽过来的
|
||||
if (draggingSource === "library") {
|
||||
// 检查是否已存在
|
||||
const exists = operators.some((item) => item.id === draggingItem.id);
|
||||
if (!exists) {
|
||||
setOperators([...operators, draggingItem]);
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 处理放置到特定位置
|
||||
const handleItemDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!draggingItem) return;
|
||||
|
||||
// 从左侧拖拽到右侧的精确插入
|
||||
if (draggingSource === "library") {
|
||||
if (targetIndex !== -1) {
|
||||
const insertIndex =
|
||||
insertPosition === "above" ? targetIndex : targetIndex + 1;
|
||||
|
||||
// 检查是否已存在
|
||||
const exists = operators.some((item) => item.id === draggingItem.id);
|
||||
if (!exists) {
|
||||
const newRightItems = [...operators];
|
||||
newRightItems.splice(insertIndex, 0, draggingItem);
|
||||
|
||||
setOperators(newRightItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 右侧容器内的重新排序
|
||||
else if (draggingSource === "sort") {
|
||||
const draggedIndex = operators.findIndex(
|
||||
(item) => item.id === draggingItem.id
|
||||
);
|
||||
if (
|
||||
draggedIndex !== -1 &&
|
||||
targetIndex !== -1 &&
|
||||
draggedIndex !== targetIndex
|
||||
) {
|
||||
const newItems = [...operators];
|
||||
const [draggedItem] = newItems.splice(draggedIndex, 1);
|
||||
|
||||
// 计算正确的插入位置
|
||||
let insertIndex =
|
||||
insertPosition === "above" ? targetIndex : targetIndex + 1;
|
||||
if (draggedIndex < insertIndex) {
|
||||
insertIndex--; // 调整插入位置,因为已经移除了原元素
|
||||
}
|
||||
|
||||
newItems.splice(insertIndex, 0, draggedItem);
|
||||
setOperators(newItems);
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 重置拖拽状态
|
||||
const resetDragState = () => {
|
||||
setDraggingItem(null);
|
||||
setInsertPosition(null);
|
||||
};
|
||||
|
||||
return {
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleContainerDragOver,
|
||||
handleContainerDragLeave,
|
||||
handleItemDragOver,
|
||||
handleItemDragLeave,
|
||||
handleItemDrop,
|
||||
handleDropToContainer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
import { CleansingTemplate } from "../../cleansing.model";
|
||||
import { queryCleaningTemplatesUsingGet } from "../../cleansing.api";
|
||||
import {
|
||||
queryCategoryTreeUsingGet,
|
||||
queryOperatorsUsingPost,
|
||||
} from "@/pages/OperatorMarket/operator.api";
|
||||
|
||||
export function useOperatorOperations() {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const [operators, setOperators] = useState<OperatorI[]>([]);
|
||||
const [selectedOperators, setSelectedOperators] = useState<OperatorI[]>([]);
|
||||
const [configOperator, setConfigOperator] = useState<OperatorI | null>(null);
|
||||
|
||||
const [templates, setTemplates] = useState<CleansingTemplate[]>([]);
|
||||
const [currentTemplate, setCurrentTemplate] =
|
||||
useState<CleansingTemplate | null>(null);
|
||||
|
||||
// 将后端返回的算子数据映射为前端需要的格式
|
||||
const mapOperator = (op: OperatorI) => {
|
||||
const configs =
|
||||
op.settings && typeof op.settings === "string"
|
||||
? JSON.parse(op.settings)
|
||||
: {};
|
||||
const defaultParams: Record<string, string> = {};
|
||||
Object.keys(configs).forEach((key) => {
|
||||
const { value } = configs[key];
|
||||
defaultParams[key] = value;
|
||||
});
|
||||
return {
|
||||
...op,
|
||||
defaultParams,
|
||||
configs,
|
||||
};
|
||||
};
|
||||
|
||||
const [categoryOptions, setCategoryOptions] = useState([]);
|
||||
|
||||
const initOperators = async () => {
|
||||
const [categoryRes, operatorRes] = await Promise.all([
|
||||
queryCategoryTreeUsingGet(),
|
||||
queryOperatorsUsingPost({ page: 0, size: 1000 }),
|
||||
]);
|
||||
|
||||
const operators = operatorRes.data.content.map(mapOperator);
|
||||
setOperators(operators || []);
|
||||
|
||||
const options = categoryRes.data.content.reduce((acc: any[], item: any) => {
|
||||
const cats = item.categories.map((cat) => ({
|
||||
...cat,
|
||||
type: item.name,
|
||||
label: cat.name,
|
||||
value: cat.id,
|
||||
icon: cat.icon,
|
||||
operators: operators.filter((op) => op[item.name] === cat.name),
|
||||
}));
|
||||
acc.push(...cats);
|
||||
return acc;
|
||||
}, [] as { id: string; name: string; icon: React.ReactNode }[]);
|
||||
|
||||
setCategoryOptions(options);
|
||||
};
|
||||
|
||||
const initTemplates = async () => {
|
||||
const { data } = await queryCleaningTemplatesUsingGet();
|
||||
const newTemplates =
|
||||
data.content?.map?.((item) => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) || [];
|
||||
setTemplates(newTemplates);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOperators(currentTemplate?.instance?.map(mapOperator) || []);
|
||||
}, [currentTemplate]);
|
||||
|
||||
useEffect(() => {
|
||||
initTemplates();
|
||||
initOperators();
|
||||
}, []);
|
||||
|
||||
const toggleOperator = (operator: OperatorI) => {
|
||||
const exist = selectedOperators.find((op) => op.id === operator.id);
|
||||
if (exist) {
|
||||
setSelectedOperators(
|
||||
selectedOperators.filter((op) => op.id !== operator.id)
|
||||
);
|
||||
} else {
|
||||
setSelectedOperators([...selectedOperators, { ...operator }]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除算子
|
||||
const removeOperator = (id: string) => {
|
||||
setSelectedOperators(selectedOperators.filter((op) => op.id !== id));
|
||||
if (configOperator?.id === id) setConfigOperator(null);
|
||||
};
|
||||
|
||||
// 配置算子参数变化
|
||||
const handleConfigChange = (
|
||||
operatorId: string,
|
||||
paramKey: string,
|
||||
value: any
|
||||
) => {
|
||||
setSelectedOperators((prev) =>
|
||||
prev.map((op) =>
|
||||
op.id === operatorId
|
||||
? {
|
||||
...op,
|
||||
overrides: {
|
||||
...(op?.overrides || op?.defaultParams),
|
||||
[paramKey]: value,
|
||||
},
|
||||
}
|
||||
: op
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 2) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
templates,
|
||||
currentTemplate,
|
||||
configOperator,
|
||||
categoryOptions,
|
||||
setConfigOperator,
|
||||
setCurrentTemplate,
|
||||
setCurrentStep,
|
||||
operators,
|
||||
setOperators,
|
||||
selectedOperators,
|
||||
setSelectedOperators,
|
||||
handleConfigChange,
|
||||
toggleOperator,
|
||||
removeOperator,
|
||||
handleNext,
|
||||
handlePrev,
|
||||
};
|
||||
}
|
||||
176
frontend/src/pages/DataCleansing/Detail/TaskDetail.tsx
Normal file
176
frontend/src/pages/DataCleansing/Detail/TaskDetail.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Breadcrumb, App } from "antd";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Trash2,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
deleteCleaningTaskByIdUsingDelete,
|
||||
executeCleaningTaskUsingPost,
|
||||
queryCleaningTaskByIdUsingGet,
|
||||
stopCleaningTaskUsingPost,
|
||||
} from "../cleansing.api";
|
||||
import { TaskStatusMap } from "../cleansing.const";
|
||||
import { TaskStatus } from "@/pages/DataCleansing/cleansing.model";
|
||||
import BasicInfo from "./components/BasicInfo";
|
||||
import OperatorTable from "./components/OperatorTable";
|
||||
import FileTable from "./components/FileTable";
|
||||
import LogsTable from "./components/LogsTable";
|
||||
|
||||
// 任务详情页面组件
|
||||
export default function CleansingTaskDetail() {
|
||||
const { id = "" } = useParams(); // 获取动态路由参数
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskByIdUsingGet(id);
|
||||
setTask(data);
|
||||
} catch (error) {
|
||||
message.error("获取任务详情失败");
|
||||
navigate("/data/cleansing");
|
||||
}
|
||||
};
|
||||
|
||||
const pauseTask = async () => {
|
||||
await stopCleaningTaskUsingPost(id);
|
||||
message.success("任务已暂停");
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const startTask = async () => {
|
||||
await executeCleaningTaskUsingPost(id);
|
||||
message.success("任务已启动");
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const deleteTask = async () => {
|
||||
await deleteCleaningTaskByIdUsingDelete(id);
|
||||
message.success("任务已删除");
|
||||
navigate("/data/cleansing");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskDetail();
|
||||
}, [id]);
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
const headerData = {
|
||||
...task,
|
||||
icon: <Database className="w-8 h-8" />,
|
||||
status: TaskStatusMap[task?.status],
|
||||
createdAt: task?.startTime,
|
||||
lastUpdated: task?.updatedAt,
|
||||
};
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
icon: <Clock className="w-4 h-4 text-blue-500" />,
|
||||
label: "总耗时",
|
||||
value: task?.duration || "--",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-4 h-4 text-green-500" />,
|
||||
label: "成功文件",
|
||||
value: task?.successFiles || "--",
|
||||
},
|
||||
{
|
||||
icon: <AlertCircle className="w-4 h-4 text-red-500" />,
|
||||
label: "失败文件",
|
||||
value: task?.failedFiles || "--",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-4 h-4 text-purple-500" />,
|
||||
label: "成功率",
|
||||
value: `${task?.progress}%`,
|
||||
},
|
||||
];
|
||||
|
||||
const operations = [
|
||||
...(task?.status === TaskStatus.RUNNING
|
||||
? [
|
||||
{
|
||||
key: "pause",
|
||||
label: "暂停任务",
|
||||
icon: <Pause className="w-4 h-4" />,
|
||||
onClick: pauseTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(task?.status === TaskStatus.PENDING
|
||||
? [
|
||||
{
|
||||
key: "start",
|
||||
label: "执行任务",
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onClick: startTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除任务",
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onClick: deleteTask,
|
||||
},
|
||||
];
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: "basic",
|
||||
tab: "基本信息",
|
||||
children: <BasicInfo task={task} />,
|
||||
},
|
||||
{
|
||||
key: "operators",
|
||||
tab: "处理算子",
|
||||
children: <OperatorTable task={task} />,
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
tab: "处理文件",
|
||||
children: <FileTable task={task} />,
|
||||
},
|
||||
{ key: "logs", tab: "运行日志", children: <LogsTable task={task} /> },
|
||||
];
|
||||
|
||||
const breadItems = [
|
||||
{
|
||||
title: <Link to="/data/cleansing">数据清洗</Link>,
|
||||
},
|
||||
{
|
||||
title: "清洗任务详情",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Breadcrumb items={breadItems} />
|
||||
<div className="mb-4 mt-4">
|
||||
<DetailHeader
|
||||
data={headerData}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
/>
|
||||
</div>
|
||||
<Card
|
||||
tabList={tabList}
|
||||
activeTabKey={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/pages/DataCleansing/Detail/components/BasicInfo.tsx
Normal file
148
frontend/src/pages/DataCleansing/Detail/components/BasicInfo.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { CleansingTask } from "@/pages/DataCleansing/cleansing.model";
|
||||
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
import { Button, Card, Descriptions, Progress, Tag } from "antd";
|
||||
import { Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function BasicInfo({ task }: { task: CleansingTask }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const descriptionItems = [
|
||||
{
|
||||
key: "id",
|
||||
label: "任务ID",
|
||||
children: <span className="font-mono">#{task?.id}</span>,
|
||||
},
|
||||
{ key: "name", label: "任务名称", children: task?.name },
|
||||
{
|
||||
key: "dataset",
|
||||
label: "源数据集",
|
||||
children: (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigate("/data/management/detail/" + task?.srcDatasetId)
|
||||
}
|
||||
>
|
||||
{task?.srcDatasetName}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "targetDataset",
|
||||
label: "目标数据集",
|
||||
children: (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigate("/data/management/detail/" + task?.destDatasetId)
|
||||
}
|
||||
>
|
||||
{task?.destDatasetName}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ key: "template", label: "使用模板", children: task?.template },
|
||||
{ key: "startTime", label: "开始时间", children: task?.startedAt },
|
||||
{ key: "estimatedTime", label: "预计用时", children: task?.estimatedTime },
|
||||
{
|
||||
key: "description",
|
||||
label: "任务描述",
|
||||
children: (
|
||||
<span className="text-gray-600">{task?.description || "暂无描述"}</span>
|
||||
),
|
||||
span: 2,
|
||||
},
|
||||
{
|
||||
key: "rules",
|
||||
label: "处理算子",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task?.instance?.map?.((op: OperatorI) => (
|
||||
<Tag key={op.id}>{op.name}</Tag>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
span: 2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 执行摘要 */}
|
||||
<Card className="mb-6">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg">
|
||||
<Clock className="w-8 h-8 text-blue-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-blue-500">
|
||||
{task?.duration || "--"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">总耗时</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gradient-to-br from-green-50 to-green-100 rounded-lg">
|
||||
<CheckCircle className="w-8 h-8 text-green-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-green-500">
|
||||
{task?.successFiles || "--"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">成功文件</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gradient-to-br from-red-50 to-red-100 rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-red-500">
|
||||
{task?.failedFiles || "--"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">失败文件</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg">
|
||||
<Activity className="w-8 h-8 text-purple-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-purple-500">
|
||||
{task?.progress || "--"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">成功率</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 基本信息 */}
|
||||
<Card>
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">基本信息</h3>
|
||||
<Descriptions
|
||||
column={2}
|
||||
bordered={false}
|
||||
size="middle"
|
||||
labelStyle={{ fontWeight: 500, color: "#555" }}
|
||||
contentStyle={{ fontSize: 14 }}
|
||||
items={descriptionItems}
|
||||
></Descriptions>
|
||||
</div>
|
||||
{/* 处理进度 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">处理进度</h3>
|
||||
<Progress percent={task?.progress} showInfo />
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full inline-block" />
|
||||
<span>已完成: {task?.processedFiles || "--"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-blue-500 rounded-full inline-block" />
|
||||
<span>处理中: {task?.processingFiles || "--"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-gray-300 rounded-full inline-block" />
|
||||
<span>
|
||||
待处理: {task?.totalFiles - task?.processedFiles || "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-full inline-block" />
|
||||
<span>失败: {task?.failedFiles || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
503
frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx
Normal file
503
frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
import { Button, Modal, Table, Badge, Input } from "antd";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
// 模拟文件列表数据
|
||||
const fileList = [
|
||||
{
|
||||
id: 1,
|
||||
fileName: "lung_cancer_001.svs",
|
||||
originalSize: "15.2MB",
|
||||
processedSize: "8.5MB",
|
||||
status: "已完成",
|
||||
duration: "2分15秒",
|
||||
processedAt: "2024-01-20 09:32:40",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileName: "lung_cancer_002.svs",
|
||||
originalSize: "18.7MB",
|
||||
processedSize: "10.2MB",
|
||||
status: "已完成",
|
||||
duration: "2分38秒",
|
||||
processedAt: "2024-01-20 09:35:18",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
fileName: "lung_cancer_003.svs",
|
||||
originalSize: "12.3MB",
|
||||
processedSize: "6.8MB",
|
||||
status: "已完成",
|
||||
duration: "1分52秒",
|
||||
processedAt: "2024-01-20 09:37:10",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
fileName: "lung_cancer_004.svs",
|
||||
originalSize: "20.1MB",
|
||||
processedSize: "-",
|
||||
status: "失败",
|
||||
duration: "0分45秒",
|
||||
processedAt: "2024-01-20 09:38:55",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
fileName: "lung_cancer_005.svs",
|
||||
originalSize: "16.8MB",
|
||||
processedSize: "9.3MB",
|
||||
status: "已完成",
|
||||
duration: "2分22秒",
|
||||
processedAt: "2024-01-20 09:41:17",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FileTable() {
|
||||
const [showFileCompareDialog, setShowFileCompareDialog] = useState(false);
|
||||
const [showFileLogDialog, setShowFileLogDialog] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<number[]>([]);
|
||||
const handleSelectAllFiles = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds(fileList.map((file) => file.id));
|
||||
} else {
|
||||
setSelectedFileIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFile = (fileId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds([...selectedFileIds, fileId]);
|
||||
} else {
|
||||
setSelectedFileIds(selectedFileIds.filter((id) => id !== fileId));
|
||||
}
|
||||
};
|
||||
const handleViewFileCompare = (file: any) => {
|
||||
setSelectedFile(file);
|
||||
setShowFileCompareDialog(true);
|
||||
};
|
||||
const handleBatchDownload = () => {
|
||||
// 实际下载逻辑
|
||||
};
|
||||
|
||||
const handleBatchDeleteFiles = () => {
|
||||
// 实际删除逻辑
|
||||
setSelectedFileIds([]);
|
||||
};
|
||||
const handleViewFileLog = (file: any) => {
|
||||
setSelectedFile(file);
|
||||
setShowFileLogDialog(true);
|
||||
};
|
||||
|
||||
// 模拟单个文件的处理日志
|
||||
const getFileProcessLog = (fileName: string) => [
|
||||
{
|
||||
time: "09:30:18",
|
||||
step: "开始处理",
|
||||
operator: "格式转换",
|
||||
status: "INFO",
|
||||
message: `开始处理文件: ${fileName}`,
|
||||
},
|
||||
{
|
||||
time: "09:30:19",
|
||||
step: "文件验证",
|
||||
operator: "格式转换",
|
||||
status: "INFO",
|
||||
message: "验证文件格式和完整性",
|
||||
},
|
||||
{
|
||||
time: "09:30:20",
|
||||
step: "格式解析",
|
||||
operator: "格式转换",
|
||||
status: "INFO",
|
||||
message: "解析SVS格式文件",
|
||||
},
|
||||
{
|
||||
time: "09:30:25",
|
||||
step: "格式转换",
|
||||
operator: "格式转换",
|
||||
status: "SUCCESS",
|
||||
message: "成功转换为JPEG格式",
|
||||
},
|
||||
{
|
||||
time: "09:30:26",
|
||||
step: "噪声检测",
|
||||
operator: "噪声去除",
|
||||
status: "INFO",
|
||||
message: "检测图像噪声水平",
|
||||
},
|
||||
{
|
||||
time: "09:30:28",
|
||||
step: "噪声去除",
|
||||
operator: "噪声去除",
|
||||
status: "INFO",
|
||||
message: "应用高斯滤波去除噪声",
|
||||
},
|
||||
{
|
||||
time: "09:30:31",
|
||||
step: "噪声去除完成",
|
||||
operator: "噪声去除",
|
||||
status: "SUCCESS",
|
||||
message: "噪声去除处理完成",
|
||||
},
|
||||
{
|
||||
time: "09:30:32",
|
||||
step: "尺寸检测",
|
||||
operator: "尺寸标准化",
|
||||
status: "INFO",
|
||||
message: "检测当前图像尺寸: 2048x1536",
|
||||
},
|
||||
{
|
||||
time: "09:30:33",
|
||||
step: "尺寸调整",
|
||||
operator: "尺寸标准化",
|
||||
status: "INFO",
|
||||
message: "调整图像尺寸至512x512",
|
||||
},
|
||||
{
|
||||
time: "09:30:35",
|
||||
step: "尺寸标准化完成",
|
||||
operator: "尺寸标准化",
|
||||
status: "SUCCESS",
|
||||
message: "图像尺寸标准化完成",
|
||||
},
|
||||
{
|
||||
time: "09:30:36",
|
||||
step: "质量检查",
|
||||
operator: "质量检查",
|
||||
status: "INFO",
|
||||
message: "检查图像质量指标",
|
||||
},
|
||||
{
|
||||
time: "09:30:38",
|
||||
step: "分辨率检查",
|
||||
operator: "质量检查",
|
||||
status: "SUCCESS",
|
||||
message: "分辨率符合要求",
|
||||
},
|
||||
{
|
||||
time: "09:30:39",
|
||||
step: "清晰度检查",
|
||||
operator: "质量检查",
|
||||
status: "SUCCESS",
|
||||
message: "图像清晰度良好",
|
||||
},
|
||||
{
|
||||
time: "09:30:40",
|
||||
step: "处理完成",
|
||||
operator: "质量检查",
|
||||
status: "SUCCESS",
|
||||
message: `文件 ${fileName} 处理完成`,
|
||||
},
|
||||
];
|
||||
|
||||
const fileColumns = [
|
||||
{
|
||||
title: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedFileIds.length === fileList.length && fileList.length > 0
|
||||
}
|
||||
onChange={(e) => handleSelectAllFiles(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
),
|
||||
dataIndex: "select",
|
||||
key: "select",
|
||||
width: 50,
|
||||
render: (text: string, record: any) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFileIds.includes(record.id)}
|
||||
onChange={(e) => handleSelectFile(record.id, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "文件名",
|
||||
dataIndex: "fileName",
|
||||
key: "fileName",
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder="搜索文件名"
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.fileName.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span className="font-mono text-sm">{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "清洗前大小",
|
||||
dataIndex: "originalSize",
|
||||
key: "originalSize",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getSizeInBytes = (size: string) => {
|
||||
if (!size || size === "-") return 0;
|
||||
const num = Number.parseFloat(size);
|
||||
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
|
||||
if (size.includes("MB")) return num * 1024 * 1024;
|
||||
if (size.includes("KB")) return num * 1024;
|
||||
return num;
|
||||
};
|
||||
return getSizeInBytes(a.originalSize) - getSizeInBytes(b.originalSize);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "清洗后大小",
|
||||
dataIndex: "processedSize",
|
||||
key: "processedSize",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getSizeInBytes = (size: string) => {
|
||||
if (!size || size === "-") return 0;
|
||||
const num = Number.parseFloat(size);
|
||||
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
|
||||
if (size.includes("MB")) return num * 1024 * 1024;
|
||||
if (size.includes("KB")) return num * 1024;
|
||||
return num;
|
||||
};
|
||||
return (
|
||||
getSizeInBytes(a.processedSize) - getSizeInBytes(b.processedSize)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
filters: [
|
||||
{ text: "已完成", value: "已完成" },
|
||||
{ text: "失败", value: "失败" },
|
||||
{ text: "处理中", value: "处理中" },
|
||||
],
|
||||
onFilter: (value: string, record: any) => record.status === value,
|
||||
render: (status: string) => (
|
||||
<Badge
|
||||
status={
|
||||
status === "已完成"
|
||||
? "success"
|
||||
: status === "失败"
|
||||
? "error"
|
||||
: "processing"
|
||||
}
|
||||
text={status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "执行耗时",
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getTimeInSeconds = (duration: string) => {
|
||||
const parts = duration.split(/[分秒]/);
|
||||
const minutes = Number.parseInt(parts[0]) || 0;
|
||||
const seconds = Number.parseInt(parts[1]) || 0;
|
||||
return minutes * 60 + seconds;
|
||||
};
|
||||
return getTimeInSeconds(a.duration) - getTimeInSeconds(b.duration);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (text: string, record: any) => (
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleViewFileLog(record)}
|
||||
>
|
||||
日志
|
||||
</Button>
|
||||
{record.status === "已完成" && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleViewFileCompare(record)}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
)}
|
||||
<Button type="link" size="small">
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedFileIds.length > 0 && (
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
已选择 {selectedFileIds.length} 个文件
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBatchDownload}
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<Download className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
批量下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
columns={fileColumns}
|
||||
dataSource={fileList}
|
||||
pagination={{ pageSize: 10, showSizeChanger: true }}
|
||||
size="middle"
|
||||
rowKey="id"
|
||||
/>
|
||||
|
||||
{/* 文件日志弹窗 */}
|
||||
<Modal
|
||||
open={showFileLogDialog}
|
||||
onCancel={() => setShowFileLogDialog(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
title={
|
||||
<span>
|
||||
<FileText className="w-4 h-4 mr-2 inline" />
|
||||
文件处理日志 - {selectedFile?.fileName}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="bg-gray-900 rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||
<div className="font-mono text-sm">
|
||||
{selectedFile &&
|
||||
getFileProcessLog(selectedFile.fileName).map((log, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<span className="text-gray-500 min-w-20">{log.time}</span>
|
||||
<span className="text-blue-400 min-w-24">
|
||||
[{log.operator}]
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-20 ${
|
||||
log.status === "ERROR"
|
||||
? "text-red-400"
|
||||
: log.status === "SUCCESS"
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{log.step}
|
||||
</span>
|
||||
<span className="text-gray-100">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* 文件对比弹窗 */}
|
||||
<Modal
|
||||
open={showFileCompareDialog}
|
||||
onCancel={() => setShowFileCompareDialog(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
title={<span>文件对比 - {selectedFile?.fileName}</span>}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6 py-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">清洗前</h4>
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="w-16 h-16 bg-gray-300 rounded-lg mx-auto mb-2" />
|
||||
<div className="text-sm">原始文件预览</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
大小: {selectedFile?.originalSize}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-3 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">文件格式:</span> SVS
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">分辨率:</span> 2048x1536
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">色彩空间:</span> RGB
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">压缩方式:</span> 无压缩
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">清洗后</h4>
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="w-16 h-16 bg-blue-300 rounded-lg mx-auto mb-2" />
|
||||
<div className="text-sm">处理后文件预览</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
大小: {selectedFile?.processedSize}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-3 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">文件格式:</span> JPEG
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">分辨率:</span> 512x512
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">色彩空间:</span> RGB
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">压缩方式:</span> JPEG压缩
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 mt-6 pt-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">处理效果对比</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="font-medium text-green-700">文件大小优化</div>
|
||||
<div className="text-green-600">减少了 44.1%</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="font-medium text-blue-700">处理时间</div>
|
||||
<div className="text-blue-600">{selectedFile?.duration}</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="font-medium text-purple-700">质量评分</div>
|
||||
<div className="text-purple-600">优秀 (9.2/10)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx
Normal file
110
frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
export default function LogsTable({ task }: { task: any }) {
|
||||
// 模拟运行日志
|
||||
const runLogs = [
|
||||
{
|
||||
time: "09:30:15",
|
||||
level: "INFO",
|
||||
message: "开始执行数据清洗任务: 肺癌WSI图像清洗任务",
|
||||
},
|
||||
{
|
||||
time: "09:30:16",
|
||||
level: "INFO",
|
||||
message: "加载源数据集: 肺癌WSI病理图像数据集 (1250 文件)",
|
||||
},
|
||||
{ time: "09:30:17", level: "INFO", message: "初始化算子: 格式转换" },
|
||||
{
|
||||
time: "09:30:18",
|
||||
level: "INFO",
|
||||
message: "开始处理文件: lung_cancer_001.svs",
|
||||
},
|
||||
{
|
||||
time: "09:30:25",
|
||||
level: "SUCCESS",
|
||||
message: "文件处理成功: lung_cancer_001.svs -> lung_cancer_001.jpg",
|
||||
},
|
||||
{
|
||||
time: "09:30:26",
|
||||
level: "INFO",
|
||||
message: "开始处理文件: lung_cancer_002.svs",
|
||||
},
|
||||
{
|
||||
time: "09:30:33",
|
||||
level: "SUCCESS",
|
||||
message: "文件处理成功: lung_cancer_002.svs -> lung_cancer_002.jpg",
|
||||
},
|
||||
{
|
||||
time: "09:58:42",
|
||||
level: "INFO",
|
||||
message: "格式转换完成,成功处理 1250/1250 文件",
|
||||
},
|
||||
{ time: "09:58:43", level: "INFO", message: "初始化算子: 噪声去除" },
|
||||
{
|
||||
time: "09:58:44",
|
||||
level: "INFO",
|
||||
message: "开始处理文件: lung_cancer_001.jpg",
|
||||
},
|
||||
{
|
||||
time: "09:58:51",
|
||||
level: "SUCCESS",
|
||||
message: "噪声去除成功: lung_cancer_001.jpg",
|
||||
},
|
||||
{
|
||||
time: "10:15:23",
|
||||
level: "WARNING",
|
||||
message: "文件质量较低,跳过处理: lung_cancer_156.jpg",
|
||||
},
|
||||
{
|
||||
time: "10:35:18",
|
||||
level: "INFO",
|
||||
message: "噪声去除完成,成功处理 1228/1250 文件",
|
||||
},
|
||||
{ time: "10:35:19", level: "INFO", message: "初始化算子: 尺寸标准化" },
|
||||
{
|
||||
time: "11:12:05",
|
||||
level: "INFO",
|
||||
message: "尺寸标准化完成,成功处理 1222/1228 文件",
|
||||
},
|
||||
{ time: "11:12:06", level: "INFO", message: "初始化算子: 质量检查" },
|
||||
{
|
||||
time: "11:25:33",
|
||||
level: "ERROR",
|
||||
message: "质量检查失败: lung_cancer_089.jpg - 分辨率过低",
|
||||
},
|
||||
{
|
||||
time: "11:45:32",
|
||||
level: "INFO",
|
||||
message: "质量检查完成,成功处理 1198/1222 文件",
|
||||
},
|
||||
{
|
||||
time: "11:45:33",
|
||||
level: "SUCCESS",
|
||||
message: "数据清洗任务完成!总成功率: 95.8%",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="text-gray-300 p-4 border border-gray-700 bg-gray-800 rounded-lg">
|
||||
<div className="font-mono text-sm">
|
||||
{runLogs?.map?.((log, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<span className="text-gray-500 min-w-20">{log.time}</span>
|
||||
<span
|
||||
className={`min-w-20 ${
|
||||
log.level === "ERROR"
|
||||
? "text-red-500"
|
||||
: log.level === "WARNING"
|
||||
? "text-yellow-500"
|
||||
: log.level === "SUCCESS"
|
||||
? "text-green-500"
|
||||
: "text-blue-500"
|
||||
}`}
|
||||
>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="text-gray-100">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Button, Input, Table } from "antd";
|
||||
|
||||
const operators = [
|
||||
{
|
||||
name: "格式转换",
|
||||
startTime: "09:30:15",
|
||||
endTime: "09:58:42",
|
||||
duration: "28分27秒",
|
||||
status: "成功",
|
||||
processedFiles: 1250,
|
||||
successRate: 100,
|
||||
},
|
||||
{
|
||||
name: "噪声去除",
|
||||
startTime: "09:58:42",
|
||||
endTime: "10:35:18",
|
||||
duration: "36分36秒",
|
||||
status: "成功",
|
||||
processedFiles: 1250,
|
||||
successRate: 98.2,
|
||||
},
|
||||
{
|
||||
name: "尺寸标准化",
|
||||
startTime: "10:35:18",
|
||||
endTime: "11:12:05",
|
||||
duration: "36分47秒",
|
||||
status: "成功",
|
||||
processedFiles: 1228,
|
||||
successRate: 99.5,
|
||||
},
|
||||
{
|
||||
name: "质量检查",
|
||||
startTime: "11:12:05",
|
||||
endTime: "11:45:32",
|
||||
duration: "33分27秒",
|
||||
status: "成功",
|
||||
processedFiles: 1222,
|
||||
successRate: 97.8,
|
||||
},
|
||||
];
|
||||
export default function OperatorTable({ task }: { task: any }) {
|
||||
const operatorColumns = [
|
||||
{
|
||||
title: "算子名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
fixed: "left",
|
||||
width: 200,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder="搜索算子名称"
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.name.toLowerCase().includes(value.toLowerCase()),
|
||||
},
|
||||
{
|
||||
title: "版本",
|
||||
dataIndex: "version",
|
||||
key: "version",
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updatedAt",
|
||||
key: "updatedAt",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={operatorColumns}
|
||||
dataSource={task?.instance || operators}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
frontend/src/pages/DataCleansing/Home/DataCleansing.tsx
Normal file
61
frontend/src/pages/DataCleansing/Home/DataCleansing.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tabs, Button } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router";
|
||||
import TaskList from "./components/TaskList";
|
||||
import TemplateList from "./components/TemplateList";
|
||||
import ProcessFlowDiagram from "./components/ProcessFlowDiagram";
|
||||
import { useSearchParams } from "@/hooks/useSearchParams";
|
||||
|
||||
export default function DataProcessingPage() {
|
||||
const navigate = useNavigate();
|
||||
const urlParams = useSearchParams();
|
||||
const [currentView, setCurrentView] = useState<"task" | "template">("task");
|
||||
|
||||
useEffect(() => {
|
||||
if (urlParams.view) {
|
||||
setCurrentView(urlParams.view);
|
||||
}
|
||||
}, [urlParams]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">数据清洗</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate("/data/cleansing/create-template")}
|
||||
>
|
||||
创建清洗模板
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate("/data/cleansing/create-task")}
|
||||
>
|
||||
创建清洗任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProcessFlowDiagram />
|
||||
<Tabs
|
||||
activeKey={currentView}
|
||||
onChange={(key) => setCurrentView(key as any)}
|
||||
items={[
|
||||
{
|
||||
key: "task",
|
||||
label: "任务列表",
|
||||
},
|
||||
{
|
||||
key: "template",
|
||||
label: "模板管理",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{currentView === "task" && <TaskList />}
|
||||
{currentView === "template" && <TemplateList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Database,
|
||||
Play,
|
||||
Settings,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
// 流程图组件
|
||||
export default function ProcessFlowDiagram() {
|
||||
const flowSteps = [
|
||||
{
|
||||
id: "start",
|
||||
label: "开始",
|
||||
type: "start",
|
||||
icon: Play,
|
||||
color: "bg-green-500",
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
label: "选择数据集",
|
||||
type: "process",
|
||||
icon: Database,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
id: "config",
|
||||
label: "基本配置",
|
||||
type: "process",
|
||||
icon: Settings,
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
{
|
||||
id: "operators",
|
||||
label: "算子编排",
|
||||
type: "process",
|
||||
icon: Workflow,
|
||||
color: "bg-orange-500",
|
||||
},
|
||||
{
|
||||
id: "execute",
|
||||
label: "执行任务",
|
||||
type: "process",
|
||||
icon: Zap,
|
||||
color: "bg-red-500",
|
||||
},
|
||||
{
|
||||
id: "end",
|
||||
label: "完成",
|
||||
type: "end",
|
||||
icon: CheckCircle,
|
||||
color: "bg-green-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 p-6 bg-white">
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<div className="w-full flex items-center space-x-12">
|
||||
{flowSteps.map((step, index) => {
|
||||
const IconComponent = step.icon;
|
||||
return (
|
||||
<div key={step.id} className="flex-1 flex items-center">
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div
|
||||
className={`w-12 h-12 ${step.color} rounded-full flex items-center justify-center text-white shadow-lg`}
|
||||
>
|
||||
<IconComponent className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700 mt-2 text-center max-w-16">
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < flowSteps.length - 1 && (
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mx-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
frontend/src/pages/DataCleansing/Home/components/TaskList.tsx
Normal file
257
frontend/src/pages/DataCleansing/Home/components/TaskList.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from "react";
|
||||
import { Table, Progress, Badge, Button, Tooltip, Card, App } from "antd";
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import { useNavigate } from "react-router";
|
||||
import { mapTask, TaskStatusMap } from "../../cleansing.const";
|
||||
import {
|
||||
TaskStatus,
|
||||
type CleansingTask,
|
||||
} from "@/pages/DataCleansing/cleansing.model";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
deleteCleaningTaskByIdUsingDelete,
|
||||
executeCleaningTaskUsingPost,
|
||||
queryCleaningTasksUsingGet,
|
||||
stopCleaningTaskUsingPost,
|
||||
} from "../../cleansing.api";
|
||||
|
||||
export default function TaskList() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("list");
|
||||
const filterOptions = [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
options: [...Object.values(TaskStatusMap)],
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryCleaningTasksUsingGet, mapTask);
|
||||
|
||||
const handleViewTask = (task: any) => {
|
||||
navigate("/data/cleansing/task-detail/" + task.id);
|
||||
};
|
||||
|
||||
const pauseTask = async (item: CleansingTask) => {
|
||||
await stopCleaningTaskUsingPost(item.id);
|
||||
message.success("任务已暂停");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const startTask = async (item: CleansingTask) => {
|
||||
await executeCleaningTaskUsingPost(item.id);
|
||||
message.success("任务已启动");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const deleteTask = async (item: CleansingTask) => {
|
||||
await deleteCleaningTaskByIdUsingDelete(item.id);
|
||||
message.success("任务已删除");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const taskOperations = (record) => {
|
||||
const isRunning = record.status?.value === TaskStatus.RUNNING;
|
||||
const showStart = [
|
||||
TaskStatus.PENDING,
|
||||
TaskStatus.FAILED,
|
||||
TaskStatus.STOPPED,
|
||||
].includes(record.status?.value);
|
||||
const pauseBtn = {
|
||||
key: "pause",
|
||||
label: "暂停",
|
||||
icon: isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
onClick: pauseTask, // implement pause/play logic
|
||||
};
|
||||
|
||||
const startBtn = {
|
||||
key: "start",
|
||||
label: "启动",
|
||||
icon: isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
onClick: startTask, // implement pause/play logic
|
||||
};
|
||||
return [
|
||||
isRunning && pauseBtn,
|
||||
showStart && startBtn,
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
|
||||
onClick: deleteTask, // implement delete logic
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const taskColumns = [
|
||||
{
|
||||
title: "任务名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
fixed: "left",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "源数据集",
|
||||
dataIndex: "srcDatasetId",
|
||||
key: "srcDatasetId",
|
||||
width: 150,
|
||||
render: (_, record: CleansingTask) => {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
navigate("/data/management/detail/" + record.srcDatasetId)
|
||||
}
|
||||
>
|
||||
{record.srcDatasetName}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "目标数据集",
|
||||
dataIndex: "destDatasetId",
|
||||
key: "destDatasetId",
|
||||
width: 150,
|
||||
render: (_, record: CleansingTask) => {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
navigate("/data/management/detail/" + record.destDatasetId)
|
||||
}
|
||||
>
|
||||
{record.destDatasetName}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (status: any) => {
|
||||
return <Badge color={status.color} text={status.label} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "开始时间",
|
||||
dataIndex: "startedAt",
|
||||
key: "startedAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "结束时间",
|
||||
dataIndex: "finishedAt",
|
||||
key: "finishedAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "进度",
|
||||
dataIndex: "progress",
|
||||
key: "progress",
|
||||
width: 200,
|
||||
render: (progress: number) => (
|
||||
<Progress percent={progress} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "执行耗时",
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "数据量变化",
|
||||
dataIndex: "dataSizeChange",
|
||||
key: "dataSizeChange",
|
||||
width: 180,
|
||||
render: (_: any, record: CleansingTask) => {
|
||||
if (record.before !== undefined && record.after !== undefined) {
|
||||
return `${record.before} → ${record.after}`;
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
fixed: "right",
|
||||
render: (text: string, record: any) => (
|
||||
<div className="flex gap-2">
|
||||
{taskOperations(record).map((op) =>
|
||||
op ? (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={op.icon}
|
||||
onClick={() => op.onClick(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search and Filters */}
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={(keyword) =>
|
||||
setSearchParams({ ...searchParams, keyword })
|
||||
}
|
||||
searchPlaceholder="搜索任务名称、描述"
|
||||
filters={filterOptions}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
onReload={fetchData}
|
||||
/>
|
||||
{/* Task List */}
|
||||
{viewMode === "card" ? (
|
||||
<CardView
|
||||
data={tableData}
|
||||
operations={taskOperations}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
columns={taskColumns}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: "max-content", y: "calc(100vh - 35rem)" }}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import CardView from "@/components/CardView";
|
||||
import {
|
||||
deleteCleaningTemplateByIdUsingDelete,
|
||||
queryCleaningTemplatesUsingGet,
|
||||
} from "../../cleansing.api";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import { mapTemplate } from "../../cleansing.const";
|
||||
import { App } from "antd";
|
||||
import { CleansingTemplate } from "../../cleansing.model";
|
||||
|
||||
export default function TemplateList() {
|
||||
const { message } = App.useApp();
|
||||
|
||||
const { tableData, pagination, fetchData } = useFetchData(
|
||||
queryCleaningTemplatesUsingGet,
|
||||
mapTemplate
|
||||
);
|
||||
|
||||
const deleteTemplate = async (template: CleansingTemplate) => {
|
||||
if (!template.id) {
|
||||
return;
|
||||
}
|
||||
// 实现删除逻辑
|
||||
await deleteCleaningTemplateByIdUsingDelete(template.id);
|
||||
fetchData();
|
||||
message.success("模板删除成功");
|
||||
};
|
||||
|
||||
const operations = [
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除模板",
|
||||
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
|
||||
onClick: (template: CleansingTemplate) => deleteTemplate(template), // 可实现删除逻辑
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CardView
|
||||
data={tableData}
|
||||
operations={operations}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
frontend/src/pages/DataCleansing/cleansing.api.ts
Normal file
57
frontend/src/pages/DataCleansing/cleansing.api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { get, post, put, del } from "@/utils/request";
|
||||
|
||||
// 清洗任务相关接口
|
||||
export function queryCleaningTasksUsingGet(params?: any) {
|
||||
return get("/api/cleaning/tasks", params);
|
||||
}
|
||||
|
||||
export function createCleaningTaskUsingPost(data: any) {
|
||||
return post("/api/cleaning/tasks", data);
|
||||
}
|
||||
|
||||
export function queryCleaningTaskByIdUsingGet(taskId: string | number) {
|
||||
return get(`/api/cleaning/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export function updateCleaningTaskByIdUsingPut(taskId: string | number, data: any) {
|
||||
return put(`/api/cleaning/tasks/${taskId}`, data);
|
||||
}
|
||||
|
||||
export function deleteCleaningTaskByIdUsingDelete(taskId: string | number) {
|
||||
return del(`/api/cleaning/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export function executeCleaningTaskUsingPost(taskId: string | number, data?: any) {
|
||||
return post(`/api/cleaning/tasks/${taskId}/execute`, data);
|
||||
}
|
||||
|
||||
export function stopCleaningTaskUsingPost(taskId: string | number, data?: any) {
|
||||
return post(`/api/cleaning/tasks/${taskId}/stop`, data);
|
||||
}
|
||||
|
||||
// 清洗模板相关接口
|
||||
export function queryCleaningTemplatesUsingGet(params?: any) {
|
||||
return get("/api/cleaning/templates", params);
|
||||
}
|
||||
|
||||
export function createCleaningTemplateUsingPost(data: any) {
|
||||
return post("/api/cleaning/templates", data);
|
||||
}
|
||||
|
||||
export function queryCleaningTemplateByIdUsingGet(templateId: string | number) {
|
||||
return get(`/api/cleaning/templates/${templateId}`);
|
||||
}
|
||||
|
||||
export function updateCleaningTemplateByIdUsingPut(templateId: string | number, data: any) {
|
||||
return put(`/api/cleaning/templates/${templateId}`, data);
|
||||
}
|
||||
|
||||
export function deleteCleaningTemplateByIdUsingDelete(templateId: string | number) {
|
||||
return del(`/api/cleaning/templates/${templateId}`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
134
frontend/src/pages/DataCleansing/cleansing.const.tsx
Normal file
134
frontend/src/pages/DataCleansing/cleansing.const.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
CleansingTask,
|
||||
CleansingTemplate,
|
||||
TaskStatus,
|
||||
TemplateType,
|
||||
} from "@/pages/DataCleansing/cleansing.model";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
formatExecutionDuration,
|
||||
} from "@/utils/unit";
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
AlertOutlined,
|
||||
DatabaseOutlined,
|
||||
AppstoreOutlined,
|
||||
PauseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export const templateTypesMap = {
|
||||
[TemplateType.TEXT]: {
|
||||
label: "文本",
|
||||
value: TemplateType.TEXT,
|
||||
icon: "📝",
|
||||
description: "处理文本数据的清洗模板",
|
||||
},
|
||||
[TemplateType.IMAGE]: {
|
||||
label: "图片",
|
||||
value: TemplateType.IMAGE,
|
||||
icon: "🖼️",
|
||||
description: "处理图像数据的清洗模板",
|
||||
},
|
||||
[TemplateType.VIDEO]: {
|
||||
value: TemplateType.VIDEO,
|
||||
label: "视频",
|
||||
icon: "🎥",
|
||||
description: "处理视频数据的清洗模板",
|
||||
},
|
||||
[TemplateType.AUDIO]: {
|
||||
value: TemplateType.AUDIO,
|
||||
label: "音频",
|
||||
icon: "🎵",
|
||||
description: "处理音频数据的清洗模板",
|
||||
},
|
||||
[TemplateType.IMAGE2TEXT]: {
|
||||
value: TemplateType.IMAGE2TEXT,
|
||||
label: "图片转文本",
|
||||
icon: "🔄",
|
||||
description: "图像识别转文本的处理模板",
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskStatusMap = {
|
||||
[TaskStatus.PENDING]: {
|
||||
label: "待处理",
|
||||
value: TaskStatus.PENDING,
|
||||
color: "gray",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
[TaskStatus.RUNNING]: {
|
||||
label: "进行中",
|
||||
value: TaskStatus.RUNNING,
|
||||
color: "blue",
|
||||
icon: <PlayCircleOutlined />,
|
||||
},
|
||||
[TaskStatus.COMPLETED]: {
|
||||
label: "已完成",
|
||||
value: TaskStatus.COMPLETED,
|
||||
color: "green",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
[TaskStatus.FAILED]: {
|
||||
label: "失败",
|
||||
value: TaskStatus.FAILED,
|
||||
color: "red",
|
||||
icon: <AlertOutlined />,
|
||||
},
|
||||
[TaskStatus.STOPPED]: {
|
||||
label: "已停止",
|
||||
value: TaskStatus.STOPPED,
|
||||
color: "orange",
|
||||
icon: <PauseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
export const mapTask = (task: CleansingTask) => {
|
||||
const duration = formatExecutionDuration(task.startedAt, task.finishedAt);
|
||||
const before = formatBytes(task.beforeSize);
|
||||
const after = formatBytes(task.afterSize);
|
||||
const status = TaskStatusMap[task.status];
|
||||
const finishedAt = formatDateTime(task.finishedAt);
|
||||
const startedAt = formatDateTime(task.startedAt);
|
||||
const createdAt = formatDateTime(task.createdAt);
|
||||
return {
|
||||
...task,
|
||||
createdAt,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
icon: <DatabaseOutlined style={{ color: "#1677ff" }} />,
|
||||
iconColor: "bg-blue-100",
|
||||
status,
|
||||
duration,
|
||||
before,
|
||||
after,
|
||||
statistics: [
|
||||
{ label: "进度", value: `${task.progress || 0}%` },
|
||||
{
|
||||
label: "执行耗时",
|
||||
value: duration,
|
||||
},
|
||||
{
|
||||
label: "处理前数据大小",
|
||||
value: task.beforeSize ? formatBytes(task.beforeSize) : "--",
|
||||
},
|
||||
{
|
||||
label: "处理后数据大小",
|
||||
value: task.afterSize ? formatBytes(task.afterSize) : "--",
|
||||
},
|
||||
],
|
||||
lastModified: formatDateTime(task.createdAt),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapTemplate = (template: CleansingTemplate) => ({
|
||||
...template,
|
||||
createdAt: formatDateTime(template.createdAt),
|
||||
updatedAt: formatDateTime(template.updatedAt),
|
||||
icon: <AppstoreOutlined style={{ color: "#1677ff" }} />,
|
||||
iconColor: "bg-blue-100",
|
||||
statistics: [{ label: "算子数量", value: template.instance?.length ?? 0 }],
|
||||
lastModified: formatDateTime(template.updatedAt),
|
||||
});
|
||||
68
frontend/src/pages/DataCleansing/cleansing.model.ts
Normal file
68
frontend/src/pages/DataCleansing/cleansing.model.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { OperatorI } from "../OperatorMarket/operator.model";
|
||||
|
||||
export interface CleansingTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
srcDatasetId: string;
|
||||
srcDatasetName: string;
|
||||
destDatasetId: string;
|
||||
destDatasetName: string;
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
status: {
|
||||
label: string;
|
||||
value: TaskStatus;
|
||||
color: string;
|
||||
};
|
||||
startedAt: string;
|
||||
progress: number;
|
||||
operators: OperatorI[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt: string;
|
||||
beforeSize?: number;
|
||||
afterSize?: number;
|
||||
}
|
||||
|
||||
export interface CleansingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
instance: OperatorI[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export enum RuleCategory {
|
||||
DATA_VALIDATION = "DATA_VALIDATION",
|
||||
MISSING_VALUE_HANDLING = "MISSING_VALUE_HANDLING",
|
||||
OUTLIER_DETECTION = "OUTLIER_DETECTION",
|
||||
DEDUPLICATION = "DEDUPLICATION",
|
||||
FORMAT_STANDARDIZATION = "FORMAT_STANDARDIZATION",
|
||||
TEXT_CLEANING = "TEXT_CLEANING",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
PENDING = "PENDING",
|
||||
RUNNING = "RUNNING",
|
||||
COMPLETED = "COMPLETED",
|
||||
FAILED = "FAILED",
|
||||
STOPPED = "STOPPED",
|
||||
}
|
||||
|
||||
export interface RuleCondition {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
logicOperator?: "AND" | "OR";
|
||||
}
|
||||
|
||||
export enum TemplateType {
|
||||
TEXT = "TEXT",
|
||||
IMAGE = "IMAGE",
|
||||
VIDEO = "VIDEO",
|
||||
AUDIO = "AUDIO",
|
||||
IMAGE2TEXT = "IMAGE2TEXT",
|
||||
}
|
||||
359
frontend/src/pages/DataCollection/Create/CreateTask.tsx
Normal file
359
frontend/src/pages/DataCollection/Create/CreateTask.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
Radio,
|
||||
Form,
|
||||
Divider,
|
||||
InputNumber,
|
||||
TimePicker,
|
||||
App,
|
||||
} from "antd";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createTaskUsingPost } from "../collection.apis";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface ScheduleConfig {
|
||||
type: "immediate" | "scheduled";
|
||||
scheduleType?: "day" | "week" | "month" | "custom";
|
||||
time?: string;
|
||||
dayOfWeek?: string;
|
||||
dayOfMonth?: string;
|
||||
cronExpression?: string;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
const defaultTemplates = [
|
||||
{
|
||||
id: "nas-to-local",
|
||||
name: "NAS到本地",
|
||||
description: "从NAS文件系统导入数据到本地文件系统",
|
||||
config: {
|
||||
reader: "nasreader",
|
||||
writer: "localwriter",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "obs-to-local",
|
||||
name: "OBS到本地",
|
||||
description: "从OBS文件系统导入数据到本地文件系统",
|
||||
config: {
|
||||
reader: "obsreader",
|
||||
writer: "localwriter",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "web-tolocal",
|
||||
name: "Web到本地",
|
||||
description: "从Web URL导入数据到本地文件系统",
|
||||
config: {
|
||||
reader: "webreader",
|
||||
writer: "localwriter",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function CollectionTaskCreate() {
|
||||
return <DevelopmentInProgress />;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [templateType, setTemplateType] = useState<"default" | "custom">(
|
||||
"default"
|
||||
);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||
const [customConfig, setCustomConfig] = useState("");
|
||||
|
||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>({
|
||||
type: "immediate",
|
||||
maxRetries: 10,
|
||||
scheduleType: "daily",
|
||||
});
|
||||
|
||||
const [isCreateDataset, setIsCreateDataset] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formData = await form.validateFields();
|
||||
if (templateType === "default" && !selectedTemplate) {
|
||||
window.alert("请选择默认模板");
|
||||
return;
|
||||
}
|
||||
if (templateType === "custom" && !customConfig.trim()) {
|
||||
window.alert("请填写自定义配置");
|
||||
return;
|
||||
}
|
||||
// Create task logic here
|
||||
const params = {
|
||||
...formData,
|
||||
templateType,
|
||||
selectedTemplate: templateType === "default" ? selectedTemplate : null,
|
||||
customConfig: templateType === "custom" ? customConfig : null,
|
||||
scheduleConfig,
|
||||
};
|
||||
console.log("Creating task:", params);
|
||||
await createTaskUsingPost(params);
|
||||
message.success("任务创建成功");
|
||||
navigate("/data/collection");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<Link to="/data/collection">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold bg-clip-text">创建归集任务</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
name: "",
|
||||
datasetName: "",
|
||||
fileFormat: "",
|
||||
description: "",
|
||||
cronExpression: "",
|
||||
retryCount: 3,
|
||||
timeout: 3600,
|
||||
incrementalField: "",
|
||||
}}
|
||||
onValuesChange={(_, allValues) => {
|
||||
// 文件格式变化时重置模板选择
|
||||
if (_.fileFormat !== undefined) setSelectedTemplate("");
|
||||
}}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-4">基本信息</h2>
|
||||
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="请输入任务描述" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item label="文件格式" name="fileFormat">
|
||||
<Input placeholder="请填写文件格式,使用正则表达式" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 同步配置 */}
|
||||
<h2 className="font-medium text-gray-900 my-4 text-lg">同步配置</h2>
|
||||
<Form.Item label="同步方式">
|
||||
<Radio.Group
|
||||
value={scheduleConfig.type}
|
||||
onChange={(e) =>
|
||||
setScheduleConfig({
|
||||
type: e.target.value as ScheduleConfig["type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Radio value="immediate">立即同步</Radio>
|
||||
<Radio value="scheduled">定时同步</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{scheduleConfig.type === "scheduled" && (
|
||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Form.Item label="调度类型">
|
||||
<Select
|
||||
options={[
|
||||
{ label: "每日", value: "day" },
|
||||
{ label: "每周", value: "week" },
|
||||
{ label: "每月", value: "month" },
|
||||
{ label: "自定义Cron", value: "custom" },
|
||||
]}
|
||||
value={scheduleConfig.scheduleType}
|
||||
onChange={(value) =>
|
||||
setScheduleConfig((prev) => ({
|
||||
...prev,
|
||||
scheduleType: value as ScheduleConfig["scheduleType"],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{scheduleConfig.scheduleType === "custom" ? (
|
||||
<Form.Item
|
||||
label="Cron表达式"
|
||||
name="cronExpression"
|
||||
rules={[{ required: true, message: "请输入Cron表达式" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如:0 0 * * * 表示每天午夜执行"
|
||||
value={scheduleConfig.cronExpression}
|
||||
onChange={(e) =>
|
||||
setScheduleConfig((prev) => ({
|
||||
...prev,
|
||||
cronExpression: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="执行时间" className="w-full">
|
||||
{scheduleConfig.scheduleType === "day" ? (
|
||||
<TimePicker />
|
||||
) : (
|
||||
<Select
|
||||
options={
|
||||
scheduleConfig.scheduleType === "week"
|
||||
? [
|
||||
{ label: "周一", value: "1" },
|
||||
{ label: "周二", value: "2" },
|
||||
{ label: "周三", value: "3" },
|
||||
{ label: "周四", value: "4" },
|
||||
{ label: "周五", value: "5" },
|
||||
{ label: "周六", value: "6" },
|
||||
{ label: "周日", value: "0" },
|
||||
]
|
||||
: [
|
||||
{ label: "每月1日", value: "1" },
|
||||
{ label: "每月5日", value: "5" },
|
||||
{ label: "每月10日", value: "10" },
|
||||
{ label: "每月15日", value: "15" },
|
||||
{ label: "每月20日", value: "20" },
|
||||
{ label: "每月25日", value: "25" },
|
||||
{ label: "每月30日", value: "30" },
|
||||
]
|
||||
}
|
||||
placeholder={
|
||||
scheduleConfig.scheduleType === "week"
|
||||
? "选择星期几"
|
||||
: "选择日期"
|
||||
}
|
||||
value={scheduleConfig.dayOfWeek}
|
||||
onChange={(value) =>
|
||||
setScheduleConfig((prev) => ({
|
||||
...prev,
|
||||
dayOfWeek: value as string,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Form.Item label="最大执行次数">
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={scheduleConfig.maxRetries}
|
||||
onChange={(value) =>
|
||||
setScheduleConfig((prev) => ({
|
||||
...prev,
|
||||
maxRetries: value,
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板配置 */}
|
||||
<h2 className="font-medium text-gray-900 my-4 text-lg">模板配置</h2>
|
||||
<Form.Item label="模板类型">
|
||||
<Radio.Group
|
||||
value={templateType}
|
||||
onChange={(e) => setTemplateType(e.target.value)}
|
||||
>
|
||||
<Radio value="default">使用默认模板</Radio>
|
||||
<Radio value="custom">自定义DataX JSON配置</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{templateType === "default" && (
|
||||
<Form.Item label="选择模板">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{defaultTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`border p-4 rounded-md hover:shadow-lg transition-shadow ${
|
||||
selectedTemplate === template.id
|
||||
? "border-blue-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setSelectedTemplate(template.id)}
|
||||
>
|
||||
<div className="font-medium">{template.name}</div>
|
||||
<div className="text-gray-500">{template.description}</div>
|
||||
<div className="text-gray-400">
|
||||
{template.config.reader} → {template.config.writer}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{templateType === "custom" && (
|
||||
<Form.Item label="DataX JSON配置">
|
||||
<TextArea
|
||||
placeholder="请输入DataX JSON配置..."
|
||||
value={customConfig}
|
||||
onChange={(e) => setCustomConfig(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 数据集配置 */}
|
||||
{templateType === "default" && (
|
||||
<>
|
||||
<h2 className="font-medium text-gray-900 my-4 text-lg">
|
||||
数据集配置
|
||||
</h2>
|
||||
<Form.Item
|
||||
label="是否创建数据集"
|
||||
name="createDataset"
|
||||
required
|
||||
rules={[{ required: true, message: "请选择是否创建数据集" }]}
|
||||
>
|
||||
<Radio.Group
|
||||
value={isCreateDataset}
|
||||
onChange={(e) => setIsCreateDataset(e.target.value)}
|
||||
>
|
||||
<Radio value={true}>是</Radio>
|
||||
<Radio value={false}>否</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{isCreateDataset && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="数据集名称"
|
||||
name="datasetName"
|
||||
rules={[{ required: true, message: "请输入数据集名称" }]}
|
||||
>
|
||||
<Input placeholder="请输入数据集名称" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<Divider />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button onClick={() => navigate("/data/collection")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/pages/DataCollection/Home/DataCollection.tsx
Normal file
44
frontend/src/pages/DataCollection/Home/DataCollection.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Tabs } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import TaskManagement from "./components/TaskManagement";
|
||||
import ExecutionLog from "./components/ExecutionLog";
|
||||
import { useNavigate } from "react-router";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
export default function DataCollection() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("task-management");
|
||||
|
||||
return <DevelopmentInProgress />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">数据归集</h1>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/data/collection/create-task")}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
items={[
|
||||
{ label: "任务管理", key: "task-management" },
|
||||
{ label: "执行日志", key: "execution-log" },
|
||||
]}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
/>
|
||||
{activeTab === "task-management" ? <TaskManagement /> : <ExecutionLog />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Card, Badge, Table } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import type { CollectionLog } from "@/pages/DataCollection/collection.model";
|
||||
import { queryExecutionLogUsingPost } from "../../collection.apis";
|
||||
import { LogStatusMap, LogTriggerTypeMap } from "../../collection.const";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
|
||||
const filterOptions = [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态筛选",
|
||||
options: Object.values(LogStatusMap),
|
||||
},
|
||||
{
|
||||
key: "triggerType",
|
||||
label: "触发类型",
|
||||
options: Object.values(LogTriggerTypeMap),
|
||||
},
|
||||
];
|
||||
|
||||
export default function ExecutionLog() {
|
||||
const handleReset = () => {
|
||||
setSearchParams({
|
||||
keyword: "",
|
||||
filters: {},
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
dateRange: null,
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryExecutionLogUsingPost);
|
||||
|
||||
const columns: ColumnsType<CollectionLog> = [
|
||||
{
|
||||
title: "任务名称",
|
||||
dataIndex: "taskName",
|
||||
key: "taskName",
|
||||
fixed: "left",
|
||||
render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: string) => (
|
||||
<Badge
|
||||
text={LogStatusMap[status]?.label}
|
||||
color={LogStatusMap[status]?.color}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "触发类型",
|
||||
dataIndex: "triggerType",
|
||||
key: "triggerType",
|
||||
render: (type: string) => LogTriggerTypeMap[type].label,
|
||||
},
|
||||
{
|
||||
title: "开始时间",
|
||||
dataIndex: "startTime",
|
||||
key: "startTime",
|
||||
},
|
||||
{
|
||||
title: "结束时间",
|
||||
dataIndex: "endTime",
|
||||
key: "endTime",
|
||||
},
|
||||
{
|
||||
title: "执行时长",
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
},
|
||||
{
|
||||
title: "重试次数",
|
||||
dataIndex: "retryCount",
|
||||
key: "retryCount",
|
||||
},
|
||||
{
|
||||
title: "进程ID",
|
||||
dataIndex: "processId",
|
||||
key: "processId",
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: "monospace" }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "错误信息",
|
||||
dataIndex: "errorMessage",
|
||||
key: "errorMessage",
|
||||
render: (msg?: string) =>
|
||||
msg ? (
|
||||
<span style={{ color: "#f5222d" }} title={msg}>
|
||||
{msg}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "#bbb" }}>-</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Filter Controls */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={(keyword: string) =>
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword,
|
||||
})
|
||||
}
|
||||
filters={filterOptions}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showViewToggle={false}
|
||||
onClearFilters={() =>
|
||||
setSearchParams((prev) => ({
|
||||
...prev,
|
||||
filters: {},
|
||||
}))
|
||||
}
|
||||
showDatePicker
|
||||
dateRange={searchParams.dateRange || [null, null]}
|
||||
onDateChange={(date) =>
|
||||
setSearchParams((prev) => ({ ...prev, dateRange: date }))
|
||||
}
|
||||
onReload={handleReset}
|
||||
searchPlaceholder="搜索任务名称、进程ID或错误信息..."
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
pagination={pagination}
|
||||
scroll={{ x: "max-content" }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Card, Button, Badge, Table, Dropdown, App } from "antd";
|
||||
import { EllipsisOutlined } from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import {
|
||||
deleteTaskByIdUsingDelete,
|
||||
executeTaskByIdUsingPost,
|
||||
queryTasksUsingGet,
|
||||
stopTaskByIdUsingPost,
|
||||
} from "../../collection.apis";
|
||||
import { TaskStatus, type CollectionTask } from "../../collection.model";
|
||||
import { StatusMap, SyncModeMap } from "../../collection.const";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function TaskManagement() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const filters = [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态筛选",
|
||||
options: [
|
||||
{ value: "all", label: "全部状态" },
|
||||
...Object.values(StatusMap),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryTasksUsingGet);
|
||||
|
||||
const handleStartTask = async (taskId: string) => {
|
||||
await executeTaskByIdUsingPost(taskId);
|
||||
message.success("任务启动请求已发送");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleStopTask = async (taskId: string) => {
|
||||
await stopTaskByIdUsingPost(taskId);
|
||||
message.success("任务停止请求已发送");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
await deleteTaskByIdUsingDelete(taskId);
|
||||
message.success("任务已删除");
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "任务名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
fixed: "left",
|
||||
render: (text: string, record: CollectionTask) => (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate("`/data-collection/tasks/${record.id}`)}>")}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: string) =>
|
||||
StatusMap[status] ? (
|
||||
<Badge
|
||||
color={StatusMap[status].color}
|
||||
text={StatusMap[status].label}
|
||||
/>
|
||||
) : (
|
||||
<Badge text={status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "同步方式",
|
||||
dataIndex: "syncMode",
|
||||
key: "syncMode",
|
||||
render: (text: string) => <span>{SyncModeMap[text]?.label}</span>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updatedAt",
|
||||
key: "updatedAt",
|
||||
},
|
||||
{
|
||||
title: "最近执行ID",
|
||||
dataIndex: "lastExecutionId",
|
||||
key: "lastExecutionId",
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
fixed: "right" as const,
|
||||
render: (_: any, record: Task) => (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
record.status === TaskStatus.STOPPED
|
||||
? {
|
||||
key: "start",
|
||||
label: "启动",
|
||||
onClick: () => handleStartTask(record.id),
|
||||
}
|
||||
: {
|
||||
key: "stop",
|
||||
label: "停止",
|
||||
onClick: () => handleStopTask(record.id),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
onClick: () => handleViewDetail(record),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
onClick: () => handleDeleteTask(record.id),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EllipsisOutlined style={{ fontSize: 20 }} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Actions */}
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={(newSearchTerm) =>
|
||||
setSearchParams((prev) => ({
|
||||
...prev,
|
||||
keyword: newSearchTerm,
|
||||
current: 1,
|
||||
}))
|
||||
}
|
||||
searchPlaceholder="搜索任务名称或描述..."
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showViewToggle={false}
|
||||
onClearFilters={() =>
|
||||
setSearchParams((prev) => ({
|
||||
...prev,
|
||||
filters: {},
|
||||
}))
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Tasks Table */}
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
...pagination,
|
||||
current: searchParams.current,
|
||||
pageSize: searchParams.pageSize,
|
||||
total: pagination.total,
|
||||
}}
|
||||
scroll={{ x: "max-content" }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/DataCollection/collection.apis.ts
Normal file
60
frontend/src/pages/DataCollection/collection.apis.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { get, post, put, del } from "@/utils/request";
|
||||
|
||||
// 数据源任务相关接口
|
||||
export function queryTasksUsingGet(params?: any) {
|
||||
return get("/api/data-collection/tasks", params);
|
||||
}
|
||||
|
||||
export function createTaskUsingPost(data: any) {
|
||||
return post("/api/data-collection/tasks", data);
|
||||
}
|
||||
|
||||
export function queryTaskByIdUsingGet(id: string | number) {
|
||||
return get(`/api/data-collection/tasks/${id}`);
|
||||
}
|
||||
|
||||
export function updateTaskByIdUsingPut(
|
||||
id: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/data-collection/tasks/${id}`, data);
|
||||
}
|
||||
|
||||
export function queryTaskDetailsByIdUsingGet(id: string | number) {
|
||||
return get(`/api/data-collection/tasks/${id}`);
|
||||
}
|
||||
|
||||
export function queryDataXTemplatesUsingGet(params?: any) {
|
||||
return get("/api/data-collection/templates", params);
|
||||
}
|
||||
export function deleteTaskByIdUsingDelete(id: string | number) {
|
||||
return del(`/api/data-collection/tasks/${id}`);
|
||||
}
|
||||
|
||||
export function executeTaskByIdUsingPost(
|
||||
id: string | number,
|
||||
data?: any
|
||||
) {
|
||||
return post(`/api/data-collection/tasks/${id}/execute`, data);
|
||||
}
|
||||
|
||||
export function stopTaskByIdUsingPost(
|
||||
id: string | number,
|
||||
data?: any
|
||||
) {
|
||||
return post(`/api/data-collection/tasks/${id}/stop`, data);
|
||||
}
|
||||
|
||||
// 执行日志相关接口
|
||||
export function queryExecutionLogUsingPost(params?: any) {
|
||||
return post("/api/data-collection/executions", params);
|
||||
}
|
||||
|
||||
export function queryExecutionLogByIdUsingGet(id: string | number) {
|
||||
return get(`/api/data-collection/executions/${id}`);
|
||||
}
|
||||
|
||||
// 监控统计相关接口
|
||||
export function queryCollectionStatisticsUsingGet(params?: any) {
|
||||
return get("/api/data-collection/monitor/statistics", params);
|
||||
}
|
||||
69
frontend/src/pages/DataCollection/collection.const.ts
Normal file
69
frontend/src/pages/DataCollection/collection.const.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { LogStatus, SyncMode, TaskStatus, TriggerType } from "./collection.model";
|
||||
|
||||
export const StatusMap: Record<
|
||||
TaskStatus,
|
||||
{ label: string; color: string; value: TaskStatus }
|
||||
> = {
|
||||
[TaskStatus.RUNNING]: {
|
||||
label: "运行",
|
||||
color: "blue",
|
||||
value: TaskStatus.RUNNING,
|
||||
},
|
||||
[TaskStatus.STOPPED]: {
|
||||
label: "停止",
|
||||
color: "gray",
|
||||
value: TaskStatus.STOPPED,
|
||||
},
|
||||
[TaskStatus.FAILED]: {
|
||||
label: "错误",
|
||||
color: "red",
|
||||
value: TaskStatus.FAILED,
|
||||
},
|
||||
[TaskStatus.SUCCESS]: {
|
||||
label: "成功",
|
||||
color: "green",
|
||||
value: TaskStatus.SUCCESS,
|
||||
},
|
||||
[TaskStatus.DRAFT]: {
|
||||
label: "草稿",
|
||||
color: "orange",
|
||||
value: TaskStatus.DRAFT,
|
||||
},
|
||||
[TaskStatus.READY]: { label: "就绪", color: "cyan", value: TaskStatus.READY },
|
||||
};
|
||||
|
||||
export const SyncModeMap: Record<SyncMode, { label: string; value: SyncMode }> =
|
||||
{
|
||||
[SyncMode.ONCE]: { label: "立即同步", value: SyncMode.ONCE },
|
||||
[SyncMode.SCHEDULED]: { label: "定时同步", value: SyncMode.SCHEDULED },
|
||||
};
|
||||
|
||||
export const LogStatusMap: Record<
|
||||
LogStatus,
|
||||
{ label: string; color: string; value: LogStatus }
|
||||
> = {
|
||||
[LogStatus.SUCCESS]: {
|
||||
label: "成功",
|
||||
color: "green",
|
||||
value: LogStatus.SUCCESS,
|
||||
},
|
||||
[LogStatus.FAILED]: {
|
||||
label: "失败",
|
||||
color: "red",
|
||||
value: LogStatus.FAILED,
|
||||
},
|
||||
[LogStatus.RUNNING]: {
|
||||
label: "运行中",
|
||||
color: "blue",
|
||||
value: LogStatus.RUNNING,
|
||||
},
|
||||
};
|
||||
|
||||
export const LogTriggerTypeMap: Record<
|
||||
TriggerType,
|
||||
{ label: string; value: TriggerType }
|
||||
> = {
|
||||
[TriggerType.MANUAL]: { label: "手动", value: TriggerType.MANUAL },
|
||||
[TriggerType.SCHEDULED]: { label: "定时", value: TriggerType.SCHEDULED },
|
||||
[TriggerType.API]: { label: "API", value: TriggerType.API },
|
||||
};
|
||||
52
frontend/src/pages/DataCollection/collection.model.ts
Normal file
52
frontend/src/pages/DataCollection/collection.model.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export enum TaskStatus {
|
||||
DRAFT = "DRAFT",
|
||||
READY = "READY",
|
||||
RUNNING = "RUNNING",
|
||||
SUCCESS = "SUCCESS",
|
||||
FAILED = "FAILED",
|
||||
STOPPED = "STOPPED",
|
||||
}
|
||||
|
||||
export enum SyncMode {
|
||||
ONCE = "ONCE",
|
||||
SCHEDULED = "SCHEDULED",
|
||||
}
|
||||
|
||||
export interface CollectionTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config: object; // 具体配置结构根据实际需求定义
|
||||
status: TaskStatus;
|
||||
syncMode: SyncMode;
|
||||
scheduleExpression?: string; // 仅当 syncMode 为 SCHEDULED 时存在
|
||||
lastExecutionId: string;
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
}
|
||||
|
||||
export enum LogStatus {
|
||||
RUNNING = "RUNNING",
|
||||
SUCCESS = "SUCCESS",
|
||||
FAILED = "FAILED",
|
||||
}
|
||||
|
||||
export enum TriggerType {
|
||||
MANUAL = "MANUAL",
|
||||
SCHEDULED = "SCHEDULED",
|
||||
API = "API",
|
||||
}
|
||||
|
||||
export interface CollectionLog {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
status: TaskStatus; // 任务执行状态
|
||||
triggerType: TriggerType; // 触发类型,如手动触发、定时触发等
|
||||
startTime: string; // ISO date string
|
||||
endTime: string; // ISO date string
|
||||
duration: string; // 格式化的持续时间字符串
|
||||
retryCount: number;
|
||||
processId: string;
|
||||
errorMessage?: string; // 可选,错误信息
|
||||
}
|
||||
574
frontend/src/pages/DataEvaluation/Create/CreateTask.tsx
Normal file
574
frontend/src/pages/DataEvaluation/Create/CreateTask.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Badge,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
Form,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
ArrowLeftOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
evaluationTemplates,
|
||||
presetEvaluationDimensions,
|
||||
sliceOperators,
|
||||
} from "@/mock/evaluation";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const EvaluationTaskCreate = () => {
|
||||
const navigate = useNavigate();
|
||||
const [datasets, setDatasets] = useState([]);
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<string>("dialogue_text");
|
||||
const [allDimensions, setAllDimensions] = useState<EvaluationDimension[]>([
|
||||
...presetEvaluationDimensions,
|
||||
]);
|
||||
const [editingDimension, setEditingDimension] = useState<string | null>(null);
|
||||
const [newDimension, setNewDimension] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
datasetId: "",
|
||||
evaluationType: "model" as "model" | "manual",
|
||||
dimensions: [] as string[],
|
||||
customDimensions: [] as EvaluationDimension[],
|
||||
sliceConfig: {
|
||||
threshold: 0.8,
|
||||
sampleCount: 100,
|
||||
method: "语义分割",
|
||||
},
|
||||
modelConfig: {
|
||||
url: "",
|
||||
apiKey: "",
|
||||
prompt: "",
|
||||
temperature: 0.3,
|
||||
maxTokens: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
const handleTemplateChange = (templateKey: string) => {
|
||||
setSelectedTemplate(templateKey);
|
||||
const template =
|
||||
evaluationTemplates[templateKey as keyof typeof evaluationTemplates];
|
||||
if (template) {
|
||||
const customDimensions = allDimensions.filter((d) => d.isCustom);
|
||||
setAllDimensions([...template.dimensions, ...customDimensions]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustomDimension = () => {
|
||||
if (newDimension.name.trim() && newDimension.description.trim()) {
|
||||
const customDimension: EvaluationDimension = {
|
||||
id: `custom_${Date.now()}`,
|
||||
name: newDimension.name.trim(),
|
||||
description: newDimension.description.trim(),
|
||||
category: "custom",
|
||||
isCustom: true,
|
||||
isEnabled: true,
|
||||
};
|
||||
setAllDimensions([...allDimensions, customDimension]);
|
||||
setNewDimension({ name: "", description: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionToggle = (id: string, checked: boolean) => {
|
||||
setAllDimensions(
|
||||
allDimensions.map((d) => (d.id === id ? { ...d, isEnabled: checked } : d))
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditDimension = (
|
||||
id: string,
|
||||
field: "name" | "description",
|
||||
value: string
|
||||
) => {
|
||||
setAllDimensions(
|
||||
allDimensions.map((d) => (d.id === id ? { ...d, [field]: value } : d))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteCustomDimension = (id: string) => {
|
||||
setAllDimensions(allDimensions.filter((d) => d.id !== id));
|
||||
};
|
||||
|
||||
const handleDeletePresetDimension = (id: string) => {
|
||||
setAllDimensions(
|
||||
allDimensions.map((d) => (d.id === id ? { ...d, isEnabled: false } : d))
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateTask = () => {
|
||||
const selectedDataset = datasets.find((d) => d.id === createForm.datasetId);
|
||||
if (!selectedDataset) return;
|
||||
|
||||
const enabledDimensions = allDimensions.filter((d) => d.isEnabled);
|
||||
const presetDimensionIds = enabledDimensions
|
||||
.filter((d) => !d.isCustom)
|
||||
.map((d) => d.id);
|
||||
const customDimensions = enabledDimensions.filter((d) => d.isCustom);
|
||||
|
||||
let finalPrompt = createForm.modelConfig.prompt;
|
||||
if (createForm.evaluationType === "model" && !finalPrompt.trim()) {
|
||||
finalPrompt = generateDefaultPrompt(selectedDataset.name);
|
||||
}
|
||||
|
||||
const newTask: EvaluationTask = {
|
||||
id: Date.now().toString(),
|
||||
name: createForm.name,
|
||||
datasetId: createForm.datasetId,
|
||||
datasetName: selectedDataset.name,
|
||||
evaluationType: createForm.evaluationType,
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
description: `${
|
||||
createForm.evaluationType === "model" ? "模型自动" : "人工"
|
||||
}评估${selectedDataset.name}`,
|
||||
dimensions: presetDimensionIds,
|
||||
customDimensions: customDimensions,
|
||||
modelConfig:
|
||||
createForm.evaluationType === "model"
|
||||
? {
|
||||
...createForm.modelConfig,
|
||||
prompt: finalPrompt,
|
||||
}
|
||||
: undefined,
|
||||
metrics: {
|
||||
accuracy: 0,
|
||||
completeness: 0,
|
||||
consistency: 0,
|
||||
relevance: 0,
|
||||
},
|
||||
issues: [],
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
setCreateForm({
|
||||
name: "",
|
||||
datasetId: "",
|
||||
evaluationType: "model",
|
||||
dimensions: [],
|
||||
customDimensions: [],
|
||||
modelConfig: {
|
||||
url: "",
|
||||
apiKey: "",
|
||||
prompt: "",
|
||||
temperature: 0.3,
|
||||
maxTokens: 2000,
|
||||
},
|
||||
});
|
||||
navigate("/data/evaluation");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate("/data/evaluation")}
|
||||
></Button>
|
||||
<div className="text-xl font-bold">创建评估任务</div>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical">
|
||||
{/* 基本信息 */}
|
||||
<Card title="基本信息" style={{ marginBottom: 24 }}>
|
||||
<Form.Item label="任务名称" required>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, name: e.target.value })
|
||||
}
|
||||
placeholder="输入任务名称"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="选择数据集" required>
|
||||
<Select
|
||||
value={createForm.datasetId || undefined}
|
||||
onChange={(value) =>
|
||||
setCreateForm({ ...createForm, datasetId: value })
|
||||
}
|
||||
placeholder="选择要评估的数据集"
|
||||
>
|
||||
{datasets.map((dataset) => (
|
||||
<Option key={dataset.id} value={dataset.id}>
|
||||
{dataset.name}({dataset.fileCount} 文件 • {dataset.size})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="评估方式" required>
|
||||
<Select
|
||||
value={createForm.evaluationType}
|
||||
onChange={(value: "model" | "manual") =>
|
||||
setCreateForm({ ...createForm, evaluationType: value })
|
||||
}
|
||||
>
|
||||
<Option value="model">模型自动评估</Option>
|
||||
<Option value="manual">人工评估</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 算子配置 */}
|
||||
<Card title="切片算子配置" style={{ marginBottom: 24 }}>
|
||||
<Form.Item label="切片算子">
|
||||
<Select
|
||||
value={createForm.sliceConfig.method}
|
||||
onChange={(value) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
sliceConfig: { ...createForm.sliceConfig, method: value },
|
||||
})
|
||||
}
|
||||
placeholder="选择切片算子"
|
||||
>
|
||||
{sliceOperators.map((operator) => (
|
||||
<Option key={operator.id} value={operator.name}>
|
||||
{operator.name}{" "}
|
||||
<Badge style={{ marginLeft: 8 }} count={operator.type} />
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="分隔符">
|
||||
<Input
|
||||
placeholder="输入分隔符,如 \\n\\n"
|
||||
value={createForm.sliceConfig.delimiter}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
sliceConfig: {
|
||||
...createForm.sliceConfig,
|
||||
delimiter: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="分块大小">
|
||||
<Input
|
||||
type="number"
|
||||
value={createForm.sliceConfig.chunkSize}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
sliceConfig: {
|
||||
...createForm.sliceConfig,
|
||||
chunkSize: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="重叠长度">
|
||||
<Input
|
||||
type="number"
|
||||
value={createForm.sliceConfig.overlapLength}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
sliceConfig: {
|
||||
...createForm.sliceConfig,
|
||||
overlapLength: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="抽样比例">
|
||||
<Input
|
||||
type="number"
|
||||
value={createForm.sliceConfig.threshold}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
sliceConfig: {
|
||||
...createForm.sliceConfig,
|
||||
threshold: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 评估维度配置 */}
|
||||
<Card
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>评估维度配置</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
onChange={handleTemplateChange}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
{Object.entries(evaluationTemplates).map(
|
||||
([key, template]) => (
|
||||
<Option key={key} value={key}>
|
||||
{template.name}
|
||||
</Option>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
<Badge
|
||||
count={allDimensions.filter((d) => d.isEnabled).length}
|
||||
style={{ background: "#f0f0f0", color: "#333" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
{/* 维度表格 */}
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #f0f0f0",
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fafafa",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 60 }}>启用</div>
|
||||
<div style={{ width: 160 }}>维度名称</div>
|
||||
<div style={{ flex: 1 }}>描述</div>
|
||||
<div style={{ width: 120 }}>操作</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ maxHeight: 320, overflowY: "auto" }}>
|
||||
{allDimensions.map((dimension) => (
|
||||
<div
|
||||
key={dimension.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid #f5f5f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 60 }}>
|
||||
<Checkbox
|
||||
checked={dimension.isEnabled}
|
||||
onChange={(e) =>
|
||||
handleDimensionToggle(dimension.id, e.target.checked!)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
{editingDimension === dimension.id && dimension.isCustom ? (
|
||||
<Input
|
||||
value={dimension.name}
|
||||
onChange={(e) =>
|
||||
handleEditDimension(
|
||||
dimension.id,
|
||||
"name",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{dimension.name}
|
||||
{dimension.isCustom && (
|
||||
<Badge
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
background: "#f9f0ff",
|
||||
color: "#722ed1",
|
||||
}}
|
||||
count="自定义"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingDimension === dimension.id && dimension.isCustom ? (
|
||||
<Input
|
||||
value={dimension.description}
|
||||
onChange={(e) =>
|
||||
handleEditDimension(
|
||||
dimension.id,
|
||||
"description",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: "#888" }}>
|
||||
{dimension.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
{editingDimension === dimension.id && dimension.isCustom ? (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SaveOutlined />}
|
||||
size="small"
|
||||
onClick={() => setEditingDimension(null)}
|
||||
/>
|
||||
) : (
|
||||
dimension.isCustom && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => setEditingDimension(dimension.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
onClick={() =>
|
||||
dimension.isCustom
|
||||
? handleDeleteCustomDimension(dimension.id)
|
||||
: handleDeletePresetDimension(dimension.id)
|
||||
}
|
||||
disabled={
|
||||
allDimensions.filter((d) => d.isEnabled).length <= 1 &&
|
||||
dimension.isEnabled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 添加自定义维度 */}
|
||||
<div style={{ background: "#fafafa", borderRadius: 6, padding: 16 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>
|
||||
添加自定义维度
|
||||
</div>
|
||||
<Input
|
||||
value={newDimension.name}
|
||||
onChange={(e) =>
|
||||
setNewDimension({ ...newDimension, name: e.target.value })
|
||||
}
|
||||
placeholder="维度名称"
|
||||
style={{ width: 180, marginRight: 8 }}
|
||||
size="small"
|
||||
/>
|
||||
<Input
|
||||
value={newDimension.description}
|
||||
onChange={(e) =>
|
||||
setNewDimension({
|
||||
...newDimension,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="维度描述"
|
||||
style={{ width: 260, marginRight: 8 }}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddCustomDimension}
|
||||
disabled={
|
||||
!newDimension.name.trim() || !newDimension.description.trim()
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
添加维度
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 模型配置(仅在选择模型评估时显示) */}
|
||||
{createForm.evaluationType === "model" && (
|
||||
<Card title="模型配置" style={{ marginBottom: 24 }}>
|
||||
<Form.Item label="模型 URL" required>
|
||||
<Input
|
||||
value={createForm.modelConfig.url}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
modelConfig: {
|
||||
...createForm.modelConfig,
|
||||
url: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="https://api.openai.com/v1/chat/completions"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" required>
|
||||
<Input.Password
|
||||
value={createForm.modelConfig.apiKey}
|
||||
onChange={(e) =>
|
||||
setCreateForm({
|
||||
...createForm,
|
||||
modelConfig: {
|
||||
...createForm.modelConfig,
|
||||
apiKey: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="sk-***"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Form.Item>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 12 }}>
|
||||
<Button onClick={() => navigate("/data/evaluation")}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleCreateTask}
|
||||
disabled={
|
||||
!createForm.name ||
|
||||
!createForm.datasetId ||
|
||||
allDimensions.filter((d) => d.isEnabled).length === 0 ||
|
||||
(createForm.evaluationType === "model" &&
|
||||
(!createForm.modelConfig.url ||
|
||||
!createForm.modelConfig.apiKey))
|
||||
}
|
||||
>
|
||||
创建评估任务
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvaluationTaskCreate;
|
||||
407
frontend/src/pages/DataEvaluation/Evaluate/ManualEvaluate.tsx
Normal file
407
frontend/src/pages/DataEvaluation/Evaluate/ManualEvaluate.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Card, Badge, Input, Typography, Breadcrumb } from "antd";
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
SaveOutlined,
|
||||
ScissorOutlined,
|
||||
AimOutlined,
|
||||
CalendarOutlined,
|
||||
FileTextOutlined,
|
||||
StarFilled,
|
||||
DatabaseOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { mockTasks, presetEvaluationDimensions } from "@/mock/evaluation";
|
||||
import { useNavigate } from "react-router";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title } = Typography;
|
||||
|
||||
// 生成切片内容
|
||||
const generateSliceContent = (index: number) => {
|
||||
const contents = [
|
||||
"用户咨询产品退换货政策的相关问题,希望了解具体的退货流程和时间限制。客服详细解释了7天无理由退货政策,包括商品需要保持原包装完整的要求。这个回答涵盖了用户关心的主要问题,提供了明确的时间限制和条件说明。",
|
||||
"客服回复关于质量问题商品的处理方式,说明15天内免费换货服务,并承诺承担相关物流费用。用户对此表示满意,认为这个政策很合理。回答中明确区分了质量问题和非质量问题的不同处理方式。",
|
||||
"用户询问特殊商品的退换货政策,客服解释个人定制商品不支持退货的规定,并建议用户在购买前仔细确认商品信息。这个回答帮助用户理解了特殊商品的限制条件。",
|
||||
"关于退货流程的详细说明,客服介绍了在线申请退货的步骤,包括订单页面操作和快递上门取件服务。整个流程描述清晰,用户可以轻松按照步骤操作。",
|
||||
"用户对物流费用承担问题提出疑问,客服明确说明质量问题导致的退换货由公司承担物流费用,非质量问题由用户承担。这个回答消除了用户的疑虑。",
|
||||
];
|
||||
return contents[index % contents.length];
|
||||
};
|
||||
|
||||
const slices: EvaluationSlice[] = Array.from(
|
||||
{ length: mockTasks[0].sliceConfig?.sampleCount || 50 },
|
||||
(_, index) => ({
|
||||
id: `slice_${index + 1}`,
|
||||
content: generateSliceContent(index),
|
||||
sourceFile: `file_${Math.floor(index / 5) + 1}.txt`,
|
||||
sliceIndex: index % 5,
|
||||
sliceType: ["paragraph", "sentence", "semantic"][index % 3],
|
||||
metadata: {
|
||||
startPosition: index * 200,
|
||||
endPosition: (index + 1) * 200,
|
||||
pageNumber: Math.floor(index / 10) + 1,
|
||||
section: `Section ${Math.floor(index / 5) + 1}`,
|
||||
processingMethod: mockTasks[0].sliceConfig?.method || "语义分割",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const ManualEvaluatePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const taskId = mockTasks[0].id;
|
||||
// 人工评估状态
|
||||
const [currentEvaluationTask, setCurrentEvaluationTask] =
|
||||
useState<EvaluationTask | null>(mockTasks[0]);
|
||||
const [evaluationSlices, setEvaluationSlices] =
|
||||
useState<EvaluationSlice[]>(slices);
|
||||
const [currentSliceIndex, setCurrentSliceIndex] = useState(0);
|
||||
const [sliceScores, setSliceScores] = useState<{
|
||||
[key: string]: { [dimensionId: string]: number };
|
||||
}>({});
|
||||
const [sliceComments, setSliceComments] = useState<{ [key: string]: string }>(
|
||||
{}
|
||||
);
|
||||
|
||||
const currentSlice = evaluationSlices[currentSliceIndex];
|
||||
const currentScores = sliceScores[currentSlice?.id] || {};
|
||||
const progress =
|
||||
evaluationSlices.length > 0
|
||||
? ((currentSliceIndex + 1) / evaluationSlices.length) * 100
|
||||
: 0;
|
||||
|
||||
// 获取任务的所有维度
|
||||
const getTaskAllDimensions = (task: EvaluationTask) => {
|
||||
const presetDimensions = presetEvaluationDimensions.filter((d) =>
|
||||
task.dimensions.includes(d.id)
|
||||
);
|
||||
return [...presetDimensions, ...(task.customDimensions || [])];
|
||||
};
|
||||
|
||||
const allDimensions = getTaskAllDimensions(mockTasks[0]);
|
||||
|
||||
// 更新切片评分
|
||||
const updateSliceScore = (
|
||||
sliceId: string,
|
||||
dimensionId: string,
|
||||
score: number
|
||||
) => {
|
||||
setSliceScores((prev) => ({
|
||||
...prev,
|
||||
[sliceId]: {
|
||||
...prev[sliceId],
|
||||
[dimensionId]: score,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// 保存当前切片评分并进入下一个
|
||||
const handleSaveAndNext = () => {
|
||||
const currentSlice = evaluationSlices[currentSliceIndex];
|
||||
if (!currentSlice) return;
|
||||
|
||||
// 检查是否所有维度都已评分
|
||||
const allDimensions = getTaskAllDimensions(currentEvaluationTask!);
|
||||
const currentScores = sliceScores[currentSlice.id] || {};
|
||||
const hasAllScores = allDimensions.every(
|
||||
(dim) => currentScores[dim.id] > 0
|
||||
);
|
||||
|
||||
if (!hasAllScores) {
|
||||
window.alert("请为所有维度评分后再保存");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是最后一个切片,完成评估
|
||||
if (currentSliceIndex === evaluationSlices.length - 1) {
|
||||
handleCompleteEvaluation();
|
||||
} else {
|
||||
setCurrentSliceIndex(currentSliceIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 完成评估
|
||||
const handleCompleteEvaluation = () => {
|
||||
navigate(`/data/evaluation/task-report/${mockTasks[0].id}`);
|
||||
};
|
||||
|
||||
// 星星评分组件
|
||||
const StarRating = ({
|
||||
value,
|
||||
onChange,
|
||||
dimension,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
dimension: EvaluationDimension;
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{dimension.name}</span>
|
||||
<span style={{ fontSize: 13, color: "#888" }}>{value}/5</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#888", marginBottom: 4 }}>
|
||||
{dimension.description}
|
||||
</div>
|
||||
<div>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Button
|
||||
key={star}
|
||||
type="text"
|
||||
icon={
|
||||
<StarFilled
|
||||
style={{
|
||||
color: star <= value ? "#fadb14" : "#d9d9d9",
|
||||
fontSize: 22,
|
||||
transition: "color 0.2s",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={() => onChange(star)}
|
||||
style={{ padding: 0, marginRight: 2 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 头部统计信息
|
||||
const statistics = [
|
||||
{
|
||||
icon: <DatabaseOutlined className="text-gray-500" />,
|
||||
label: "数据集",
|
||||
value: currentEvaluationTask?.datasetName || "",
|
||||
},
|
||||
{
|
||||
icon: <ScissorOutlined className="text-gray-500" />,
|
||||
label: "切片方法",
|
||||
value: currentEvaluationTask?.sliceConfig?.method || "",
|
||||
},
|
||||
{
|
||||
icon: <AimOutlined className="text-gray-500" />,
|
||||
label: "样本数量",
|
||||
value: evaluationSlices.length,
|
||||
},
|
||||
{
|
||||
icon: <CalendarOutlined className="text-gray-500" />,
|
||||
label: "创建时间",
|
||||
value: currentEvaluationTask?.createdAt || "",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: (
|
||||
<span onClick={() => navigate("/data/evaluation")}>数据评估</span>
|
||||
),
|
||||
},
|
||||
{ title: "人工评估", key: "manual-evaluate" },
|
||||
]}
|
||||
/>
|
||||
{/* 头部信息 */}
|
||||
<DetailHeader
|
||||
data={{
|
||||
name: currentEvaluationTask?.name || "",
|
||||
description: "人工评估任务",
|
||||
icon: <FileTextOutlined />,
|
||||
createdAt: currentEvaluationTask?.createdAt,
|
||||
lastUpdated: currentEvaluationTask?.createdAt,
|
||||
}}
|
||||
statistics={statistics}
|
||||
operations={[]}
|
||||
/>
|
||||
{/* 进度条 */}
|
||||
<div className="flex justify-between items-center mt-4 mb-6">
|
||||
<div className="text-xs text-gray-500">
|
||||
当前进度: {currentSliceIndex + 1} / {evaluationSlices.length}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-gray-500">
|
||||
{Math.round(progress)}% 完成
|
||||
</span>
|
||||
<div className="w-48 bg-gray-200 rounded h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 左侧:切片内容 */}
|
||||
<Card>
|
||||
<div className="border-b border-gray-100 pb-4 mb-4 flex justify-between items-center">
|
||||
<span className="text-base font-semibold flex items-center gap-2">
|
||||
<FileTextOutlined />
|
||||
切片内容
|
||||
</span>
|
||||
<Badge
|
||||
count={`切片 ${currentSliceIndex + 1}`}
|
||||
style={{ background: "#fafafa", color: "#333" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{currentSlice && (
|
||||
<>
|
||||
{/* 切片元信息 */}
|
||||
<div className="bg-gray-50 rounded p-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span className="text-gray-500">来源文件:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{currentSlice.sourceFile}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">处理方法:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{currentSlice.metadata.processingMethod}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">位置:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{currentSlice.metadata.startPosition}-
|
||||
{currentSlice.metadata.endPosition}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">章节:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{currentSlice.metadata.section}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 切片内容 */}
|
||||
<div className="border border-gray-100 rounded p-4 min-h-[180px]">
|
||||
<div className="text-xs text-gray-500 mb-2">内容预览</div>
|
||||
<div className="text-gray-900 leading-relaxed">
|
||||
{currentSlice.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() =>
|
||||
setCurrentSliceIndex(Math.max(0, currentSliceIndex - 1))
|
||||
}
|
||||
disabled={currentSliceIndex === 0}
|
||||
>
|
||||
上一个
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{currentSliceIndex + 1} / {evaluationSlices.length}
|
||||
</span>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<RightOutlined />}
|
||||
onClick={() =>
|
||||
setCurrentSliceIndex(
|
||||
Math.min(
|
||||
evaluationSlices.length - 1,
|
||||
currentSliceIndex + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={currentSliceIndex === evaluationSlices.length - 1}
|
||||
>
|
||||
下一个
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 右侧:评估维度 */}
|
||||
<Card>
|
||||
<div className="border-b border-gray-100 pb-4 mb-4">
|
||||
<span className="text-base font-semibold flex items-center gap-2">
|
||||
<StarFilled className="text-yellow-400" />
|
||||
评估维度
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
请为每个维度进行1-5星评分
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{allDimensions.map((dimension) => (
|
||||
<div
|
||||
key={dimension.id}
|
||||
className="border border-gray-100 rounded p-4"
|
||||
>
|
||||
<StarRating
|
||||
value={currentScores[dimension.id] || 0}
|
||||
onChange={(score) =>
|
||||
updateSliceScore(
|
||||
currentSlice?.id || "",
|
||||
dimension.id,
|
||||
score
|
||||
)
|
||||
}
|
||||
dimension={dimension}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 评论区域 */}
|
||||
<div className="border border-gray-100 rounded p-4">
|
||||
<span className="font-medium mb-2 block">评估备注</span>
|
||||
<TextArea
|
||||
placeholder="请输入对该切片的评估备注和建议..."
|
||||
value={sliceComments[currentSlice?.id || ""] || ""}
|
||||
onChange={(e) =>
|
||||
setSliceComments((prev) => ({
|
||||
...prev,
|
||||
[currentSlice?.id || ""]: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveAndNext}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{currentSliceIndex === evaluationSlices.length - 1
|
||||
? "完成评估"
|
||||
: "保存并下一个"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualEvaluatePage;
|
||||
484
frontend/src/pages/DataEvaluation/Home/DataEvaluation.tsx
Normal file
484
frontend/src/pages/DataEvaluation/Home/DataEvaluation.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Card, Badge, Progress, Table } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
ClockCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ReloadOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
EditOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import { mockTasks } from "@/mock/evaluation";
|
||||
import CardView from "@/components/CardView";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
export default function DataEvaluationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<EvaluationTask[]>(mockTasks);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
|
||||
// 搜索和过滤状态
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedFilters, setSelectedFilters] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [sortBy, setSortBy] = useState("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [viewMode, setViewMode] = useState<"card" | "table">("card");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("evaluation-tasks", JSON.stringify(tasks));
|
||||
}
|
||||
}, [tasks]);
|
||||
|
||||
// 搜索和过滤配置
|
||||
const filterOptions = [
|
||||
{
|
||||
key: "evaluationType",
|
||||
label: "评估方式",
|
||||
options: [
|
||||
{ label: "模型评估", value: "model" },
|
||||
{ label: "人工评估", value: "manual" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
options: [
|
||||
{ label: "待处理", value: "pending" },
|
||||
{ label: "运行中", value: "running" },
|
||||
{ label: "已完成", value: "completed" },
|
||||
{ label: "失败", value: "failed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "dataset",
|
||||
label: "数据集",
|
||||
options: datasets.map((d) => ({ label: d.name, value: d.id })),
|
||||
},
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "创建时间", value: "createdAt" },
|
||||
{ label: "任务名称", value: "name" },
|
||||
{ label: "完成时间", value: "completedAt" },
|
||||
{ label: "评分", value: "score" },
|
||||
];
|
||||
|
||||
// 过滤和排序逻辑
|
||||
const filteredTasks = tasks.filter((task) => {
|
||||
// 搜索过滤
|
||||
if (
|
||||
searchTerm &&
|
||||
!task.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!task.datasetName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 评估方式过滤
|
||||
if (
|
||||
selectedFilters.evaluationType?.length &&
|
||||
!selectedFilters.evaluationType.includes(task.evaluationType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (
|
||||
selectedFilters.status?.length &&
|
||||
!selectedFilters.status.includes(task.status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 数据集过滤
|
||||
if (
|
||||
selectedFilters.dataset?.length &&
|
||||
!selectedFilters.dataset.includes(task.datasetId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 排序
|
||||
const sortedTasks = [...filteredTasks].sort((a, b) => {
|
||||
let aValue: any = a[sortBy as keyof EvaluationTask];
|
||||
let bValue: any = b[sortBy as keyof EvaluationTask];
|
||||
|
||||
if (sortBy === "score") {
|
||||
aValue = a.score || 0;
|
||||
bValue = b.score || 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === "asc") {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "running":
|
||||
return "blue";
|
||||
case "failed":
|
||||
return "red";
|
||||
case "pending":
|
||||
return "gold";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircleOutlined />;
|
||||
case "running":
|
||||
return <ReloadOutlined spin />;
|
||||
case "failed":
|
||||
return <CloseCircleOutlined />;
|
||||
case "pending":
|
||||
return <ClockCircleOutlined />;
|
||||
default:
|
||||
return <ExclamationCircleOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始人工评估
|
||||
const handleStartManualEvaluation = (task: EvaluationTask) => {
|
||||
navigate(`/data/evaluation/manual-evaluate/${task.id}`);
|
||||
};
|
||||
|
||||
// 查看评估报告
|
||||
const handleViewReport = (task: EvaluationTask) => {
|
||||
navigate(`/data/evaluation/task-report/${task.id}`);
|
||||
};
|
||||
|
||||
// 删除任务
|
||||
const handleDeleteTask = (taskId: string) => {
|
||||
setTasks(tasks.filter((task) => task.id !== taskId));
|
||||
};
|
||||
|
||||
return (
|
||||
<DevelopmentInProgress />
|
||||
);
|
||||
// 主列表界面
|
||||
return (
|
||||
<div>
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">数据评估</h1>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate("/data/evaluation/create-task")}
|
||||
>
|
||||
创建评估任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤控件 */}
|
||||
<SearchControls
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="搜索任务名称或数据集..."
|
||||
filters={filterOptions}
|
||||
selectedFilters={selectedFilters}
|
||||
onFiltersChange={setSelectedFilters}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* 任务列表 */}
|
||||
{viewMode === "card" ? (
|
||||
<CardView
|
||||
data={sortedTasks.map((task) => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
type: task.evaluationType,
|
||||
icon:
|
||||
task.evaluationType === "model" ? (
|
||||
<RobotOutlined style={{ fontSize: 24, color: "#722ed1" }} />
|
||||
) : (
|
||||
<UserOutlined style={{ fontSize: 24, color: "#52c41a" }} />
|
||||
),
|
||||
iconColor: "",
|
||||
status: {
|
||||
label:
|
||||
task.status === "completed"
|
||||
? "已完成"
|
||||
: task.status === "running"
|
||||
? "运行中"
|
||||
: task.status === "failed"
|
||||
? "失败"
|
||||
: "待处理",
|
||||
icon: getStatusIcon(task.status),
|
||||
color: getStatusColor(task.status),
|
||||
},
|
||||
description: task.description,
|
||||
tags: [task.datasetName],
|
||||
statistics: [
|
||||
{
|
||||
label: "进度",
|
||||
value: task.progress !== undefined ? `${task.progress}%` : "-",
|
||||
},
|
||||
{ label: "评分", value: task.score ? `${task.score}分` : "-" },
|
||||
],
|
||||
lastModified: task.createdAt,
|
||||
}))}
|
||||
operations={[
|
||||
{
|
||||
key: "view",
|
||||
label: "查看报告",
|
||||
icon: <EyeOutlined />,
|
||||
onClick: (item) => {
|
||||
const task = tasks.find((t) => t.id === item.id);
|
||||
if (task) handleViewReport(task);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "evaluate",
|
||||
label: "开始评估",
|
||||
icon: <EditOutlined />,
|
||||
onClick: (item) => {
|
||||
const task = tasks.find((t) => t.id === item.id);
|
||||
if (task) handleStartManualEvaluation(task);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: (item) => handleDeleteTask(item.id as string),
|
||||
},
|
||||
]}
|
||||
onView={(item) => {
|
||||
const task = tasks.find((t) => t.id === item.id);
|
||||
if (task) handleViewReport(task);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={sortedTasks}
|
||||
pagination={false}
|
||||
scroll={{ x: "max-content" }}
|
||||
columns={[
|
||||
{
|
||||
title: "任务名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{text}</div>
|
||||
<div style={{ fontSize: 13, color: "#888" }}>
|
||||
{record.description}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "数据集",
|
||||
dataIndex: "datasetName",
|
||||
key: "datasetName",
|
||||
render: (text) => (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<DatabaseOutlined />
|
||||
<span style={{ fontSize: 13 }}>{text}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "评估方式",
|
||||
dataIndex: "evaluationType",
|
||||
key: "evaluationType",
|
||||
render: (type) => (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
{type === "model" ? (
|
||||
<RobotOutlined style={{ color: "#722ed1" }} />
|
||||
) : (
|
||||
<UserOutlined style={{ color: "#52c41a" }} />
|
||||
)}
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{type === "model" ? "模型评估" : "人工评估"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status) => (
|
||||
<Badge
|
||||
color={getStatusColor(status)}
|
||||
style={{ background: "none", padding: 0 }}
|
||||
count={
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
<span>
|
||||
{status === "completed" && "已完成"}
|
||||
{status === "running" && "运行中"}
|
||||
{status === "failed" && "失败"}
|
||||
{status === "pending" && "待处理"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
showZero={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "进度",
|
||||
dataIndex: "progress",
|
||||
key: "progress",
|
||||
render: (progress) =>
|
||||
progress !== undefined ? (
|
||||
<div style={{ width: 100 }}>
|
||||
<Progress
|
||||
percent={progress}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#888",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "#bbb" }}>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "评分",
|
||||
dataIndex: "score",
|
||||
key: "score",
|
||||
render: (score) =>
|
||||
score ? (
|
||||
<span style={{ fontWeight: 500, color: "#389e0d" }}>
|
||||
{score}分
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "#bbb" }}>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
render: (text) => <span style={{ fontSize: 13 }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (_, task) => (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{task.status === "completed" && (
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewReport(task)}
|
||||
>
|
||||
报告
|
||||
</Button>
|
||||
)}
|
||||
{task.evaluationType === "manual" &&
|
||||
task.status === "pending" && (
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleStartManualEvaluation(task)}
|
||||
>
|
||||
评估
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div
|
||||
style={{ textAlign: "center", padding: 48, color: "#bbb" }}
|
||||
>
|
||||
<DatabaseOutlined style={{ fontSize: 48, marginBottom: 8 }} />
|
||||
<div style={{ marginTop: 8 }}>暂无评估任务</div>
|
||||
<div style={{ fontSize: 13, color: "#ccc" }}>
|
||||
点击"创建评估任务"开始评估数据集质量
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{sortedTasks.length === 0 && (
|
||||
<div style={{ textAlign: "center", padding: "48px 0" }}>
|
||||
<DatabaseOutlined
|
||||
style={{ fontSize: 64, color: "#bbb", marginBottom: 16 }}
|
||||
/>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 8 }}>
|
||||
暂无评估任务
|
||||
</div>
|
||||
<div style={{ color: "#888", marginBottom: 24 }}>
|
||||
创建您的第一个数据评估任务
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate("/data/evaluation/create-task")}
|
||||
>
|
||||
创建评估任务
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
frontend/src/pages/DataEvaluation/Report/EvaluationReport.tsx
Normal file
310
frontend/src/pages/DataEvaluation/Report/EvaluationReport.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Button, Card, Badge, Breadcrumb } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Users,
|
||||
Scissors,
|
||||
BarChart3,
|
||||
Target,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
MessageSquare,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
mockQAPairs,
|
||||
mockTasks,
|
||||
presetEvaluationDimensions,
|
||||
} from "@/mock/evaluation";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
const EvaluationTaskReport = () => {
|
||||
const navigate = useNavigate();
|
||||
const selectedTask = mockTasks[0]; // 假设我们只展示第一个任务的报告
|
||||
|
||||
// 获取任务的所有维度
|
||||
const getTaskAllDimensions = (task: EvaluationTask) => {
|
||||
const presetDimensions = presetEvaluationDimensions.filter((d) =>
|
||||
task.dimensions.includes(d.id)
|
||||
);
|
||||
return [...presetDimensions, ...(task.customDimensions || [])];
|
||||
};
|
||||
const allDimensions = getTaskAllDimensions(selectedTask);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="mx-auto space-y-2">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: <Link to="/data/evaluation">数据评估</Link>,
|
||||
},
|
||||
{ title: "评估报告", key: "report" },
|
||||
]}
|
||||
></Breadcrumb>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
>
|
||||
导出报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{selectedTask.score || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">总体评分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Target className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{selectedTask.sliceConfig?.sampleCount}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">评估样本数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{selectedTask.evaluationType === "manual" ? "人工" : "模型"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">评估方式</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{selectedTask.progress}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">完成进度</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 评估结果 */}
|
||||
<Card
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
评估结果
|
||||
</span>
|
||||
}
|
||||
bodyStyle={{ paddingTop: 0 }}
|
||||
>
|
||||
{/* 维度评分 */}
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-3">维度评分</h4>
|
||||
<div className="space-y-3">
|
||||
{allDimensions.map((dimension) => {
|
||||
const score = 75 + Math.floor(Math.random() * 20); // 模拟评分
|
||||
return (
|
||||
<div
|
||||
key={dimension.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">
|
||||
{dimension.name}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-blue-600">
|
||||
{score}分
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分数解读 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="font-medium mb-3">质量分数解读</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||
<span>90-100分: 优秀,质量很高</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
<span>80-89分: 良好,质量较好</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span>70-79分: 一般,需要改进</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<span>60-69分: 较差,需要重点关注</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 切片信息 */}
|
||||
<Card
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5" />
|
||||
切片信息
|
||||
</span>
|
||||
}
|
||||
bodyStyle={{ paddingTop: 0 }}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">切片阈值:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedTask.sliceConfig?.threshold}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">抽样数量:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedTask.sliceConfig?.sampleCount}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">切片方法:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedTask.sliceConfig?.method}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">评估时间:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedTask.completedAt || selectedTask.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-3">评估维度</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allDimensions.map((dimension) => (
|
||||
<Badge
|
||||
key={dimension.id}
|
||||
style={{
|
||||
border: "1px solid #d9d9d9",
|
||||
background: "#fafafa",
|
||||
padding: "0 8px",
|
||||
}}
|
||||
>
|
||||
{dimension.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* QA对详情 */}
|
||||
<Card
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
QA对详情
|
||||
</span>
|
||||
}
|
||||
bodyStyle={{ paddingTop: 0 }}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
{mockQAPairs.map((qa) => (
|
||||
<div key={qa.id} className="border rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 mb-1">
|
||||
问题:
|
||||
</span>
|
||||
<span className="text-gray-900">{qa.question}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 mb-1">
|
||||
回答:
|
||||
</span>
|
||||
<span className="text-gray-900">{qa.answer}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">评分:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${
|
||||
star <= qa.score
|
||||
? "text-yellow-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
style={star <= qa.score ? { fill: "#facc15" } : {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{qa.score}/5</span>
|
||||
</div>
|
||||
{qa.feedback && (
|
||||
<div className="text-sm text-gray-600">{qa.feedback}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvaluationTaskReport;
|
||||
243
frontend/src/pages/DataEvaluation/data-evaluation.api.ts
Normal file
243
frontend/src/pages/DataEvaluation/data-evaluation.api.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { get, post, put, del, download } from "@/utils/request";
|
||||
|
||||
// 数据质量评估相关接口
|
||||
export function evaluateDataQualityUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/quality", data);
|
||||
}
|
||||
|
||||
export function getQualityEvaluationByIdUsingGet(evaluationId: string | number) {
|
||||
return get(`/api/v1/evaluation/quality/${evaluationId}`);
|
||||
}
|
||||
|
||||
// 适配性评估相关接口
|
||||
export function evaluateCompatibilityUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/compatibility", data);
|
||||
}
|
||||
|
||||
// 价值评估相关接口
|
||||
export function evaluateValueUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/value", data);
|
||||
}
|
||||
|
||||
// 评估报告管理接口
|
||||
export function queryEvaluationReportsUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/reports", params);
|
||||
}
|
||||
|
||||
export function getEvaluationReportByIdUsingGet(reportId: string | number) {
|
||||
return get(`/api/v1/evaluation/reports/${reportId}`);
|
||||
}
|
||||
|
||||
export function exportEvaluationReportUsingGet(reportId: string | number, format = "PDF", filename?: string) {
|
||||
return download(`/api/v1/evaluation/reports/${reportId}/export`, { format }, filename);
|
||||
}
|
||||
|
||||
// 批量评估接口
|
||||
export function batchEvaluationUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/batch", data);
|
||||
}
|
||||
|
||||
// 扩展功能接口(基于常见需求添加)
|
||||
|
||||
// 评估模板管理
|
||||
export function queryEvaluationTemplatesUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/templates", params);
|
||||
}
|
||||
|
||||
export function createEvaluationTemplateUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/templates", data);
|
||||
}
|
||||
|
||||
export function getEvaluationTemplateByIdUsingGet(templateId: string | number) {
|
||||
return get(`/api/v1/evaluation/templates/${templateId}`);
|
||||
}
|
||||
|
||||
export function updateEvaluationTemplateByIdUsingPut(templateId: string | number, data: any) {
|
||||
return put(`/api/v1/evaluation/templates/${templateId}`, data);
|
||||
}
|
||||
|
||||
export function deleteEvaluationTemplateByIdUsingDelete(templateId: string | number) {
|
||||
return del(`/api/v1/evaluation/templates/${templateId}`);
|
||||
}
|
||||
|
||||
// 评估历史记录
|
||||
export function queryEvaluationHistoryUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/history", params);
|
||||
}
|
||||
|
||||
export function getEvaluationHistoryByDatasetUsingGet(datasetId: string | number, params?: any) {
|
||||
return get(`/api/v1/evaluation/history/dataset/${datasetId}`, params);
|
||||
}
|
||||
|
||||
// 评估指标配置
|
||||
export function queryQualityMetricsUsingGet() {
|
||||
return get("/api/v1/evaluation/metrics/quality");
|
||||
}
|
||||
|
||||
export function queryCompatibilityMetricsUsingGet() {
|
||||
return get("/api/v1/evaluation/metrics/compatibility");
|
||||
}
|
||||
|
||||
export function queryValueMetricsUsingGet() {
|
||||
return get("/api/v1/evaluation/metrics/value");
|
||||
}
|
||||
|
||||
// 评估规则管理
|
||||
export function queryEvaluationRulesUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/rules", params);
|
||||
}
|
||||
|
||||
export function createEvaluationRuleUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/rules", data);
|
||||
}
|
||||
|
||||
export function updateEvaluationRuleByIdUsingPut(ruleId: string | number, data: any) {
|
||||
return put(`/api/v1/evaluation/rules/${ruleId}`, data);
|
||||
}
|
||||
|
||||
export function deleteEvaluationRuleByIdUsingDelete(ruleId: string | number) {
|
||||
return del(`/api/v1/evaluation/rules/${ruleId}`);
|
||||
}
|
||||
|
||||
// 评估统计信息
|
||||
export function getEvaluationStatisticsUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/statistics", params);
|
||||
}
|
||||
|
||||
export function getDatasetEvaluationSummaryUsingGet(datasetId: string | number) {
|
||||
return get(`/api/v1/evaluation/datasets/${datasetId}/summary`);
|
||||
}
|
||||
|
||||
// 评估任务管理
|
||||
export function queryEvaluationTasksUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/tasks", params);
|
||||
}
|
||||
|
||||
export function createEvaluationTaskUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/tasks", data);
|
||||
}
|
||||
|
||||
export function getEvaluationTaskByIdUsingGet(taskId: string | number) {
|
||||
return get(`/api/v1/evaluation/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export function cancelEvaluationTaskUsingPost(taskId: string | number) {
|
||||
return post(`/api/v1/evaluation/tasks/${taskId}/cancel`);
|
||||
}
|
||||
|
||||
export function retryEvaluationTaskUsingPost(taskId: string | number) {
|
||||
return post(`/api/v1/evaluation/tasks/${taskId}/retry`);
|
||||
}
|
||||
|
||||
// 评估结果比较
|
||||
export function compareEvaluationResultsUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/compare", data);
|
||||
}
|
||||
|
||||
// 评估配置管理
|
||||
export function getEvaluationConfigUsingGet() {
|
||||
return get("/api/v1/evaluation/config");
|
||||
}
|
||||
|
||||
export function updateEvaluationConfigUsingPut(data: any) {
|
||||
return put("/api/v1/evaluation/config", data);
|
||||
}
|
||||
|
||||
// 数据质量监控
|
||||
export function createQualityMonitorUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/quality/monitors", data);
|
||||
}
|
||||
|
||||
export function queryQualityMonitorsUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/quality/monitors", params);
|
||||
}
|
||||
|
||||
export function updateQualityMonitorByIdUsingPut(monitorId: string | number, data: any) {
|
||||
return put(`/api/v1/evaluation/quality/monitors/${monitorId}`, data);
|
||||
}
|
||||
|
||||
export function deleteQualityMonitorByIdUsingDelete(monitorId: string | number) {
|
||||
return del(`/api/v1/evaluation/quality/monitors/${monitorId}`);
|
||||
}
|
||||
|
||||
// 评估基准管理
|
||||
export function queryEvaluationBenchmarksUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/benchmarks", params);
|
||||
}
|
||||
|
||||
export function createEvaluationBenchmarkUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/benchmarks", data);
|
||||
}
|
||||
|
||||
export function updateEvaluationBenchmarkByIdUsingPut(benchmarkId: string | number, data: any) {
|
||||
return put(`/api/v1/evaluation/benchmarks/${benchmarkId}`, data);
|
||||
}
|
||||
|
||||
export function deleteEvaluationBenchmarkByIdUsingDelete(benchmarkId: string | number) {
|
||||
return del(`/api/v1/evaluation/benchmarks/${benchmarkId}`);
|
||||
}
|
||||
|
||||
// 评估算法管理
|
||||
export function queryEvaluationAlgorithmsUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/algorithms", params);
|
||||
}
|
||||
|
||||
export function runCustomEvaluationUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/custom", data);
|
||||
}
|
||||
|
||||
// 评估可视化数据
|
||||
export function getEvaluationVisualizationUsingGet(evaluationId: string | number, chartType?: string) {
|
||||
return get(`/api/v1/evaluation/${evaluationId}/visualization`, { chartType });
|
||||
}
|
||||
|
||||
// 评估通知和警报
|
||||
export function queryEvaluationAlertsUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/alerts", params);
|
||||
}
|
||||
|
||||
export function createEvaluationAlertUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/alerts", data);
|
||||
}
|
||||
|
||||
export function updateEvaluationAlertByIdUsingPut(alertId: string | number, data: any) {
|
||||
return put(`/api/v1/evaluation/alerts/${alertId}`, data);
|
||||
}
|
||||
|
||||
export function deleteEvaluationAlertByIdUsingDelete(alertId: string | number) {
|
||||
return del(`/api/v1/evaluation/alerts/${alertId}`);
|
||||
}
|
||||
|
||||
// 批量操作扩展
|
||||
export function batchDeleteEvaluationReportsUsingPost(data: { reportIds: string[] }) {
|
||||
return post("/api/v1/evaluation/reports/batch-delete", data);
|
||||
}
|
||||
|
||||
export function batchExportEvaluationReportsUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/reports/batch-export", data);
|
||||
}
|
||||
|
||||
// 评估调度管理
|
||||
export function queryEvaluationSchedulesUsingGet(params?: any) {
|
||||
return get("/api/v1/evaluation/schedules", params);
|
||||
}
|
||||
|
||||
export function createEvaluationScheduleUsingPost(data: any) {
|
||||
return post("/api/v1/evaluation/schedules", data);
|
||||
}
|
||||
|
||||
export function updateEvaluationScheduleByIdUsingPut(scheduleId: string | number, data: any) {
|
||||
return put(`/api/v1/evaluation/schedules/${scheduleId}`, data);
|
||||
}
|
||||
|
||||
export function deleteEvaluationScheduleByIdUsingDelete(scheduleId: string | number) {
|
||||
return del(`/api/v1/evaluation/schedules/${scheduleId}`);
|
||||
}
|
||||
|
||||
export function enableEvaluationScheduleUsingPost(scheduleId: string | number) {
|
||||
return post(`/api/v1/evaluation/schedules/${scheduleId}/enable`);
|
||||
}
|
||||
|
||||
export function disableEvaluationScheduleUsingPost(scheduleId: string | number) {
|
||||
return post(`/api/v1/evaluation/schedules/${scheduleId}/disable`);
|
||||
}
|
||||
73
frontend/src/pages/DataEvaluation/data-evaluation.d.ts
vendored
Normal file
73
frontend/src/pages/DataEvaluation/data-evaluation.d.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
interface EvaluationDimension {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: "quality" | "accuracy" | "completeness" | "consistency" | "bias" | "custom"
|
||||
isCustom?: boolean
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
interface EvaluationTask {
|
||||
id: string
|
||||
name: string
|
||||
datasetId: string
|
||||
datasetName: string
|
||||
evaluationType: "model" | "manual"
|
||||
status: "running" | "completed" | "failed" | "pending"
|
||||
score?: number
|
||||
progress?: number
|
||||
createdAt: string
|
||||
completedAt?: string
|
||||
description: string
|
||||
dimensions: string[]
|
||||
customDimensions: EvaluationDimension[]
|
||||
sliceConfig?: {
|
||||
threshold: number
|
||||
sampleCount: number
|
||||
method: string
|
||||
}
|
||||
modelConfig?: {
|
||||
url: string
|
||||
apiKey: string
|
||||
prompt: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
}
|
||||
metrics: {
|
||||
accuracy: number
|
||||
completeness: number
|
||||
consistency: number
|
||||
relevance: number
|
||||
}
|
||||
issues: {
|
||||
type: string
|
||||
count: number
|
||||
severity: "high" | "medium" | "low"
|
||||
}[]
|
||||
}
|
||||
|
||||
interface EvaluationSlice {
|
||||
id: string
|
||||
content: string
|
||||
sourceFile: string
|
||||
sliceIndex: number
|
||||
sliceType: string
|
||||
metadata: {
|
||||
startPosition?: number
|
||||
endPosition?: number
|
||||
pageNumber?: number
|
||||
section?: string
|
||||
processingMethod: string
|
||||
}
|
||||
scores?: { [dimensionId: string]: number }
|
||||
comment?: string
|
||||
}
|
||||
|
||||
interface QAPair {
|
||||
id: string
|
||||
question: string
|
||||
answer: string
|
||||
sliceId: string
|
||||
score: number
|
||||
feedback?: string
|
||||
}
|
||||
79
frontend/src/pages/DataManagement/Create/CreateDataset.tsx
Normal file
79
frontend/src/pages/DataManagement/Create/CreateDataset.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button, Form, App } from "antd";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { createDatasetUsingPost } from "../dataset.api";
|
||||
import { DatasetType, DataSource } from "../dataset.model";
|
||||
import BasicInformation from "./components/BasicInformation";
|
||||
|
||||
export default function DatasetCreate() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [newDataset, setNewDataset] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
datasetType: DatasetType.TEXT,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formValues = await form.validateFields();
|
||||
|
||||
const params = {
|
||||
...formValues,
|
||||
files: undefined,
|
||||
};
|
||||
try {
|
||||
await createDatasetUsingPost(params);
|
||||
message.success(`数据集创建成功`);
|
||||
navigate("/data/management");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("数据集创建失败,请重试");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setNewDataset({ ...newDataset, ...allValues });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<Link to="/data/management">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold bg-clip-text">创建数据集</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* form */}
|
||||
<div className="h-full flex flex-col flex-1 overflow-auto bg-white border-gray-200 rounded shadow-sm">
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={newDataset}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
>
|
||||
<BasicInformation data={newDataset} setData={setNewDataset} />
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end p-6 border-t border-gray-200">
|
||||
<Button onClick={() => navigate("/data/management")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/pages/DataManagement/Create/EditDataset.tsx
Normal file
101
frontend/src/pages/DataManagement/Create/EditDataset.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import BasicInformation from "./components/BasicInformation";
|
||||
import {
|
||||
queryDatasetByIdUsingGet,
|
||||
updateDatasetByIdUsingPut,
|
||||
} from "../dataset.api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dataset, DatasetType } from "../dataset.model";
|
||||
import { App, Button, Drawer, Form, Modal } from "antd";
|
||||
|
||||
export default function EditDataset({
|
||||
open,
|
||||
data,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
data: Dataset | null;
|
||||
onClose: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [newDataset, setNewDataset] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
datasetType: DatasetType.TEXT,
|
||||
tags: [],
|
||||
});
|
||||
const fetchDataset = async () => {
|
||||
// 如果有id,说明是编辑模式
|
||||
if (data && data.id) {
|
||||
const { data: newData } = await queryDatasetByIdUsingGet(data.id);
|
||||
const updatedDataset = {
|
||||
...newData,
|
||||
type: newData.type,
|
||||
tags: newData.tags.map((tag) => tag.name) || [],
|
||||
};
|
||||
setNewDataset(updatedDataset);
|
||||
form.setFieldsValue(updatedDataset);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDataset();
|
||||
}, [data]);
|
||||
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setNewDataset({ ...newDataset, ...allValues });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formValues = await form.validateFields();
|
||||
|
||||
const params = {
|
||||
...formValues,
|
||||
files: undefined,
|
||||
};
|
||||
try {
|
||||
await updateDatasetByIdUsingPut(data?.id, params);
|
||||
onClose();
|
||||
message.success("数据集更新成功");
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("数据集更新失败,请重试");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`编辑数据集${data?.name}`}
|
||||
onCancel={onClose}
|
||||
open={open}
|
||||
width={600}
|
||||
maskClosable={false}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={newDataset}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
>
|
||||
<BasicInformation
|
||||
data={newDataset}
|
||||
setData={setNewDataset}
|
||||
hidden={["datasetType"]}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import RadioCard from "@/components/RadioCard";
|
||||
import { Input, Select, Form } from "antd";
|
||||
import { datasetTypes } from "../../dataset.const";
|
||||
import { useEffect, useState } from "react";
|
||||
import { mockPreparedTags } from "@/components/TagManagement";
|
||||
import { queryDatasetTagsUsingGet } from "../../dataset.api";
|
||||
|
||||
export default function BasicInformation({
|
||||
data,
|
||||
setData,
|
||||
hidden = [],
|
||||
}: {
|
||||
data: any;
|
||||
setData: any;
|
||||
hidden?: string[];
|
||||
}) {
|
||||
const [tagOptions, setTagOptions] = useState<
|
||||
{
|
||||
label: JSX.Element;
|
||||
title: string;
|
||||
options: { label: JSX.Element; value: string }[];
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
// 获取标签
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const { data } = await queryDatasetTagsUsingGet();
|
||||
const preparedTags = mockPreparedTags.map((tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
}));
|
||||
const customTags = data.map((tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
}));
|
||||
setTagOptions([
|
||||
{
|
||||
label: <span>预置标签</span>,
|
||||
title: "prepared",
|
||||
options: preparedTags,
|
||||
},
|
||||
{
|
||||
label: <span>自定义标签</span>,
|
||||
title: "custom",
|
||||
options: customTags,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error fetching tags: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入数据集名称" }]}
|
||||
>
|
||||
<Input placeholder="输入数据集名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea placeholder="描述数据集的用途和内容" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 数据集类型选择 - 使用卡片形式 */}
|
||||
{!hidden.includes("datasetType") && (
|
||||
<Form.Item
|
||||
label="类型"
|
||||
name="datasetType"
|
||||
rules={[{ required: true, message: "请选择数据集类型" }]}
|
||||
>
|
||||
<RadioCard
|
||||
options={datasetTypes}
|
||||
value={data.type}
|
||||
onChange={(datasetType) => setData({ ...data, datasetType })}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select
|
||||
className="w-full"
|
||||
mode="tags"
|
||||
options={tagOptions}
|
||||
placeholder="请选择标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
227
frontend/src/pages/DataManagement/Detail/DatasetDetail.tsx
Normal file
227
frontend/src/pages/DataManagement/Detail/DatasetDetail.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Breadcrumb, App, Tabs } from "antd";
|
||||
import {
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
EditOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { mapDataset, datasetTypeMap } from "../dataset.const";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useFilesOperation } from "../hooks";
|
||||
import {
|
||||
createDatasetTagUsingPost,
|
||||
downloadFile,
|
||||
queryDatasetByIdUsingGet,
|
||||
queryDatasetTagsUsingGet,
|
||||
updateDatasetByIdUsingPut,
|
||||
} from "../dataset.api";
|
||||
import DataQuality from "./components/DataQuality";
|
||||
import DataLineageFlow from "./components/DataLineageFlow";
|
||||
import Overview from "./components/Overview";
|
||||
import { Activity, Clock, File, FileType } from "lucide-react";
|
||||
import EditDataset from "../Create/EditDataset";
|
||||
import ImportConfiguration from "./components/ImportConfiguration";
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: "overview",
|
||||
label: "概览",
|
||||
},
|
||||
{
|
||||
key: "lineage",
|
||||
label: "数据血缘",
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "数据质量",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DatasetDetail() {
|
||||
const { id } = useParams(); // 获取动态路由参数
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const { message } = App.useApp();
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
|
||||
const [dataset, setDataset] = useState<Dataset>({} as Dataset);
|
||||
const filesOperation = useFilesOperation(dataset);
|
||||
|
||||
const [showUploadDialog, setShowUploadDialog] = useState(false);
|
||||
const navigateItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: <Link to="/data/management">数据管理</Link>,
|
||||
},
|
||||
{
|
||||
title: dataset.name || "数据集详情",
|
||||
},
|
||||
],
|
||||
[dataset]
|
||||
);
|
||||
const fetchDataset = async () => {
|
||||
const { data } = await queryDatasetByIdUsingGet(id as unknown as number);
|
||||
setDataset(mapDataset(data));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDataset();
|
||||
filesOperation.fetchFiles();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async (showMessage = true) => {
|
||||
fetchDataset();
|
||||
filesOperation.fetchFiles();
|
||||
if (showMessage) message.success({ content: "数据刷新成功" });
|
||||
};
|
||||
|
||||
const handleExportFormat = async ({ type }) => {
|
||||
await downloadFile(dataset.id, type, `${dataset.name}-${type}.zip`);
|
||||
message.success("文件下载成功");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const refreshDataset = () => {
|
||||
fetchDataset();
|
||||
};
|
||||
const refreshData = () => {
|
||||
handleRefresh(false);
|
||||
};
|
||||
window.addEventListener("update:dataset", refreshData);
|
||||
window.addEventListener("update:dataset-status", () => refreshDataset());
|
||||
return () => {
|
||||
window.removeEventListener("update:dataset", refreshData);
|
||||
window.removeEventListener("update:dataset-status", refreshDataset);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 基本信息描述项
|
||||
const statistics = [
|
||||
{
|
||||
icon: <File className="text-blue-400 w-4 h-4" />,
|
||||
key: "file",
|
||||
value: dataset?.fileCount || 0,
|
||||
},
|
||||
{
|
||||
icon: <Activity className="text-blue-400 w-4 h-4" />,
|
||||
key: "size",
|
||||
value: dataset?.size || "0 B",
|
||||
},
|
||||
{
|
||||
icon: <FileType className="text-blue-400 w-4 h-4" />,
|
||||
key: "type",
|
||||
value:
|
||||
datasetTypeMap[dataset?.datasetType as keyof typeof datasetTypeMap]
|
||||
?.label ||
|
||||
dataset?.type ||
|
||||
"未知",
|
||||
},
|
||||
{
|
||||
icon: <Clock className="text-blue-400 w-4 h-4" />,
|
||||
key: "time",
|
||||
value: dataset?.createdAt,
|
||||
},
|
||||
];
|
||||
|
||||
// 数据集操作列表
|
||||
const operations = [
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setShowEditDialog(true);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "upload",
|
||||
label: "导入数据",
|
||||
icon: <UploadOutlined />,
|
||||
onClick: () => setShowUploadDialog(true),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
label: "导出",
|
||||
icon: <DownloadOutlined />,
|
||||
// isDropdown: true,
|
||||
// items: [
|
||||
// { key: "alpaca", label: "Alpaca 格式", icon: <FileTextOutlined /> },
|
||||
// { key: "jsonl", label: "JSONL 格式", icon: <DatabaseOutlined /> },
|
||||
// { key: "csv", label: "CSV 格式", icon: <FileTextOutlined /> },
|
||||
// { key: "coco", label: "COCO 格式", icon: <FileImageOutlined /> },
|
||||
// ],
|
||||
onMenuClick: handleExportFormat,
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
label: "刷新",
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: handleRefresh,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<Breadcrumb items={navigateItems} />
|
||||
{/* Header */}
|
||||
<DetailHeader
|
||||
data={dataset}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
tagConfig={{
|
||||
showAdd: true,
|
||||
tags: dataset.tags || [],
|
||||
onFetchTags: async () => {
|
||||
const res = await queryDatasetTagsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000,
|
||||
});
|
||||
return res.data || [];
|
||||
},
|
||||
onCreateAndTag: async (tagName) => {
|
||||
const res = await createDatasetTagUsingPost({ name: tagName });
|
||||
if (res.data) {
|
||||
await updateDatasetByIdUsingPut(dataset.id, {
|
||||
tags: [...dataset.tags.map((tag) => tag.name), res.data.name],
|
||||
});
|
||||
handleRefresh();
|
||||
}
|
||||
},
|
||||
onAddTag: async (tag) => {
|
||||
const res = await updateDatasetByIdUsingPut(dataset.id, {
|
||||
tags: [...dataset.tags.map((tag) => tag.name), tag],
|
||||
});
|
||||
if (res.data) {
|
||||
handleRefresh();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className="h-full flex flex-col flex-1 overflow-auto p-6 pt-2 bg-white rounded-md shadow">
|
||||
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
|
||||
<div className="h-full overflow-auto">
|
||||
{activeTab === "overview" && (
|
||||
<Overview dataset={dataset} filesOperation={filesOperation} />
|
||||
)}
|
||||
{activeTab === "lineage" && <DataLineageFlow dataset={dataset} />}
|
||||
{activeTab === "quality" && <DataQuality />}
|
||||
</div>
|
||||
</div>
|
||||
<ImportConfiguration
|
||||
data={dataset}
|
||||
open={showUploadDialog}
|
||||
onClose={() => setShowUploadDialog(false)}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<EditDataset
|
||||
data={dataset}
|
||||
open={showEditDialog}
|
||||
onClose={() => setShowEditDialog(false)}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
import { Dataset } from "../../dataset.model";
|
||||
|
||||
export default function DataLineageFlow(dataset: Dataset) {
|
||||
return <DevelopmentInProgress showHome={false} />
|
||||
const lineage = dataset.lineage;
|
||||
if (!lineage) return null;
|
||||
|
||||
const steps = [
|
||||
{ name: "数据源", value: lineage.source, icon: Database },
|
||||
...lineage.processing.map((step, index) => ({
|
||||
name: `处理${index + 1}`,
|
||||
value: step,
|
||||
icon: GitBranch,
|
||||
})),
|
||||
];
|
||||
|
||||
if (lineage.training) {
|
||||
steps.push({
|
||||
name: "模型训练",
|
||||
value: `${lineage.training.model} (准确率: ${lineage.training.accuracy}%)`,
|
||||
icon: Target,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex items-start gap-4 pb-8 last:pb-0">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center shadow-lg">
|
||||
<step.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="w-0.5 h-12 bg-gradient-to-b from-blue-200 to-indigo-200 mt-2"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 pt-3">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h5 className="font-semibold text-gray-900 mb-1">
|
||||
{step.name}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600">{step.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
import { Card } from "antd";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function DataQuality() {
|
||||
return <DevelopmentInProgress showHome={false} />
|
||||
return (
|
||||
<div className=" mt-0">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card title="质量分布">
|
||||
{[
|
||||
{ metric: "图像清晰度", value: 96.2, color: "bg-green-500" },
|
||||
{ metric: "色彩一致性", value: 94.8, color: "bg-blue-500" },
|
||||
{ metric: "标注完整性", value: 98.1, color: "bg-purple-500" },
|
||||
].map((item, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{item.metric}</span>
|
||||
<span className="font-semibold">{item.value}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`${item.color} h-3 rounded-full transition-all duration-500`}
|
||||
style={{ width: `${item.value}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="数据完整性">
|
||||
{[
|
||||
{ metric: "文件完整性", value: 99.7, color: "bg-green-500" },
|
||||
{ metric: "元数据完整性", value: 97.3, color: "bg-blue-500" },
|
||||
{ metric: "标签一致性", value: 95.6, color: "bg-purple-500" },
|
||||
].map((item, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{item.metric}</span>
|
||||
<span className="font-semibold">{item.value}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`${item.color} h-3 rounded-full transition-all duration-500`}
|
||||
style={{ width: `${item.value}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-r from-yellow-50 to-orange-50 border-yellow-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-600 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-800 mb-2">质量改进建议</h4>
|
||||
<ul className="text-sm text-yellow-700 space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
建议对42张图像进行重新标注以提高准确性
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
检查并补充缺失的病理分级信息
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
考虑增加更多低分化样本以平衡数据分布
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Select, Input, Form, Radio, Modal, Button } from "antd";
|
||||
import { dataSourceOptions } from "../../dataset.const";
|
||||
import { Dataset, DataSource } from "../../dataset.model";
|
||||
import { useEffect, useState } from "react";
|
||||
import { queryTasksUsingGet } from "@/pages/DataCollection/collection.apis";
|
||||
import { useImportFile } from "../../hooks";
|
||||
import { updateDatasetByIdUsingPut } from "../../dataset.api";
|
||||
|
||||
export default function ImportConfiguration({
|
||||
data,
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
data?: Dataset;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [collectionOptions, setCollectionOptions] = useState([]);
|
||||
const [importConfig, setImportConfig] = useState<any>({
|
||||
source: DataSource.UPLOAD,
|
||||
});
|
||||
const { importFileRender, handleUpload } = useImportFile();
|
||||
|
||||
// 获取归集任务列表
|
||||
const fetchCollectionTasks = async () => {
|
||||
try {
|
||||
const res = await queryTasksUsingGet({ page: 0, size: 100 });
|
||||
const options = res.data.content.map((task: any) => ({
|
||||
label: task.name,
|
||||
value: task.id,
|
||||
}));
|
||||
setCollectionOptions(options);
|
||||
} catch (error) {
|
||||
console.error("Error fetching collection tasks:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
form.resetFields();
|
||||
setImportConfig({ source: DataSource.UPLOAD });
|
||||
};
|
||||
|
||||
const handleImportData = async () => {
|
||||
if (importConfig.source === DataSource.UPLOAD) {
|
||||
await handleUpload(data);
|
||||
} else if (importConfig.source === DataSource.COLLECTION) {
|
||||
await updateDatasetByIdUsingPut(data?.id!, {
|
||||
...importConfig,
|
||||
});
|
||||
}
|
||||
resetState();
|
||||
onRefresh?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchCollectionTasks();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="导入数据"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={onClose}
|
||||
maskClosable={false}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={handleImportData}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={importConfig || {}}
|
||||
onValuesChange={(_, allValues) => setImportConfig(allValues)}
|
||||
>
|
||||
<Form.Item
|
||||
label="数据源"
|
||||
name="source"
|
||||
rules={[{ required: true, message: "请选择数据源" }]}
|
||||
>
|
||||
<Radio.Group
|
||||
buttonStyle="solid"
|
||||
options={dataSourceOptions}
|
||||
optionType="button"
|
||||
/>
|
||||
</Form.Item>
|
||||
{importConfig?.source === DataSource.COLLECTION && (
|
||||
<Form.Item name="dataSource" label="归集任务" required>
|
||||
<Select placeholder="请选择归集任务" options={collectionOptions} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* nas import */}
|
||||
{importConfig?.source === DataSource.NAS && (
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-blue-50 rounded-lg">
|
||||
<Form.Item
|
||||
name="nasPath"
|
||||
rules={[{ required: true }]}
|
||||
label="NAS地址"
|
||||
>
|
||||
<Input placeholder="192.168.1.100" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sharePath"
|
||||
rules={[{ required: true }]}
|
||||
label="共享路径"
|
||||
>
|
||||
<Input placeholder="/share/importConfig" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true }]}
|
||||
label="用户名"
|
||||
>
|
||||
<Input placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true }]}
|
||||
label="密码"
|
||||
>
|
||||
<Input type="password" placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
{/* obs import */}
|
||||
{importConfig?.source === DataSource.OBS && (
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-blue-50 rounded-lg">
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
rules={[{ required: true }]}
|
||||
label="Endpoint"
|
||||
>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="obs.cn-north-4.myhuaweicloud.com"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bucket"
|
||||
rules={[{ required: true }]}
|
||||
label="Bucket"
|
||||
>
|
||||
<Input className="h-8 text-xs" placeholder="my-bucket" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="accessKey"
|
||||
rules={[{ required: true }]}
|
||||
label="Access Key"
|
||||
>
|
||||
<Input className="h-8 text-xs" placeholder="Access Key" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="secretKey"
|
||||
rules={[{ required: true }]}
|
||||
label="Secret Key"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Secret Key"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Upload Component */}
|
||||
{importConfig?.source === DataSource.UPLOAD && (
|
||||
<Form.Item
|
||||
label="上传文件"
|
||||
name="files"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请上传文件",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{importFileRender()}
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Target Configuration */}
|
||||
{importConfig?.target && importConfig?.target !== DataSource.UPLOAD && (
|
||||
<div className="space-y-3 p-4 bg-blue-50 rounded-lg">
|
||||
{importConfig?.target === DataSource.DATABASE && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item
|
||||
name="databaseType"
|
||||
rules={[{ required: true }]}
|
||||
label="数据库类型"
|
||||
>
|
||||
<Select
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "MySQL", value: "mysql" },
|
||||
{ label: "PostgreSQL", value: "postgresql" },
|
||||
{ label: "MongoDB", value: "mongodb" },
|
||||
]}
|
||||
></Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="tableName"
|
||||
rules={[{ required: true }]}
|
||||
label="表名"
|
||||
>
|
||||
<Input className="h-8 text-xs" placeholder="dataset_table" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="connectionString"
|
||||
rules={[{ required: true }]}
|
||||
label="连接字符串"
|
||||
>
|
||||
<Input
|
||||
className="h-8 text-xs col-span-2"
|
||||
placeholder="数据库连接字符串"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
211
frontend/src/pages/DataManagement/Detail/components/Overview.tsx
Normal file
211
frontend/src/pages/DataManagement/Detail/components/Overview.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Button, Descriptions, DescriptionsProps, Modal, Table } from "antd";
|
||||
import { formatBytes, formatDateTime } from "@/utils/unit";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { datasetTypeMap } from "../../dataset.const";
|
||||
|
||||
export default function Overview({ dataset, filesOperation }) {
|
||||
const {
|
||||
fileList,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
previewVisible,
|
||||
previewFileName,
|
||||
previewContent,
|
||||
setPreviewVisible,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleBatchDeleteFiles,
|
||||
handleBatchExport,
|
||||
} = filesOperation;
|
||||
|
||||
// 文件列表多选配置
|
||||
const rowSelection = {
|
||||
onChange: (selectedRowKeys: React.Key[], selectedRows: any[]) => {
|
||||
setSelectedFiles(selectedRowKeys as number[]);
|
||||
console.log(
|
||||
`selectedRowKeys: ${selectedRowKeys}`,
|
||||
"selectedRows: ",
|
||||
selectedRows
|
||||
);
|
||||
},
|
||||
};
|
||||
// 基本信息
|
||||
const items: DescriptionsProps["items"] = [
|
||||
{
|
||||
key: "id",
|
||||
label: "ID",
|
||||
children: dataset.id,
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "名称",
|
||||
children: dataset.name,
|
||||
},
|
||||
{
|
||||
key: "fileCount",
|
||||
label: "文件数",
|
||||
children: dataset.fileCount || 0,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
label: "数据大小",
|
||||
children: dataset.size || "0 B",
|
||||
},
|
||||
|
||||
{
|
||||
key: "datasetType",
|
||||
label: "类型",
|
||||
children: datasetTypeMap[dataset?.datasetType]?.label || "未知",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
children: dataset?.status?.label || "未知",
|
||||
},
|
||||
{
|
||||
key: "createdBy",
|
||||
label: "创建者",
|
||||
children: dataset.createdBy || "未知",
|
||||
},
|
||||
{
|
||||
key: "targetLocation",
|
||||
label: "存储路径",
|
||||
children: dataset.targetLocation || "未知",
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "创建时间",
|
||||
children: dataset.createdAt,
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
children: dataset.updatedAt,
|
||||
},
|
||||
{
|
||||
key: "dataSource",
|
||||
label: "数据源",
|
||||
children: dataset.dataSource || "未知",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
children: dataset.description || "无",
|
||||
},
|
||||
];
|
||||
|
||||
// 文件列表列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "文件名",
|
||||
dataIndex: "fileName",
|
||||
key: "fileName",
|
||||
fixed: "left",
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "fileSize",
|
||||
key: "fileSize",
|
||||
width: 150,
|
||||
render: (text) => formatBytes(text),
|
||||
},
|
||||
{
|
||||
title: "上传时间",
|
||||
dataIndex: "uploadTime",
|
||||
key: "uploadTime",
|
||||
width: 200,
|
||||
render: (text) => formatDateTime(text),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 180,
|
||||
fixed: "right",
|
||||
render: (_, record) => (
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => handleDownloadFile(record)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => handleDeleteFile(record)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className=" flex flex-col gap-4">
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
title="基本信息"
|
||||
layout="vertical"
|
||||
size="small"
|
||||
items={items}
|
||||
column={5}
|
||||
/>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<h2 className="text-base font-semibold mt-8">文件列表</h2>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex items-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<span className="text-sm text-blue-700 font-medium">
|
||||
已选择 {selectedFiles.length} 个文件
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBatchExport}
|
||||
className="ml-auto bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
批量导出
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchDeleteFiles}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 bg-transparent"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
size="middle"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={fileList}
|
||||
// rowSelection={rowSelection}
|
||||
scroll={{ x: "max-content", y: 600 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 文件预览弹窗 */}
|
||||
<Modal
|
||||
title={`文件预览:${previewFileName}`}
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
fontSize: 14,
|
||||
color: "#222",
|
||||
}}
|
||||
>
|
||||
{previewContent}
|
||||
</pre>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
350
frontend/src/pages/DataManagement/Home/DataManagement.tsx
Normal file
350
frontend/src/pages/DataManagement/Home/DataManagement.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Card, Button, Statistic, Table, Tooltip, Tag, App } from "antd";
|
||||
import {
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import TagManager from "@/components/TagManagement";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import { datasetStatusMap, datasetTypeMap, mapDataset } from "../dataset.const";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
downloadDatasetUsingGet,
|
||||
getDatasetStatisticsUsingGet,
|
||||
queryDatasetsUsingGet,
|
||||
deleteDatasetByIdUsingDelete,
|
||||
createDatasetTagUsingPost,
|
||||
queryDatasetTagsUsingGet,
|
||||
updateDatasetTagByIdUsingPut,
|
||||
deleteDatasetTagByIdUsingDelete,
|
||||
} from "../dataset.api";
|
||||
import { formatBytes } from "@/utils/unit";
|
||||
import EditDataset from "../Create/EditDataset";
|
||||
|
||||
export default function DatasetManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
const [editDatasetOpen, setEditDatasetOpen] = useState(false);
|
||||
const [currentDataset, setCurrentDataset] = useState<Dataset | null>(null);
|
||||
|
||||
const [statisticsData, setStatisticsData] = useState<any>({
|
||||
count: {},
|
||||
size: {},
|
||||
});
|
||||
|
||||
async function fetchStatistics() {
|
||||
const { data } = await getDatasetStatisticsUsingGet();
|
||||
|
||||
const statistics = {
|
||||
size: [
|
||||
{
|
||||
title: "数据集总数",
|
||||
value: data?.totalDatasets || 0,
|
||||
},
|
||||
{
|
||||
title: "文件总数",
|
||||
value: data?.totalFiles?.image || "0 MB",
|
||||
},
|
||||
{
|
||||
title: "总大小",
|
||||
value: formatBytes(data?.totalSize) || 0,
|
||||
},
|
||||
],
|
||||
count: [
|
||||
{
|
||||
title: "文本",
|
||||
value: data?.count?.text || 0,
|
||||
},
|
||||
{
|
||||
title: "图像",
|
||||
value: data?.count?.image || 0,
|
||||
},
|
||||
{
|
||||
title: "音频",
|
||||
value: data?.count?.audio || 0,
|
||||
},
|
||||
{
|
||||
title: "视频",
|
||||
value: data?.count?.video || 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
setStatisticsData(statistics);
|
||||
}
|
||||
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
const { data } = await queryDatasetTagsUsingGet();
|
||||
setTags(data.map((tag) => tag.name));
|
||||
};
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "type",
|
||||
label: "类型",
|
||||
options: [...Object.values(datasetTypeMap)],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
options: [...Object.values(datasetStatusMap)],
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
label: "标签",
|
||||
mode: "multiple",
|
||||
options: tags.map((tag) => ({ label: tag, value: tag })),
|
||||
},
|
||||
],
|
||||
[tags]
|
||||
);
|
||||
|
||||
const {
|
||||
tableData,
|
||||
searchParams,
|
||||
pagination,
|
||||
fetchData,
|
||||
setSearchParams,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryDatasetsUsingGet, mapDataset);
|
||||
|
||||
const handleDownloadDataset = async (dataset: Dataset) => {
|
||||
await downloadDatasetUsingGet(dataset.id, dataset.name);
|
||||
message.success("数据集下载成功");
|
||||
};
|
||||
|
||||
const handleDeleteDataset = async (id: number) => {
|
||||
if (!id) return;
|
||||
await deleteDatasetByIdUsingDelete(id);
|
||||
fetchData();
|
||||
message.success("数据删除成功");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics();
|
||||
}, []);
|
||||
|
||||
const operations = [
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
icon: <EditOutlined />,
|
||||
onClick: (item: Dataset) => {
|
||||
console.log(item);
|
||||
setCurrentDataset(item);
|
||||
setEditDatasetOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
label: "下载",
|
||||
icon: <DownloadOutlined />,
|
||||
onClick: (item: Dataset) => {
|
||||
if (!item.id) return;
|
||||
handleDownloadDataset(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: (item: Dataset) => handleDeleteDataset(item.id),
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
fixed: "left",
|
||||
render: (name, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate(`/data/management/detail/${record.id}`)}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "size",
|
||||
key: "size",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "文件数",
|
||||
dataIndex: "fileCount",
|
||||
key: "fileCount",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "创建者",
|
||||
dataIndex: "createdBy",
|
||||
key: "createdBy",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "存储路径",
|
||||
dataIndex: "targetLocation",
|
||||
key: "targetLocation",
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updatedAt",
|
||||
key: "updatedAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: any) => {
|
||||
return (
|
||||
<Tag icon={status?.icon} color={status?.color}>
|
||||
{status?.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
render: (_: any, record: Dataset) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{operations.map((op) => (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={op.icon}
|
||||
onClick={() => op.onClick(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const renderCardView = () => (
|
||||
<CardView
|
||||
data={tableData}
|
||||
pageSize={9}
|
||||
operations={operations}
|
||||
pagination={pagination}
|
||||
onView={(dataset) => {
|
||||
navigate("/data/management/detail/" + dataset.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderListView = () => (
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={pagination}
|
||||
rowKey="id"
|
||||
scroll={{ x: "max-content", y: "calc(100vh - 30rem)" }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gap-4 h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">数据管理</h1>
|
||||
<div className="flex gap-2">
|
||||
{/* tasks */}
|
||||
<TagManager
|
||||
onCreate={createDatasetTagUsingPost}
|
||||
onDelete={deleteDatasetTagByIdUsingDelete}
|
||||
onUpdate={updateDatasetTagByIdUsingPut}
|
||||
onFetch={queryDatasetTagsUsingGet}
|
||||
/>
|
||||
<Link to="/data/management/create">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
创建数据集
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<div className="grid grid-cols-3">
|
||||
{statisticsData.size?.map?.((item) => (
|
||||
<Statistic
|
||||
title={item.title}
|
||||
key={item.title}
|
||||
value={`${item.value}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={(keyword) =>
|
||||
setSearchParams({ ...searchParams, keyword })
|
||||
}
|
||||
searchPlaceholder="搜索数据集名称、描述或标签..."
|
||||
filters={filterOptions}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle
|
||||
onReload={fetchData}
|
||||
/>
|
||||
{viewMode === "card" ? renderCardView() : renderListView()}
|
||||
<EditDataset
|
||||
open={editDatasetOpen}
|
||||
data={currentDataset}
|
||||
onClose={() => setEditDatasetOpen(false)}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/DataManagement/dataset.api.ts
Normal file
198
frontend/src/pages/DataManagement/dataset.api.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { get, post, put, del, download, upload } from "@/utils/request";
|
||||
|
||||
// 数据集统计接口
|
||||
export function getDatasetStatisticsUsingGet() {
|
||||
return get("/api/data-management/datasets/statistics");
|
||||
}
|
||||
|
||||
export function queryDatasetStatisticsByIdUsingGet(id: string | number) {
|
||||
return get(`/api/data-management/datasets/${id}/statistics`);
|
||||
}
|
||||
|
||||
// 查询数据集列表
|
||||
export function queryDatasetsUsingGet(params?: any) {
|
||||
return get("/api/data-management/datasets", params);
|
||||
}
|
||||
|
||||
// 创建数据集
|
||||
export function createDatasetUsingPost(data: any) {
|
||||
return post("/api/data-management/datasets", data);
|
||||
}
|
||||
|
||||
// 根据ID获取数据集详情
|
||||
export function queryDatasetByIdUsingGet(id: string | number) {
|
||||
return get(`/api/data-management/datasets/${id}`);
|
||||
}
|
||||
|
||||
// 更新数据集
|
||||
export function updateDatasetByIdUsingPut(id: string | number, data: any) {
|
||||
return put(`/api/data-management/datasets/${id}`, data);
|
||||
}
|
||||
|
||||
// 删除数据集
|
||||
export function deleteDatasetByIdUsingDelete(id: string | number) {
|
||||
return del(`/api/data-management/datasets/${id}`);
|
||||
}
|
||||
|
||||
// 下载数据集
|
||||
export function downloadDatasetUsingGet(
|
||||
id: string | number,
|
||||
filename?: string
|
||||
) {
|
||||
return download(
|
||||
`/api/data-management/datasets/${id}/download`,
|
||||
null,
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
// 验证数据集
|
||||
export function validateDatasetUsingPost(id: string | number, data?: any) {
|
||||
return post(`/api/data-management/datasets/${id}/validate`, data);
|
||||
}
|
||||
|
||||
// 获取数据集文件列表
|
||||
export function queryDatasetFilesUsingGet(id: string | number, params?: any) {
|
||||
return get(`/api/data-management/datasets/${id}/files`, params);
|
||||
}
|
||||
|
||||
// 上传数据集文件
|
||||
export function uploadDatasetFileUsingPost(id: string | number, data: any) {
|
||||
return post(`/api/data-management/datasets/${id}/files`, data);
|
||||
}
|
||||
|
||||
export function downloadFile(
|
||||
id: string | number,
|
||||
fileId: string | number,
|
||||
filename?: string
|
||||
) {
|
||||
return download(
|
||||
`/api/data-management/datasets/${id}/files/download`,
|
||||
null,
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
// 删除数据集文件
|
||||
export function deleteDatasetFileUsingDelete(
|
||||
datasetId: string | number,
|
||||
fileId: string | number
|
||||
) {
|
||||
return del(`/api/data-management/datasets/${datasetId}/files/${fileId}`);
|
||||
}
|
||||
|
||||
// 文件预览
|
||||
export function previewDatasetUsingGet(id: string | number, params?: any) {
|
||||
return get(`/api/data-management/datasets/${id}/preview`, params);
|
||||
}
|
||||
|
||||
// 获取数据集标签
|
||||
export function queryDatasetTagsUsingGet(params?: any) {
|
||||
return get("/api/data-management/tags", params);
|
||||
}
|
||||
|
||||
// 创建数据集标签
|
||||
export function createDatasetTagUsingPost(data: any) {
|
||||
return post("/api/data-management/tags", data);
|
||||
}
|
||||
|
||||
// 更新数据集标签
|
||||
export function updateDatasetTagByIdUsingPut(id: string | number, data: any) {
|
||||
return put(`/api/data-management/tags/${id}`, data);
|
||||
}
|
||||
|
||||
// 删除数据集标签
|
||||
export function deleteDatasetTagByIdUsingDelete(id: string | number) {
|
||||
return del(`/api/data-management/tags/${id}`);
|
||||
}
|
||||
|
||||
// 数据集质量检查
|
||||
export function checkDatasetQualityUsingPost(id: string | number, data?: any) {
|
||||
return post(`/api/data-management/datasets/${id}/quality-check`, data);
|
||||
}
|
||||
|
||||
// 获取数据集质量报告
|
||||
export function getDatasetQualityReportUsingGet(id: string | number) {
|
||||
return get(`/api/data-management/datasets/${id}/quality-report`);
|
||||
}
|
||||
|
||||
// 数据集分析
|
||||
export function analyzeDatasetUsingPost(id: string | number, data?: any) {
|
||||
return post(`/api/data-management/datasets/${id}/analyze`, data);
|
||||
}
|
||||
|
||||
// 获取数据集分析结果
|
||||
export function getDatasetAnalysisUsingGet(id: string | number) {
|
||||
return get(`/api/data-management/datasets/${id}/analysis`);
|
||||
}
|
||||
|
||||
// 导出数据集
|
||||
export function exportDatasetUsingPost(id: string | number, data: any) {
|
||||
return post(`/api/data-management/datasets/${id}/export`, data);
|
||||
}
|
||||
|
||||
// 复制数据集
|
||||
export function copyDatasetUsingPost(id: string | number, data: any) {
|
||||
return post(`/api/data-management/datasets/${id}/copy`, data);
|
||||
}
|
||||
|
||||
// 获取数据集版本列表
|
||||
export function queryDatasetVersionsUsingGet(
|
||||
id: string | number,
|
||||
params?: any
|
||||
) {
|
||||
return get(`/api/data-management/datasets/${id}/versions`, params);
|
||||
}
|
||||
|
||||
// 创建数据集版本
|
||||
export function createDatasetVersionUsingPost(id: string | number, data: any) {
|
||||
return post(`/api/data-management/datasets/${id}/versions`, data);
|
||||
}
|
||||
|
||||
// 切换数据集版本
|
||||
export function switchDatasetVersionUsingPut(
|
||||
id: string | number,
|
||||
versionId: string | number
|
||||
) {
|
||||
return put(
|
||||
`/api/data-management/datasets/${id}/versions/${versionId}/switch`
|
||||
);
|
||||
}
|
||||
|
||||
// 删除数据集版本
|
||||
export function deleteDatasetVersionUsingDelete(
|
||||
id: string | number,
|
||||
versionId: string | number
|
||||
) {
|
||||
return del(`/api/data-management/datasets/${id}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传相关接口
|
||||
*/
|
||||
|
||||
export function preUploadUsingPost(id: string | number, data: any) {
|
||||
return post(
|
||||
`/api/data-management/datasets/${id}/files/upload/pre-upload`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
export function cancelUploadUsingPut(id) {
|
||||
return put(
|
||||
`/api/data-management/datasets/upload/cancel-upload/${id}`,
|
||||
{},
|
||||
{ showLoading: false }
|
||||
);
|
||||
}
|
||||
|
||||
export function uploadFileChunkUsingPost(id: string | number, params, config) {
|
||||
return post(
|
||||
`/api/data-management/datasets/${id}/files/upload/chunk`,
|
||||
params,
|
||||
{
|
||||
showLoading: false,
|
||||
...config,
|
||||
}
|
||||
);
|
||||
}
|
||||
218
frontend/src/pages/DataManagement/dataset.const.tsx
Normal file
218
frontend/src/pages/DataManagement/dataset.const.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
DatasetType,
|
||||
DatasetStatus,
|
||||
type Dataset,
|
||||
DatasetSubType,
|
||||
DataSource,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
import { formatBytes, formatDateTime } from "@/utils/unit";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
FileOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
FileImage,
|
||||
FileText,
|
||||
Video,
|
||||
FileCode,
|
||||
MessageCircleMore,
|
||||
ImagePlus,
|
||||
FileMusic,
|
||||
Music,
|
||||
Videotape,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
|
||||
export const datasetTypeMap: Record<
|
||||
string,
|
||||
{
|
||||
value: DatasetType;
|
||||
label: string;
|
||||
order: number;
|
||||
description: string;
|
||||
icon?: any;
|
||||
iconColor?: string;
|
||||
children: DatasetSubType[];
|
||||
}
|
||||
> = {
|
||||
[DatasetType.TEXT]: {
|
||||
value: DatasetType.TEXT,
|
||||
label: "文本",
|
||||
order: 1,
|
||||
icon: FileText,
|
||||
iconColor: "#3b82f6",
|
||||
children: [
|
||||
DatasetSubType.TEXT_DOCUMENT,
|
||||
DatasetSubType.TEXT_WEB,
|
||||
DatasetSubType.TEXT_DIALOG,
|
||||
],
|
||||
description: "用于处理和分析文本数据的数据集",
|
||||
},
|
||||
[DatasetType.IMAGE]: {
|
||||
value: DatasetType.IMAGE,
|
||||
label: "图像",
|
||||
order: 2,
|
||||
icon: FileImage,
|
||||
iconColor: "#3b82f6",
|
||||
children: [DatasetSubType.IMAGE_IMAGE, DatasetSubType.IMAGE_CAPTION],
|
||||
description: "用于处理和分析图像数据的数据集",
|
||||
},
|
||||
[DatasetType.AUDIO]: {
|
||||
value: DatasetType.AUDIO,
|
||||
label: "音频",
|
||||
order: 3,
|
||||
icon: Music,
|
||||
iconColor: "#3b82f6",
|
||||
children: [DatasetSubType.AUDIO_AUDIO, DatasetSubType.AUDIO_JSONL],
|
||||
description: "用于处理和分析音频数据的数据集",
|
||||
},
|
||||
[DatasetType.VIDEO]: {
|
||||
value: DatasetType.VIDEO,
|
||||
label: "视频",
|
||||
order: 3,
|
||||
icon: Video,
|
||||
iconColor: "#3b82f6",
|
||||
children: [DatasetSubType.VIDEO_VIDEO, DatasetSubType.VIDEO_JSONL],
|
||||
description: "用于处理和分析视频数据的数据集",
|
||||
},
|
||||
};
|
||||
|
||||
export const datasetSubTypeMap: Record<
|
||||
string,
|
||||
{
|
||||
value: DatasetSubType;
|
||||
label: string;
|
||||
order?: number;
|
||||
description?: string;
|
||||
icon?: any;
|
||||
color?: string;
|
||||
}
|
||||
> = {
|
||||
[DatasetSubType.TEXT_DOCUMENT]: {
|
||||
value: DatasetSubType.TEXT_DOCUMENT,
|
||||
label: "文档",
|
||||
color: "blue",
|
||||
icon: FileText,
|
||||
description: "用于存储和处理各种文档格式的文本数据集",
|
||||
},
|
||||
[DatasetSubType.TEXT_WEB]: {
|
||||
value: DatasetSubType.TEXT_WEB,
|
||||
label: "网页",
|
||||
color: "cyan",
|
||||
icon: FileCode,
|
||||
description: "用于存储和处理网页数据集",
|
||||
},
|
||||
[DatasetSubType.TEXT_DIALOG]: {
|
||||
value: DatasetSubType.TEXT_DIALOG,
|
||||
label: "对话",
|
||||
color: "teal",
|
||||
icon: MessageCircleMore,
|
||||
description: "用于存储和处理对话数据的数据集",
|
||||
},
|
||||
[DatasetSubType.IMAGE_IMAGE]: {
|
||||
value: DatasetSubType.IMAGE_IMAGE,
|
||||
label: "图像",
|
||||
color: "green",
|
||||
icon: FileImage,
|
||||
description: "用于大规模图像预训练模型的数据集",
|
||||
},
|
||||
[DatasetSubType.IMAGE_CAPTION]: {
|
||||
value: DatasetSubType.IMAGE_CAPTION,
|
||||
label: "图像+caption",
|
||||
color: "lightgreen",
|
||||
icon: ImagePlus,
|
||||
description: "用于图像标题生成的数据集",
|
||||
},
|
||||
[DatasetSubType.AUDIO_AUDIO]: {
|
||||
value: DatasetSubType.AUDIO_AUDIO,
|
||||
label: "音频",
|
||||
color: "purple",
|
||||
icon: Music,
|
||||
description: "用于大规模音频预训练模型的数据集",
|
||||
},
|
||||
[DatasetSubType.AUDIO_JSONL]: {
|
||||
value: DatasetSubType.AUDIO_JSONL,
|
||||
label: "音频+JSONL",
|
||||
color: "purple",
|
||||
icon: FileMusic,
|
||||
description: "用于大规模音频预训练模型的数据集",
|
||||
},
|
||||
[DatasetSubType.VIDEO_VIDEO]: {
|
||||
value: DatasetSubType.VIDEO_VIDEO,
|
||||
label: "视频",
|
||||
color: "orange",
|
||||
icon: Video,
|
||||
description: "用于大规模视频预训练模型的数据集",
|
||||
},
|
||||
[DatasetSubType.VIDEO_JSONL]: {
|
||||
value: DatasetSubType.VIDEO_JSONL,
|
||||
label: "视频+JSONL",
|
||||
color: "orange",
|
||||
icon: Videotape,
|
||||
description: "用于大规模视频预训练模型的数据集",
|
||||
},
|
||||
};
|
||||
|
||||
export const datasetStatusMap = {
|
||||
[DatasetStatus.ACTIVE]: {
|
||||
label: "活跃",
|
||||
value: DatasetStatus.ACTIVE,
|
||||
color: "#409f17ff",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
[DatasetStatus.PROCESSING]: {
|
||||
label: "处理中",
|
||||
value: DatasetStatus.PROCESSING,
|
||||
color: "#2673e5",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
[DatasetStatus.INACTIVE]: {
|
||||
label: "未激活",
|
||||
value: DatasetStatus.INACTIVE,
|
||||
color: "#4f4444ff",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
[DatasetStatus.DRAFT]: {
|
||||
label: "草稿",
|
||||
value: DatasetStatus.DRAFT,
|
||||
color: "#a1a1a1ff",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
export const dataSourceMap: Record<string, { label: string; value: string }> = {
|
||||
[DataSource.UPLOAD]: { label: "本地上传", value: DataSource.UPLOAD },
|
||||
[DataSource.COLLECTION]: { label: "本地归集 ", value: DataSource.COLLECTION },
|
||||
// [DataSource.DATABASE]: { label: "数据库导入", value: DataSource.DATABASE },
|
||||
// [DataSource.NAS]: { label: "NAS导入", value: DataSource.NAS },
|
||||
// [DataSource.OBS]: { label: "OBS导入", value: DataSource.OBS },
|
||||
};
|
||||
|
||||
export const dataSourceOptions = Object.values(dataSourceMap);
|
||||
|
||||
export function mapDataset(dataset: Dataset) {
|
||||
const IconComponent = datasetTypeMap[dataset?.datasetType]?.icon || null;
|
||||
return {
|
||||
...dataset,
|
||||
type: datasetTypeMap[dataset.datasetType]?.label || "未知",
|
||||
size: formatBytes(dataset.totalSize || 0),
|
||||
createdAt: formatDateTime(dataset.createdAt) || "--",
|
||||
updatedAt: formatDateTime(dataset?.updatedAt) || "--",
|
||||
icon: IconComponent ? <IconComponent className="w-4 h-4" /> : <Database />,
|
||||
status: datasetStatusMap[dataset.status],
|
||||
statistics: [
|
||||
{ label: "文件数", value: dataset.fileCount || 0 },
|
||||
{ label: "大小", value: formatBytes(dataset.totalSize || 0) },
|
||||
],
|
||||
lastModified: dataset.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const datasetTypes = Object.values(datasetTypeMap).map((type) => ({
|
||||
...type,
|
||||
options: type.children?.map(
|
||||
(subType) => datasetSubTypeMap[subType as keyof typeof datasetSubTypeMap]
|
||||
),
|
||||
}));
|
||||
100
frontend/src/pages/DataManagement/dataset.model.ts
Normal file
100
frontend/src/pages/DataManagement/dataset.model.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export enum DatasetType {
|
||||
TEXT = "TEXT",
|
||||
IMAGE = "IMAGE",
|
||||
AUDIO = "AUDIO",
|
||||
VIDEO = "VIDEO",
|
||||
}
|
||||
|
||||
export enum DatasetSubType {
|
||||
TEXT_DOCUMENT = "TEXT_DOCUMENT",
|
||||
TEXT_WEB = "TEXT_WEB",
|
||||
TEXT_DIALOG = "TEXT_DIALOG",
|
||||
IMAGE_IMAGE = "IMAGE_IMAGE",
|
||||
IMAGE_CAPTION = "IMAGE_CAPTION",
|
||||
AUDIO_AUDIO = "AUDIO_AUDIO",
|
||||
AUDIO_JSONL = "AUDIO_JSONL",
|
||||
VIDEO_VIDEO = "VIDEO_VIDEO",
|
||||
VIDEO_JSONL = "VIDEO_JSONL",
|
||||
}
|
||||
|
||||
export enum DatasetStatus {
|
||||
ACTIVE = "ACTIVE",
|
||||
INACTIVE = "INACTIVE",
|
||||
PROCESSING = "PROCESSING",
|
||||
DRAFT = "DRAFT",
|
||||
}
|
||||
|
||||
export enum DataSource {
|
||||
UPLOAD = "UPLOAD",
|
||||
COLLECTION = "COLLECTION",
|
||||
DATABASE = "DATABASE",
|
||||
NAS = "NAS",
|
||||
OBS = "OBS",
|
||||
}
|
||||
|
||||
export interface DatasetFile {
|
||||
id: number;
|
||||
fileName: string;
|
||||
size: string;
|
||||
uploadDate: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Dataset {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
parentId?: number;
|
||||
datasetType: DatasetType;
|
||||
status: DatasetStatus;
|
||||
size?: string;
|
||||
itemCount?: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
targetLocation?: string;
|
||||
}
|
||||
|
||||
export interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ScheduleConfig {
|
||||
type: "immediate" | "scheduled";
|
||||
scheduleType?: "daily" | "weekly" | "monthly" | "custom";
|
||||
time?: string;
|
||||
dayOfWeek?: string;
|
||||
dayOfMonth?: string;
|
||||
cronExpression?: string;
|
||||
maxExecutions?: number;
|
||||
executionCount?: number;
|
||||
}
|
||||
|
||||
export interface DatasetTask {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: "importing" | "waiting" | "completed" | "failed";
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
importConfig: any;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
nextExecution?: string;
|
||||
lastExecution?: string;
|
||||
executionHistory?: { time: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface TaskItem {
|
||||
key: string;
|
||||
title: string;
|
||||
percent: number;
|
||||
reqId: number;
|
||||
isCancel?: boolean;
|
||||
controller: AbortController;
|
||||
cancelFn?: () => void;
|
||||
}
|
||||
2
frontend/src/pages/DataManagement/hooks/index.ts
Normal file
2
frontend/src/pages/DataManagement/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useFilesOperation } from "./useFilesOperation";
|
||||
export { useImportFile } from "./useImportFile";
|
||||
124
frontend/src/pages/DataManagement/hooks/useFilesOperation.ts
Normal file
124
frontend/src/pages/DataManagement/hooks/useFilesOperation.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
Dataset,
|
||||
DatasetFile,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
import { App } from "antd";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
deleteDatasetFileUsingDelete,
|
||||
downloadFile,
|
||||
exportDatasetUsingPost,
|
||||
queryDatasetFilesUsingGet,
|
||||
} from "../dataset.api";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
export function useFilesOperation(dataset: Dataset) {
|
||||
const { message } = App.useApp();
|
||||
const { id } = useParams(); // 获取动态路由参数
|
||||
|
||||
// 文件相关状态
|
||||
const [fileList, setFileList] = useState<DatasetFile[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
|
||||
|
||||
// 文件预览相关状态
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState("");
|
||||
const [previewFileName, setPreviewFileName] = useState("");
|
||||
|
||||
const fetchFiles = async () => {
|
||||
const { data } = await queryDatasetFilesUsingGet(id!);
|
||||
setFileList(data.content || []);
|
||||
};
|
||||
|
||||
const handleBatchDeleteFiles = () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
message.warning({ content: "请先选择要删除的文件" });
|
||||
return;
|
||||
}
|
||||
// 执行批量删除逻辑
|
||||
selectedFiles.forEach(async (fileId) => {
|
||||
await fetch(`/api/datasets/${dataset.id}/files/${fileId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
fetchFiles(); // 刷新文件列表
|
||||
setSelectedFiles([]); // 清空选中状态
|
||||
message.success({
|
||||
content: `已删除 ${selectedFiles.length} 个文件`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (file: DatasetFile) => {
|
||||
console.log("批量下载文件:", selectedFiles);
|
||||
// 实际导出逻辑
|
||||
await downloadFile(dataset.id, file.id, file.fileName);
|
||||
// 假设导出成功
|
||||
message.success({
|
||||
content: `已导出 1 个文件`,
|
||||
});
|
||||
setSelectedFiles([]); // 清空选中状态
|
||||
};
|
||||
|
||||
const handleShowFile = (file: any) => async () => {
|
||||
// 请求文件内容并弹窗预览
|
||||
try {
|
||||
const res = await fetch(`/api/datasets/${dataset.id}/file/${file.id}`);
|
||||
const data = await res.text();
|
||||
setPreviewFileName(file.fileName);
|
||||
setPreviewContent(data);
|
||||
setPreviewVisible(true);
|
||||
} catch (err) {
|
||||
message.error({ content: "文件预览失败" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (file) => {
|
||||
try {
|
||||
await deleteDatasetFileUsingDelete(dataset.id, file.id);
|
||||
fetchFiles(); // 刷新文件列表
|
||||
message.success({ content: `文件 ${file.fileName} 已删除` });
|
||||
} catch (error) {
|
||||
message.error({ content: `文件 ${file.fileName} 删除失败` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExport = () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
message.warning({ content: "请先选择要导出的文件" });
|
||||
return;
|
||||
}
|
||||
// 执行批量导出逻辑
|
||||
console.log("批量导出文件:", selectedFiles);
|
||||
exportDatasetUsingPost(dataset.id, { fileIds: selectedFiles })
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: `已导出 ${selectedFiles.length} 个文件`,
|
||||
});
|
||||
setSelectedFiles([]); // 清空选中状态
|
||||
})
|
||||
.catch(() => {
|
||||
message.error({
|
||||
content: "导出失败,请稍后再试",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
fileList,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
previewVisible,
|
||||
setPreviewVisible,
|
||||
previewContent,
|
||||
previewFileName,
|
||||
setPreviewContent,
|
||||
setPreviewFileName,
|
||||
fetchFiles,
|
||||
setFileList,
|
||||
handleBatchDeleteFiles,
|
||||
handleDownloadFile,
|
||||
handleShowFile,
|
||||
handleDeleteFile,
|
||||
handleBatchExport,
|
||||
};
|
||||
}
|
||||
61
frontend/src/pages/DataManagement/hooks/useImportFile.tsx
Normal file
61
frontend/src/pages/DataManagement/hooks/useImportFile.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Upload, type UploadFile } from "antd";
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import { sliceFile } from "@/utils/file.util";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
export const useImportFile = () => {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const fileSliceList = useMemo(() => {
|
||||
const sliceList = fileList.map((file) => {
|
||||
const slices = sliceFile(file);
|
||||
return { originFile: file, slices, name: file.name, size: file.size };
|
||||
});
|
||||
return sliceList;
|
||||
}, [fileList]);
|
||||
|
||||
const resetFiles = () => {
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const handleUpload = async (dataset: Dataset) => {
|
||||
const formData = new FormData();
|
||||
fileList.forEach((file) => {
|
||||
formData.append("file", file);
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("upload:dataset", {
|
||||
detail: { dataset, files: fileSliceList },
|
||||
})
|
||||
);
|
||||
resetFiles();
|
||||
};
|
||||
|
||||
const handleBeforeUpload = (_, files: UploadFile[]) => {
|
||||
setFileList([...fileList, ...files]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: UploadFile) => {
|
||||
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
|
||||
};
|
||||
|
||||
const importFileRender = () => (
|
||||
<Dragger
|
||||
className="w-full"
|
||||
onRemove={handleRemoveFile}
|
||||
beforeUpload={handleBeforeUpload}
|
||||
multiple
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">本地文件上传</p>
|
||||
<p className="ant-upload-hint">拖拽文件到此处或点击选择文件</p>
|
||||
</Dragger>
|
||||
);
|
||||
|
||||
return { fileList, resetFiles, handleUpload, importFileRender };
|
||||
};
|
||||
314
frontend/src/pages/Home/Home.tsx
Normal file
314
frontend/src/pages/Home/Home.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
FolderOpen,
|
||||
Settings,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
Target,
|
||||
Zap,
|
||||
Database,
|
||||
MessageSquare,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { features, menuItems } from "../Layout/menu";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Card } from "antd";
|
||||
|
||||
export default function WelcomePage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI数据集准备工具
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
|
||||
构建高质量
|
||||
<span className="text-blue-600"> AI数据集</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto mb-8">
|
||||
从数据管理到知识生成,一站式解决机器学习数据准备的所有需求。
|
||||
支持对话式操作、智能编排、数据合成、智能标注、全面评估和RAG知识库构建。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<span
|
||||
onClick={() => navigate("/data/management")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white shadow-lg"
|
||||
>
|
||||
<Database className="mr-2 w-4 h-4" />
|
||||
开始使用
|
||||
</span>
|
||||
<span
|
||||
onClick={() => navigate("/agent")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg"
|
||||
>
|
||||
<MessageSquare className="mr-2 w-4 h-4" />
|
||||
对话助手
|
||||
</span>
|
||||
<span
|
||||
onClick={() => navigate("/orchestration")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-700 hover:to-amber-700 text-white shadow-lg"
|
||||
>
|
||||
数据智能编排
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-5 gap-6 mb-16">
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="border-0 shadow-lg hover:shadow-xl transition-shadow"
|
||||
>
|
||||
<div className="text-center pb-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<feature.icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-lg">{feature.title}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 text-sm">{feature.description}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Menu Items Grid */}
|
||||
<div className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
功能模块
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{menuItems.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => navigate(`/data/${item.id}`)}
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-0 shadow-md relative overflow-hidden group"
|
||||
>
|
||||
<div className="text-center relative">
|
||||
<div
|
||||
className={`w-16 h-16 ${item.color} rounded-xl flex items-center justify-center mx-auto mb-4 shadow-lg group-hover:scale-110 transition-transform duration-200`}
|
||||
>
|
||||
<item.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2"></div>
|
||||
<div className="text-xl group-hover:text-blue-600 transition-colors">
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm group-hover:text-gray-700 transition-colors">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Orchestration Highlight */}
|
||||
<div className="mb-16">
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-amber-50 border-orange-200 shadow-lg">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-amber-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<GitBranch className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-orange-900 mb-2">
|
||||
数据智能编排 - 可视化流程设计
|
||||
</h3>
|
||||
<p className="text-orange-700">
|
||||
拖拽式设计复杂数据清洗管道,让数据流转更加直观高效
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-6">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-orange-900">
|
||||
🎯 核心功能:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-orange-800">
|
||||
可视化流程设计器
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-orange-800">
|
||||
丰富的数据清洗组件库
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-orange-800">
|
||||
实时流程执行监控
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-orange-900">
|
||||
⚡ 智能特性:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-orange-800">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
自动优化数据流转路径
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-orange-800">
|
||||
<Target className="w-4 h-4 text-orange-500" />
|
||||
智能错误检测和修复建议
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-orange-800">
|
||||
<Sparkles className="w-4 h-4 text-orange-500" />
|
||||
模板化流程快速复用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span
|
||||
onClick={() => navigate("/orchestration")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-700 hover:to-amber-700 text-white shadow-lg"
|
||||
>
|
||||
<GitBranch className="mr-2 w-4 h-4" />
|
||||
开始编排
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Agent Highlight */}
|
||||
<div className="mb-16">
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-purple-200 shadow-lg">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<MessageSquare className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-purple-900 mb-2">
|
||||
Data Agent - 对话式业务操作
|
||||
</h3>
|
||||
<p className="text-purple-700">
|
||||
告别复杂界面,用自然语言完成所有数据集相关业务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-6">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-purple-900">
|
||||
💬 对话示例:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-purple-800">
|
||||
"帮我创建一个图像分类数据集"
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-purple-800">
|
||||
"分析一下数据质量,生成报告"
|
||||
</div>
|
||||
<div className="bg-white/60 rounded-lg p-3 text-sm text-purple-800">
|
||||
"启动合成任务,目标1000条数据"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-purple-900">
|
||||
🚀 智能特性:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-purple-800">
|
||||
<Zap className="w-4 h-4 text-purple-500" />
|
||||
理解复杂需求,自动执行
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-purple-800">
|
||||
<Target className="w-4 h-4 text-purple-500" />
|
||||
提供专业建议和优化方案
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-purple-800">
|
||||
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||
学习使用习惯,个性化服务
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span
|
||||
onClick={() => navigate("/agent")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg"
|
||||
>
|
||||
<MessageSquare className="mr-2 w-4 h-4" />
|
||||
开始对话
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Workflow Showcase */}
|
||||
<div className="mb-16">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200 shadow-lg">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-blue-900 mb-2">
|
||||
完整的数据清洗工作流
|
||||
</h3>
|
||||
<p className="text-blue-700">
|
||||
从原始数据到高质量数据集的全流程解决方案
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<FolderOpen className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">数据收集</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
支持多种数据源导入,包括本地文件、数据库、API等
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-orange-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<GitBranch className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">智能编排</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
可视化设计数据清洗流程,自动化执行复杂任务
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-purple-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">智能处理</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
自动化的数据清洗、标注和质量评估流程
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Target className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-blue-900 mb-2">质量保证</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
全面的质量评估和偏见检测,确保数据集可靠性
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span
|
||||
onClick={() => navigate("/data/management")}
|
||||
className="cursor-pointer rounded px-4 py-2 inline-flex items-center bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg"
|
||||
>
|
||||
<Sparkles className="mr-2 w-4 h-4" />
|
||||
开始构建数据集
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import {
|
||||
mockKnowledgeBases,
|
||||
sliceOperators,
|
||||
vectorDatabases,
|
||||
} from "@/mock/knowledgeBase";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
Switch,
|
||||
Tabs,
|
||||
Divider,
|
||||
Upload,
|
||||
message,
|
||||
Form,
|
||||
} from "antd";
|
||||
import {
|
||||
BookOpen,
|
||||
Database,
|
||||
Brain,
|
||||
Scissors,
|
||||
Split,
|
||||
Upload as UploadIcon,
|
||||
Folder,
|
||||
CheckCircle,
|
||||
File,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
const KnowledgeBaseCreatePage: React.FC = () => {
|
||||
return <DevelopmentInProgress />;
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [knowledgeBases, setKnowledgeBases] =
|
||||
useState<KnowledgeBase[]>(mockKnowledgeBases);
|
||||
const [datasetSearchQuery, setDatasetSearchQuery] = useState("");
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [selectedDatasetFiles, setSelectedDatasetFiles] = useState<
|
||||
{
|
||||
datasetId: string;
|
||||
fileId: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
}[]
|
||||
>([]);
|
||||
const [selectedSliceOperators, setSelectedSliceOperators] = useState<
|
||||
string[]
|
||||
>(["semantic-split", "paragraph-split"]);
|
||||
|
||||
// Form initial values
|
||||
const initialValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "unstructured" as "unstructured" | "structured",
|
||||
embeddingModel: "text-embedding-3-large",
|
||||
llmModel: "gpt-4o",
|
||||
chunkSize: 512,
|
||||
overlap: 50,
|
||||
sliceMethod: "semantic" as
|
||||
| "paragraph"
|
||||
| "length"
|
||||
| "delimiter"
|
||||
| "semantic",
|
||||
delimiter: "",
|
||||
enableQA: true,
|
||||
vectorDatabase: "pinecone",
|
||||
};
|
||||
|
||||
// Dataset file selection helpers
|
||||
const handleDatasetFileToggle = (
|
||||
datasetId: string,
|
||||
file: MockDataset["files"][0]
|
||||
) => {
|
||||
setSelectedDatasetFiles((prev) => {
|
||||
const isSelected = prev.some(
|
||||
(f) => f.datasetId === datasetId && f.fileId === file.id
|
||||
);
|
||||
if (isSelected) {
|
||||
return prev.filter(
|
||||
(f) => !(f.datasetId === datasetId && f.fileId === file.id)
|
||||
);
|
||||
} else {
|
||||
return [...prev, { datasetId, ...file }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllDatasetFiles = (
|
||||
dataset: MockDataset,
|
||||
checked: boolean
|
||||
) => {
|
||||
setSelectedDatasetFiles((prev) => {
|
||||
let newSelectedFiles = [...prev];
|
||||
if (checked) {
|
||||
dataset.files.forEach((file) => {
|
||||
if (
|
||||
!newSelectedFiles.some(
|
||||
(f) => f.datasetId === dataset.id && f.fileId === file.id
|
||||
)
|
||||
) {
|
||||
newSelectedFiles.push({ datasetId: dataset.id, ...file });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newSelectedFiles = newSelectedFiles.filter(
|
||||
(f) => f.datasetId !== dataset.id
|
||||
);
|
||||
}
|
||||
return newSelectedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const isDatasetFileSelected = (datasetId: string, fileId: string) => {
|
||||
return selectedDatasetFiles.some(
|
||||
(f) => f.datasetId === datasetId && f.fileId === fileId
|
||||
);
|
||||
};
|
||||
|
||||
const isAllDatasetFilesSelected = (dataset: MockDataset) => {
|
||||
return dataset.files.every((file) =>
|
||||
isDatasetFileSelected(dataset.id, file.id)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSliceOperatorToggle = (operatorId: string) => {
|
||||
setSelectedSliceOperators((prev) =>
|
||||
prev.includes(operatorId)
|
||||
? prev.filter((id) => id !== operatorId)
|
||||
: [...prev, operatorId]
|
||||
);
|
||||
};
|
||||
|
||||
// 文件上传
|
||||
const handleFileChange = (info: any) => {
|
||||
setUploadedFiles(info.fileList.map((f: any) => f.originFileObj));
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleFinish = (values: any) => {
|
||||
const newKB: KnowledgeBase = {
|
||||
id: Date.now(),
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
type: values.type,
|
||||
status: "importing",
|
||||
fileCount: uploadedFiles.length + selectedDatasetFiles.length,
|
||||
chunkCount: 0,
|
||||
vectorCount: 0,
|
||||
size: "0 MB",
|
||||
progress: 0,
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
lastUpdated: new Date().toISOString().split("T")[0],
|
||||
vectorDatabase: values.vectorDatabase,
|
||||
config: {
|
||||
embeddingModel: values.embeddingModel,
|
||||
llmModel: values.llmModel,
|
||||
chunkSize: values.chunkSize,
|
||||
overlap: values.overlap,
|
||||
sliceMethod: values.sliceMethod,
|
||||
delimiter: values.delimiter,
|
||||
enableQA: values.enableQA,
|
||||
vectorDimension: values.embeddingModel.includes("3-large")
|
||||
? 3072
|
||||
: 1536,
|
||||
sliceOperators: selectedSliceOperators,
|
||||
},
|
||||
files: [
|
||||
...uploadedFiles.map((file) => ({
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
type: file.type.split("/")[1] || "unknown",
|
||||
size: `${(file.size / (1024 * 1024)).toFixed(2)} MB`,
|
||||
status: "processing" as const,
|
||||
chunkCount: 0,
|
||||
progress: 0,
|
||||
uploadedAt: new Date().toISOString().split("T")[0],
|
||||
source: "upload" as const,
|
||||
vectorizationStatus: "pending" as const,
|
||||
})),
|
||||
...selectedDatasetFiles.map((file) => ({
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
status: "processing" as const,
|
||||
chunkCount: 0,
|
||||
progress: 0,
|
||||
uploadedAt: new Date().toISOString().split("T")[0],
|
||||
source: "dataset" as const,
|
||||
datasetId: file.datasetId,
|
||||
vectorizationStatus: "pending" as const,
|
||||
})),
|
||||
],
|
||||
vectorizationHistory: [],
|
||||
};
|
||||
|
||||
setKnowledgeBases([newKB, ...knowledgeBases]);
|
||||
form.resetFields();
|
||||
setUploadedFiles([]);
|
||||
setSelectedDatasetFiles([]);
|
||||
setSelectedSliceOperators(["semantic-split", "paragraph-split"]);
|
||||
setSelectedDatasetId(null);
|
||||
message.success("知识库创建成功!");
|
||||
navigate("/data/knowledge-generation");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate("/data/knowledge-generation")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-bold bg-clip-text">创建知识库</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-y-auto p-2">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2">基本信息</h2>
|
||||
<Form.Item
|
||||
label="知识库名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入知识库名称" }]}
|
||||
>
|
||||
<Input placeholder="输入知识库名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="描述知识库的用途和内容" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item label="知识库类型" name="type">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
onClick={() => form.setFieldValue("type", "unstructured")}
|
||||
type={
|
||||
form.getFieldValue("type") === "unstructured"
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
className="h-auto py-4 flex flex-col items-center gap-2"
|
||||
>
|
||||
<BookOpen className="w-6 h-6" />
|
||||
<p className="font-medium">非结构化知识库</p>
|
||||
<p className="text-xs text-center opacity-80">
|
||||
支持文档、PDF等文件
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => form.setFieldValue("type", "structured")}
|
||||
type={
|
||||
form.getFieldValue("type") === "structured"
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
className="h-auto py-4 flex flex-col items-center gap-2"
|
||||
>
|
||||
<Database className="w-6 h-6" />
|
||||
<p className="font-medium">结构化知识库</p>
|
||||
<p className="text-xs text-center opacity-80">
|
||||
支持问答对、表格数据
|
||||
</p>
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 模型配置 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2 flex items-center gap-2">
|
||||
<Brain className="w-5 h-5" />
|
||||
模型配置
|
||||
</h2>
|
||||
<Form.Item label="嵌入模型" name="embeddingModel">
|
||||
<Select>
|
||||
<Option value="text-embedding-3-large">
|
||||
text-embedding-3-large (推荐)
|
||||
</Option>
|
||||
<Option value="text-embedding-3-small">
|
||||
text-embedding-3-small
|
||||
</Option>
|
||||
<Option value="text-embedding-ada-002">
|
||||
text-embedding-ada-002
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.type !== curr.type || prev.enableQA !== curr.enableQA
|
||||
}
|
||||
noStyle
|
||||
>
|
||||
{() =>
|
||||
form.getFieldValue("type") === "unstructured" &&
|
||||
form.getFieldValue("enableQA") && (
|
||||
<Form.Item label="LLM模型 (用于Q&A生成)" name="llmModel">
|
||||
<Select>
|
||||
<Option value="gpt-4o">GPT-4o (推荐)</Option>
|
||||
<Option value="gpt-4o-mini">GPT-4o Mini</Option>
|
||||
<Option value="gpt-3.5-turbo">GPT-3.5 Turbo</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item label="向量数据库" name="vectorDatabase">
|
||||
<Select>
|
||||
{vectorDatabases.map((db) => (
|
||||
<Option key={db.id} value={db.id}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{db.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{db.description}
|
||||
</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 切片算子配置 */}
|
||||
<Form.Item
|
||||
shouldUpdate={(prev, curr) => prev.type !== curr.type}
|
||||
noStyle
|
||||
>
|
||||
{() =>
|
||||
form.getFieldValue("type") === "unstructured" && (
|
||||
<>
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2 flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5" />
|
||||
切片算子配置
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{sliceOperators.map((operator) => (
|
||||
<div
|
||||
key={operator.id}
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-all ${
|
||||
selectedSliceOperators.includes(operator.id)
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleSliceOperatorToggle(operator.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedSliceOperators.includes(
|
||||
operator.id
|
||||
)}
|
||||
onChange={() =>
|
||||
handleSliceOperatorToggle(operator.id)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{operator.icon}</span>
|
||||
<span className="font-medium text-sm">
|
||||
{operator.name}
|
||||
</span>
|
||||
<span className="ant-badge text-xs">
|
||||
{operator.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
{operator.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 文档分割配置 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2 flex items-center gap-2">
|
||||
<Split className="w-5 h-5" />
|
||||
文档分割配置
|
||||
</h2>
|
||||
<Form.Item label="分割方式" name="sliceMethod">
|
||||
<Select>
|
||||
<Option value="semantic">语义分割 (推荐)</Option>
|
||||
<Option value="paragraph">段落分割</Option>
|
||||
<Option value="length">长度分割</Option>
|
||||
<Option value="delimiter">分隔符分割</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.sliceMethod !== curr.sliceMethod
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
form.getFieldValue("sliceMethod") === "delimiter" && (
|
||||
<Form.Item
|
||||
label="分隔符"
|
||||
name="delimiter"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入分隔符",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="输入分隔符,如 \\n\\n" />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item
|
||||
label="分块大小"
|
||||
name="chunkSize"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入分块大小",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="重叠长度"
|
||||
name="overlap"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入重叠长度",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
label="启用Q&A生成"
|
||||
name="enableQA"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
{/* 数据源选择 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2 flex items-center gap-2">
|
||||
<UploadIcon className="w-5 h-5" />
|
||||
{form.getFieldValue("type") === "structured"
|
||||
? "导入模板文件"
|
||||
: "选择数据源"}
|
||||
</h2>
|
||||
<Tabs
|
||||
defaultActiveKey="upload"
|
||||
items={[
|
||||
{
|
||||
key: "upload",
|
||||
label: "上传文件",
|
||||
children: (
|
||||
<div className="space-y-3">
|
||||
<Upload
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
onChange={handleFileChange}
|
||||
fileList={uploadedFiles.map((file, idx) => ({
|
||||
uid: String(idx),
|
||||
name: file.name,
|
||||
status: "done",
|
||||
originFileObj: file,
|
||||
}))}
|
||||
showUploadList={false}
|
||||
>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center relative cursor-pointer">
|
||||
<UploadIcon className="w-8 h-8 mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{form.getFieldValue("type") === "structured"
|
||||
? "拖拽或点击上传Excel/CSV模板文件"
|
||||
: "拖拽或点击上传文档文件"}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-2 bg-transparent pointer-events-none"
|
||||
disabled
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</Upload>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">已选择文件:</p>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700">
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<li key={index}>{file.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dataset",
|
||||
label: "从数据集选择",
|
||||
children: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="搜索数据集..."
|
||||
value={datasetSearchQuery}
|
||||
onChange={(e) => setDatasetSearchQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={() => setSelectedDatasetId(null)}>
|
||||
重置选择
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 h-80">
|
||||
<div className="col-span-1 border rounded-lg overflow-y-auto p-2 space-y-2">
|
||||
{datasets.length === 0 && (
|
||||
<p className="text-center text-gray-500 py-4 text-sm">
|
||||
无匹配数据集
|
||||
</p>
|
||||
)}
|
||||
{datasets.map((dataset) => (
|
||||
<div
|
||||
key={dataset.id}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer ${
|
||||
selectedDatasetId === dataset.id
|
||||
? "bg-blue-50 border-blue-500"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setSelectedDatasetId(dataset.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="w-5 h-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="font-medium">{dataset.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{dataset.files.length} 个文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedDatasetId === dataset.id && (
|
||||
<CheckCircle className="w-5 h-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 border rounded-lg overflow-y-auto p-2 space-y-2">
|
||||
{!selectedDatasetId ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Folder className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">请选择一个数据集</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-2 border-b pb-2">
|
||||
<Checkbox
|
||||
checked={isAllDatasetFilesSelected(
|
||||
datasets.find(
|
||||
(d) => d.id === selectedDatasetId
|
||||
)!
|
||||
)}
|
||||
onChange={(e) =>
|
||||
handleSelectAllDatasetFiles(
|
||||
datasets.find(
|
||||
(d) => d.id === selectedDatasetId
|
||||
)!,
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
全选 (
|
||||
{
|
||||
datasets.find(
|
||||
(d) => d.id === selectedDatasetId
|
||||
)?.files.length
|
||||
}{" "}
|
||||
个文件)
|
||||
</span>
|
||||
</div>
|
||||
{datasets
|
||||
.find((d) => d.id === selectedDatasetId)
|
||||
?.files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isDatasetFileSelected(
|
||||
selectedDatasetId!,
|
||||
file.id
|
||||
)}
|
||||
onChange={() =>
|
||||
handleDatasetFileToggle(
|
||||
selectedDatasetId!,
|
||||
file
|
||||
)
|
||||
}
|
||||
/>
|
||||
<File className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{file.size} • {file.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDatasetFiles.length > 0 && (
|
||||
<div className="mt-4 text-sm font-medium text-gray-700">
|
||||
已选择数据集文件总数: {selectedDatasetFiles.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button onClick={() => navigate("/data/knowledge-generation")}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
创建知识库
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgeBaseCreatePage;
|
||||
@@ -0,0 +1,672 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
File,
|
||||
Trash2,
|
||||
Save,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
BookOpen,
|
||||
Database,
|
||||
MoreHorizontal,
|
||||
Upload,
|
||||
Zap,
|
||||
StarOff,
|
||||
CheckCircle,
|
||||
VectorSquareIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
Badge,
|
||||
Button,
|
||||
Progress,
|
||||
Input,
|
||||
Modal,
|
||||
message,
|
||||
Card,
|
||||
Breadcrumb,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
} from "antd";
|
||||
import { mockKnowledgeBases } from "@/mock/knowledgeBase";
|
||||
import { useNavigate } from "react-router";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
const KnowledgeBaseDetailPage: React.FC = () => {
|
||||
return <DevelopmentInProgress />;
|
||||
const navigate = useNavigate();
|
||||
const knowledgeBase = mockKnowledgeBases[0];
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
// --- 新增的状态 ---
|
||||
const [fileSearchQuery, setFileSearchQuery] = useState("");
|
||||
const [fileTypeFilter, setFileTypeFilter] = useState<string | null>(null);
|
||||
const [fileStatusFilter, setFileStatusFilter] = useState<string | null>(null);
|
||||
const [fileSortOrder, setFileSortOrder] = useState<
|
||||
"ascend" | "descend" | null
|
||||
>(null);
|
||||
|
||||
// 获取所有类型和状态选项
|
||||
const allFileTypes = Array.from(
|
||||
new Set((knowledgeBase.files ?? []).map((f: KBFile) => f.type))
|
||||
).filter(Boolean);
|
||||
|
||||
const allVectorizationStatuses = [
|
||||
{ label: "全部", value: null },
|
||||
{ label: "已完成", value: "completed" },
|
||||
{ label: "处理中", value: "processing" },
|
||||
{ label: "向量化中", value: "vectorizing" },
|
||||
{ label: "导入中", value: "importing" },
|
||||
{ label: "错误", value: "error" },
|
||||
{ label: "已禁用", value: "disabled" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setFiles(knowledgeBase.files);
|
||||
}, [knowledgeBase]);
|
||||
|
||||
const [showVectorizationDialog, setShowVectorizationDialog] = useState(false);
|
||||
const [showEditFileDialog, setShowEditFileDialog] = useState<KBFile | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// File table logic
|
||||
const handleDeleteFile = (file: KBFile) => {};
|
||||
|
||||
const handleFileSelect = (file: KBFile) => {
|
||||
setShowEditFileDialog(file);
|
||||
};
|
||||
|
||||
const handleStartVectorization = (fileId?: string) => {
|
||||
message.info(fileId ? `开始向量化文件 ${fileId}` : "批量向量化所有文件");
|
||||
// 实际业务逻辑可在此实现
|
||||
};
|
||||
|
||||
const handleDeleteKB = (kb: KnowledgeBase) => {};
|
||||
|
||||
// 状态 Badge 映射
|
||||
function getStatusBadgeVariant(status: string) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "ready":
|
||||
return "success";
|
||||
case "processing":
|
||||
case "vectorizing":
|
||||
return "processing";
|
||||
case "importing":
|
||||
return "warning";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
function getStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "ready":
|
||||
return "已完成";
|
||||
case "processing":
|
||||
return "处理中";
|
||||
case "vectorizing":
|
||||
return "向量化中";
|
||||
case "importing":
|
||||
return "导入中";
|
||||
case "error":
|
||||
return "错误";
|
||||
case "disabled":
|
||||
return "已禁用";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
}
|
||||
function getStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "ready":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case "processing":
|
||||
case "vectorizing":
|
||||
return <RefreshCw className="w-4 h-4 text-blue-500 animate-spin" />;
|
||||
case "importing":
|
||||
return <Upload className="w-4 h-4 text-orange-500" />;
|
||||
case "error":
|
||||
return <Trash2 className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <File className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
const fileColumns = [
|
||||
{
|
||||
title: "文件名",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div style={{ padding: 8 }}>
|
||||
<Input
|
||||
placeholder="搜索文件名"
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={confirm}
|
||||
style={{ width: 188, marginBottom: 8, display: "block" }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={confirm}
|
||||
size="small"
|
||||
style={{ width: 90, marginRight: 8 }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={clearFilters} size="small" style={{ width: 90 }}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: KBFile) =>
|
||||
record.name.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string, file: KBFile) => (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
navigate("/data/knowledge-generation/file-detail/" + file.id)
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
filters: allFileTypes.map((type) => ({
|
||||
text: type,
|
||||
value: type,
|
||||
})),
|
||||
onFilter: (value: string, record: KBFile) => record.type === value,
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "size",
|
||||
key: "size",
|
||||
sorter: (a: KBFile, b: KBFile) => parseFloat(a.size) - parseFloat(b.size),
|
||||
sortOrder: fileSortOrder,
|
||||
},
|
||||
{
|
||||
title: "向量化状态",
|
||||
dataIndex: "vectorizationStatus",
|
||||
key: "vectorizationStatus",
|
||||
filters: allVectorizationStatuses
|
||||
.filter((opt) => opt.value !== null)
|
||||
.map((opt) => ({
|
||||
text: opt.label,
|
||||
value: opt.value,
|
||||
})),
|
||||
onFilter: (value: string, record: KBFile) =>
|
||||
record.vectorizationStatus === value,
|
||||
render: (_: any, file: KBFile) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
status={getStatusBadgeVariant(
|
||||
file.vectorizationStatus || "pending"
|
||||
)}
|
||||
text={getStatusLabel(file.vectorizationStatus || "pending")}
|
||||
/>
|
||||
{file.vectorizationStatus === "processing" && (
|
||||
<div className="w-16">
|
||||
<Progress percent={file.progress} size="small" showInfo={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "来源",
|
||||
dataIndex: "source",
|
||||
key: "source",
|
||||
render: (_: any, file: KBFile) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
status={file.source === "upload" ? "processing" : "default"}
|
||||
text={file.source === "upload" ? "上传" : "数据集"}
|
||||
/>
|
||||
{file.datasetId && (
|
||||
<span className="text-xs text-gray-500">({file.datasetId})</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分块数",
|
||||
dataIndex: "chunkCount",
|
||||
key: "chunkCount",
|
||||
render: (chunkCount: number) => (
|
||||
<span className="font-medium text-gray-900">{chunkCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "上传时间",
|
||||
dataIndex: "uploadedAt",
|
||||
key: "uploadedAt",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
align: "right" as const,
|
||||
render: (_: any, file: KBFile) => (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: "重试",
|
||||
key: "retry",
|
||||
onClick: () => handleStartVectorization(file.id),
|
||||
},
|
||||
{
|
||||
label: "删除",
|
||||
key: "delete",
|
||||
onClick: () => handleDeleteFile(file),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Breadcrumb>
|
||||
<Breadcrumb.Item>
|
||||
<a onClick={() => navigate("/data/knowledge-generation")}>知识库</a>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>{knowledgeBase.name}</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Knowledge Base Header */}
|
||||
<DetailHeader
|
||||
data={{
|
||||
icon:
|
||||
knowledgeBase.type === "structured" ? (
|
||||
<Database className="w-8 h-8" />
|
||||
) : (
|
||||
<BookOpen className="w-8 h-8" />
|
||||
),
|
||||
status: {
|
||||
label: getStatusLabel(knowledgeBase.status),
|
||||
icon: getStatusIcon(knowledgeBase.status),
|
||||
color: getStatusBadgeVariant(knowledgeBase.status),
|
||||
},
|
||||
name: knowledgeBase.name,
|
||||
description: knowledgeBase.description,
|
||||
createdAt: knowledgeBase.createdAt,
|
||||
lastUpdated: knowledgeBase.lastUpdated,
|
||||
}}
|
||||
statistics={[
|
||||
{
|
||||
icon: <File className="w-4 h-4 text-gray-400" />,
|
||||
label: "文件",
|
||||
value: knowledgeBase.fileCount,
|
||||
},
|
||||
{
|
||||
icon: <Layers className="w-4 h-4 text-gray-400" />,
|
||||
label: "分块",
|
||||
value: knowledgeBase.chunkCount?.toLocaleString?.() ?? 0,
|
||||
},
|
||||
{
|
||||
icon: <StarOff className="w-4 h-4 text-gray-400" />,
|
||||
label: "向量",
|
||||
value: knowledgeBase.vectorCount?.toLocaleString?.() ?? 0,
|
||||
},
|
||||
{
|
||||
icon: <Database className="w-4 h-4 text-gray-400" />,
|
||||
label: "大小",
|
||||
value: knowledgeBase.size,
|
||||
},
|
||||
]}
|
||||
operations={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "修改参数配置",
|
||||
icon: <Edit className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
setEditForm(knowledgeBase);
|
||||
setCurrentView("config");
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "vector",
|
||||
label: "向量化管理",
|
||||
icon: <VectorSquareIcon className="w-4 h-4" />,
|
||||
onClick: () => setShowVectorizationDialog(true),
|
||||
},
|
||||
...(knowledgeBase.status === "error"
|
||||
? [
|
||||
{
|
||||
key: "retry",
|
||||
label: "重试",
|
||||
onClick: () => {}, // 填写重试逻辑
|
||||
danger: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "more",
|
||||
label: "更多操作",
|
||||
icon: <MoreHorizontal className="w-4 h-4" />,
|
||||
isDropdown: true,
|
||||
items: [
|
||||
{
|
||||
key: "download",
|
||||
label: "导出",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: "配置",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
onClick: () => handleDeleteKB(knowledgeBase),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Tab Navigation */}
|
||||
<Card>
|
||||
{/* Files Section */}
|
||||
<div className="flex items-center justify-between mb-4 gap-4">
|
||||
<div className="flex-1">
|
||||
<SearchControls
|
||||
searchTerm={fileSearchQuery}
|
||||
onSearchChange={setFileSearchQuery}
|
||||
searchPlaceholder="搜索文件名..."
|
||||
filters={[
|
||||
{
|
||||
key: "status",
|
||||
label: "状态筛选",
|
||||
options: [
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已完成", value: "completed" },
|
||||
{ label: "处理中", value: "processing" },
|
||||
{ label: "向量化中", value: "vectorizing" },
|
||||
{ label: "错误", value: "error" },
|
||||
{ label: "已禁用", value: "disabled" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
onFiltersChange={(filters) => {
|
||||
setFileStatusFilter(filters.status?.[0] || "all");
|
||||
}}
|
||||
showViewToggle={false}
|
||||
/>
|
||||
</div>
|
||||
<Button type="primary">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Files Table */}
|
||||
<Table
|
||||
columns={fileColumns}
|
||||
dataSource={files}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="text-center py-12">
|
||||
<File className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
没有找到文件
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
尝试调整搜索条件或添加新文件
|
||||
</p>
|
||||
<Button type="dashed">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
添加文件
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Vectorization Dialog */}
|
||||
<Modal
|
||||
open={showVectorizationDialog}
|
||||
onCancel={() => setShowVectorizationDialog(false)}
|
||||
footer={null}
|
||||
title="向量化管理"
|
||||
width={700}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-2">当前状态</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>已向量化文件:</span>
|
||||
<span>
|
||||
{
|
||||
knowledgeBase.files.filter(
|
||||
(f) => f.vectorizationStatus === "completed"
|
||||
).length
|
||||
}
|
||||
/{knowledgeBase.files.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>向量总数:</span>
|
||||
<span>
|
||||
{knowledgeBase.vectorCount?.toLocaleString?.() ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>存储大小:</span>
|
||||
<span>{knowledgeBase.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-2">操作选项</h4>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="primary"
|
||||
onClick={() => handleStartVectorization()}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
批量向量化
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => message.info("TODO: 重新向量化全部")}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重新向量化全部
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
danger
|
||||
onClick={() => message.info("TODO: 清空向量数据")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
清空向量数据
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">文件向量化状态</h4>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{knowledgeBase.files.map((file: KBFile) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<File className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{file.chunkCount} 个分块
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
status={getStatusBadgeVariant(
|
||||
file.vectorizationStatus || "pending"
|
||||
)}
|
||||
text={getStatusLabel(
|
||||
file.vectorizationStatus || "pending"
|
||||
)}
|
||||
/>
|
||||
{file.vectorizationStatus === "processing" && (
|
||||
<div className="w-16">
|
||||
<Progress
|
||||
percent={file.progress}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{file.vectorizationStatus !== "completed" && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleStartVectorization(file.id)}
|
||||
>
|
||||
<StarOff className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={() => setShowVectorizationDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Edit File Dialog */}
|
||||
<Modal
|
||||
open={!!showEditFileDialog}
|
||||
onCancel={() => setShowEditFileDialog(null)}
|
||||
title="编辑文件"
|
||||
width={600}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setShowEditFileDialog(null)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
onClick={() => setShowEditFileDialog(null)}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存更改
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block mb-1">文件名</label>
|
||||
<Input value={showEditFileDialog?.name} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1">文件来源</label>
|
||||
<Input
|
||||
value={
|
||||
showEditFileDialog?.source === "upload"
|
||||
? "上传文件"
|
||||
: "数据集文件"
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditFileDialog?.source === "upload" ? (
|
||||
<div className="space-y-3">
|
||||
<label className="block mb-1">更新文件</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<Upload className="w-8 h-8 mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
拖拽或点击上传新版本文件
|
||||
</p>
|
||||
<Button className="mt-2 bg-transparent" disabled>
|
||||
选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label className="block mb-1">数据集文件管理</label>
|
||||
<div className="p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
当前数据集: {showEditFileDialog?.datasetId}
|
||||
</span>
|
||||
<Button size="small">
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
更新数据集文件
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
此文件来自数据集,可以选择更新数据集中的对应文件或切换到其他数据集文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block mb-1">处理选项</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="reprocess" />
|
||||
<span className="text-sm">更新后重新处理分块</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="revectorize" />
|
||||
<span className="text-sm">重新生成向量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgeBaseDetailPage;
|
||||
@@ -0,0 +1,695 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Eye,
|
||||
Edit,
|
||||
Clock,
|
||||
Trash2,
|
||||
Scissors,
|
||||
VideoIcon as Vector,
|
||||
Server,
|
||||
FileText,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Tabs,
|
||||
Modal,
|
||||
Breadcrumb,
|
||||
Tag,
|
||||
} from "antd";
|
||||
import {
|
||||
mockChunks,
|
||||
mockQAPairs,
|
||||
sliceOperators,
|
||||
} from "@/mock/knowledgeBase";
|
||||
import type { KnowledgeBase, KBFile } from "@/pages/KnowledgeGeneration/knowledge-base.model";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
// 状态标签
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ready: "就绪",
|
||||
processing: "处理中",
|
||||
vectorizing: "向量化中",
|
||||
importing: "导入中",
|
||||
error: "错误",
|
||||
disabled: "已禁用",
|
||||
completed: "已完成",
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
ready: "green",
|
||||
processing: "blue",
|
||||
vectorizing: "purple",
|
||||
importing: "orange",
|
||||
error: "blue",
|
||||
disabled: "gray",
|
||||
completed: "green",
|
||||
};
|
||||
return colors[status] || "default";
|
||||
};
|
||||
|
||||
const KnowledgeBaseFileDetail: React.FC = () => {
|
||||
return <DevelopmentInProgress />;
|
||||
const navigate = useNavigate();
|
||||
// 假设通过 props 或路由参数获取 selectedFile/selectedKB
|
||||
const [selectedFile] = useState<KBFile>(
|
||||
mockChunks.length
|
||||
? {
|
||||
id: 1,
|
||||
name: "API文档.pdf",
|
||||
type: "pdf",
|
||||
size: "2.5 MB",
|
||||
status: "completed",
|
||||
chunkCount: mockChunks.length,
|
||||
progress: 100,
|
||||
uploadedAt: "2024-01-22 10:30",
|
||||
source: "upload",
|
||||
vectorizationStatus: "completed",
|
||||
}
|
||||
: ({} as KBFile)
|
||||
);
|
||||
const [selectedKB] = useState<KnowledgeBase>({
|
||||
id: 1,
|
||||
name: "API知识库",
|
||||
description: "",
|
||||
type: "unstructured",
|
||||
status: "ready",
|
||||
fileCount: 1,
|
||||
chunkCount: mockChunks.length,
|
||||
vectorCount: mockChunks.length,
|
||||
size: "2.5 MB",
|
||||
progress: 100,
|
||||
createdAt: "2024-01-22",
|
||||
lastUpdated: "2024-01-22",
|
||||
vectorDatabase: "pinecone",
|
||||
config: {
|
||||
embeddingModel: "text-embedding-3-large",
|
||||
chunkSize: 512,
|
||||
overlap: 50,
|
||||
sliceMethod: "semantic",
|
||||
enableQA: true,
|
||||
vectorDimension: 1536,
|
||||
sliceOperators: ["semantic-split", "paragraph-split"],
|
||||
},
|
||||
files: [],
|
||||
vectorizationHistory: [],
|
||||
});
|
||||
|
||||
const [currentChunkPage, setCurrentChunkPage] = useState(1);
|
||||
const chunksPerPage = 5;
|
||||
const totalPages = Math.ceil(mockChunks.length / chunksPerPage);
|
||||
const startIndex = (currentChunkPage - 1) * chunksPerPage;
|
||||
const currentChunks = mockChunks.slice(
|
||||
startIndex,
|
||||
startIndex + chunksPerPage
|
||||
);
|
||||
|
||||
const [editingChunk, setEditingChunk] = useState<number | null>(null);
|
||||
const [editChunkContent, setEditChunkContent] = useState("");
|
||||
const [chunkDetailModal, setChunkDetailModal] = useState<number | null>(null);
|
||||
const [showSliceTraceDialog, setShowSliceTraceDialog] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const handleEditChunk = (chunkId: number, content: string) => {
|
||||
setEditingChunk(chunkId);
|
||||
setEditChunkContent(content);
|
||||
};
|
||||
|
||||
const handleSaveChunk = (chunkId: number) => {
|
||||
// 实际保存逻辑
|
||||
setEditingChunk(null);
|
||||
setEditChunkContent("");
|
||||
};
|
||||
|
||||
const handleDeleteChunk = (chunkId: number) => {
|
||||
// 实际删除逻辑
|
||||
setEditingChunk(null);
|
||||
setEditChunkContent("");
|
||||
};
|
||||
|
||||
const handleViewChunkDetail = (chunkId: number) => {
|
||||
setChunkDetailModal(chunkId);
|
||||
};
|
||||
|
||||
const renderChunks = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
共 {mockChunks.length} 个分块,第 {startIndex + 1}-
|
||||
{Math.min(startIndex + chunksPerPage, mockChunks.length)} 个
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setCurrentChunkPage(Math.max(1, currentChunkPage - 1))
|
||||
}
|
||||
disabled={currentChunkPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentChunkPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setCurrentChunkPage(Math.min(totalPages, currentChunkPage + 1))
|
||||
}
|
||||
disabled={currentChunkPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{currentChunks.map((chunk) => (
|
||||
<Card key={chunk.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">分块 {chunk.id}</h4>
|
||||
<Tag className="text-xs">
|
||||
{sliceOperators.find(
|
||||
(op) => op.id === chunk.sliceOperator
|
||||
)?.name || chunk.sliceOperator}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{editingChunk === chunk.id ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => handleSaveChunk(chunk.id)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingChunk(null);
|
||||
setEditChunkContent("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleViewChunkDetail(chunk.id)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleEditChunk(chunk.id, chunk.content)
|
||||
}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDeleteChunk(chunk.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed text-gray-700">
|
||||
{editingChunk === chunk.id ? (
|
||||
<Input.TextArea
|
||||
value={editChunkContent}
|
||||
onChange={(e) => setEditChunkContent(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
chunk.content
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span>位置: {chunk.position}</span>
|
||||
<span>Token: {chunk.tokens}</span>
|
||||
{chunk.metadata?.page && (
|
||||
<span>页码: {chunk.metadata.page}</span>
|
||||
)}
|
||||
{chunk.metadata?.section && (
|
||||
<span>章节: {chunk.metadata.section}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: <Link to="/data/knowledge-generation">知识库</Link>,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Link to="/data/knowledge-generation/detail/1">
|
||||
{selectedKB?.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: selectedFile.name,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailHeader
|
||||
data={{
|
||||
id: selectedFile.id,
|
||||
icon: <FileText className="w-8 h-8" />,
|
||||
iconColor: "bg-blue-500 text-blue-600",
|
||||
status: {
|
||||
label: getStatusLabel(selectedFile.status),
|
||||
color: getStatusColor(selectedFile.status),
|
||||
},
|
||||
name: selectedFile.name,
|
||||
description: `${selectedFile.size} • ${
|
||||
selectedFile.chunkCount
|
||||
} 个分块${
|
||||
selectedFile.source === "dataset"
|
||||
? ` • 数据集: ${selectedFile.datasetId}`
|
||||
: ""
|
||||
}`,
|
||||
createdAt: selectedFile.uploadedAt,
|
||||
lastUpdated: selectedFile.uploadedAt,
|
||||
}}
|
||||
statistics={[
|
||||
{
|
||||
icon: <Scissors className="w-4 h-4 text-blue-500" />,
|
||||
label: "分块",
|
||||
value: selectedFile.chunkCount,
|
||||
},
|
||||
{
|
||||
icon: <Vector className="w-4 h-4 text-purple-500" />,
|
||||
label: "向量化状态",
|
||||
value: getStatusLabel(
|
||||
selectedFile.vectorizationStatus || "pending"
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Server className="w-4 h-4 text-green-500" />,
|
||||
label: "文件大小",
|
||||
value: selectedFile.size,
|
||||
},
|
||||
{
|
||||
icon: <Clock className="w-4 h-4 text-gray-500" />,
|
||||
label: "上传时间",
|
||||
value: selectedFile.uploadedAt,
|
||||
},
|
||||
]}
|
||||
operations={[
|
||||
{
|
||||
key: "download",
|
||||
label: "下载",
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
// 下载逻辑
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
// 删除逻辑
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Card>{renderChunks()}</Card>
|
||||
|
||||
{/* Slice Trace Modal */}
|
||||
<Modal
|
||||
open={!!showSliceTraceDialog}
|
||||
onCancel={() => setShowSliceTraceDialog(null)}
|
||||
footer={null}
|
||||
title="知识切片回溯"
|
||||
width={800}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium mb-3">切片处理流程</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
|
||||
1
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">原始文档导入</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
文档: {selectedFile.name}
|
||||
</p>
|
||||
</div>
|
||||
<Badge>完成</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">语义分割算子</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
基于语义相似度智能分割,阈值: 0.7
|
||||
</p>
|
||||
</div>
|
||||
<Badge>完成</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
|
||||
3
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">段落分割算子</p>
|
||||
<p className="text-sm text-gray-600">按段落边界进一步细分</p>
|
||||
</div>
|
||||
<Badge>完成</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-purple-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
|
||||
4
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">向量化处理</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
使用 {selectedKB?.config.embeddingModel} 生成向量
|
||||
</p>
|
||||
</div>
|
||||
<Badge>
|
||||
{selectedFile.vectorizationStatus === "completed"
|
||||
? "完成"
|
||||
: "处理中"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-2">分块信息</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">分块ID:</span>
|
||||
<span>{showSliceTraceDialog}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">父分块:</span>
|
||||
<span>
|
||||
{mockChunks.find((c) => c.id === showSliceTraceDialog)
|
||||
?.parentChunkId || "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Token数:</span>
|
||||
<span>
|
||||
{
|
||||
mockChunks.find((c) => c.id === showSliceTraceDialog)
|
||||
?.tokens
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">创建时间:</span>
|
||||
<span>
|
||||
{
|
||||
mockChunks.find((c) => c.id === showSliceTraceDialog)
|
||||
?.createdAt
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-2">向量信息</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">向量ID:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{
|
||||
mockChunks.find((c) => c.id === showSliceTraceDialog)
|
||||
?.vectorId
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">向量维度:</span>
|
||||
<span>{selectedKB?.config.vectorDimension}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">相似度:</span>
|
||||
<span>
|
||||
{
|
||||
mockChunks.find((c) => c.id === showSliceTraceDialog)
|
||||
?.similarity
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Chunk Detail Modal */}
|
||||
<Modal
|
||||
open={!!chunkDetailModal}
|
||||
onCancel={() => setChunkDetailModal(null)}
|
||||
footer={null}
|
||||
title={`分块详细信息 - 分块 ${chunkDetailModal}`}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="content"
|
||||
items={[
|
||||
{
|
||||
key: "content",
|
||||
label: "内容详情",
|
||||
children: (
|
||||
<div>
|
||||
<div className="font-medium mb-1">分块内容</div>
|
||||
<Input.TextArea
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.content || ""
|
||||
}
|
||||
rows={8}
|
||||
readOnly
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "metadata",
|
||||
label: "元数据",
|
||||
children: (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="font-medium mb-1">位置</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.position || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">Token数量</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.tokens || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">相似度</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.similarity || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">向量维度</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.embedding?.length || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">创建时间</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.createdAt || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">更新时间</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.updatedAt || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">向量ID</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.vectorId || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">切片算子</div>
|
||||
<Input
|
||||
value={
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.sliceOperator || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "qa",
|
||||
label: "Q&A对",
|
||||
children: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">关联的问答对</span>
|
||||
<Button size="small">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加Q&A
|
||||
</Button>
|
||||
</div>
|
||||
{mockQAPairs.map((qa) => (
|
||||
<Card key={qa.id} className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
问题 {qa.id}
|
||||
</span>
|
||||
<p className="text-sm mt-1">{qa.question}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
答案
|
||||
</span>
|
||||
<p className="text-sm mt-1">{qa.answer}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="text" size="small">
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="text" size="small" danger>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "trace",
|
||||
label: "切片回溯",
|
||||
children: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">原始文档</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-green-50 rounded-lg">
|
||||
<Scissors className="w-5 h-5 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">切片算子处理</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
应用算子:{" "}
|
||||
{
|
||||
sliceOperators.find(
|
||||
(op) =>
|
||||
op.id ===
|
||||
mockChunks.find((c) => c.id === chunkDetailModal)
|
||||
?.sliceOperator
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-purple-50 rounded-lg">
|
||||
<Vector className="w-5 h-5 text-purple-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">向量化处理</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
生成 {selectedKB?.config.vectorDimension} 维向量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgeBaseFileDetail;
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useState } from "react";
|
||||
import { Card, Button, Badge, Table, Dropdown } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import {
|
||||
BookOpen,
|
||||
Plus,
|
||||
Upload,
|
||||
Database,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
VideoIcon as Vector,
|
||||
} from "lucide-react";
|
||||
import { mockKnowledgeBases, vectorDatabases } from "@/mock/knowledgeBase";
|
||||
import { useNavigate } from "react-router";
|
||||
import CardView from "@/components/CardView";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
|
||||
|
||||
export default function KnowledgeGenerationPage() {
|
||||
return <DevelopmentInProgress />;
|
||||
const navigate = useNavigate();
|
||||
const [knowledgeBases, setKnowledgeBases] =
|
||||
useState<KnowledgeBase[]>(mockKnowledgeBases);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: mockKnowledgeBases.length,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100"],
|
||||
onChange: (page: number, pageSize?: number) => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || prev.pageSize,
|
||||
}));
|
||||
},
|
||||
onShowSizeChange: (current: number, size: number) => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: current,
|
||||
pageSize: size,
|
||||
}));
|
||||
},
|
||||
});
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [sortBy, setSortBy] = useState<
|
||||
"name" | "size" | "fileCount" | "createdAt"
|
||||
>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filterOptions = [
|
||||
{
|
||||
key: "type",
|
||||
label: "类型",
|
||||
options: [
|
||||
{ label: "非结构化", value: "unstructured" },
|
||||
{ label: "结构化", value: "structured" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
options: [
|
||||
{ label: "就绪", value: "ready" },
|
||||
{ label: "处理中", value: "processing" },
|
||||
{ label: "向量化中", value: "vectorizing" },
|
||||
{ label: "导入中", value: "importing" },
|
||||
{ label: "错误", value: "error" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "名称", value: "name" },
|
||||
{ label: "大小", value: "size" },
|
||||
{ label: "文件数量", value: "fileCount" },
|
||||
{ label: "创建时间", value: "createdAt" },
|
||||
{ label: "修改时间", value: "lastModified" },
|
||||
];
|
||||
|
||||
// Filter and sort logic
|
||||
const filteredData = knowledgeBases.filter((item) => {
|
||||
// Search filter
|
||||
if (
|
||||
searchTerm &&
|
||||
!item.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!item.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== "all" && item.type !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== "all" && item.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort data
|
||||
if (sortBy) {
|
||||
filteredData.sort((a, b) => {
|
||||
let aValue: any = a[sortBy as keyof KnowledgeBase];
|
||||
let bValue: any = b[sortBy as keyof KnowledgeBase];
|
||||
|
||||
if (sortBy === "size") {
|
||||
aValue = Number.parseFloat(aValue.replace(/[^\d.]/g, ""));
|
||||
bValue = Number.parseFloat(bValue.replace(/[^\d.]/g, ""));
|
||||
}
|
||||
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === "asc") {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
case "completed":
|
||||
return <CheckCircle />;
|
||||
case "processing":
|
||||
return <Clock />;
|
||||
case "vectorizing":
|
||||
return <Vector />;
|
||||
case "importing":
|
||||
return <Upload />;
|
||||
case "error":
|
||||
return <XCircle />;
|
||||
case "disabled":
|
||||
return <AlertCircle />;
|
||||
default:
|
||||
return <AlertCircle />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels = {
|
||||
ready: "就绪",
|
||||
processing: "处理中",
|
||||
vectorizing: "向量化中",
|
||||
importing: "导入中",
|
||||
error: "错误",
|
||||
disabled: "已禁用",
|
||||
completed: "已完成",
|
||||
};
|
||||
return labels[status as keyof typeof labels] || status;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
case "completed":
|
||||
return "#389e0d"; // green-500
|
||||
case "processing":
|
||||
case "vectorizing":
|
||||
case "importing":
|
||||
return "#3b82f6"; // blue-600
|
||||
case "error":
|
||||
return "#ef4444"; // red-600
|
||||
case "disabled":
|
||||
return "#6b7280"; // gray-600
|
||||
default:
|
||||
return "#6b7280"; // gray-600
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKB = (kb: KnowledgeBase) => {
|
||||
if (confirm(`确定要删除知识库 "${kb.name}" 吗?此操作不可撤销。`)) {
|
||||
setKnowledgeBases((prev) => prev.filter((k) => k.id !== kb.id));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "知识库",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
fixed: "left" as const,
|
||||
width: 200,
|
||||
render: (_: any, kb: KnowledgeBase) => (
|
||||
<div
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
onClick={() => navigate(`/data/knowledge-generation/detail/${kb.id}`)}
|
||||
>
|
||||
{kb.name}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
render: (type: string) => (
|
||||
<Badge>{type === "structured" ? "结构化" : "非结构化"}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: string) => (
|
||||
<div
|
||||
className={`inline-flex items-center text-white px-2 py-1 rounded text-xs`}
|
||||
style={{ backgroundColor: getStatusColor(status) }}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
<span className="ml-1">{getStatusLabel(status)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "向量数据库",
|
||||
dataIndex: "vectorDatabase",
|
||||
key: "vectorDatabase",
|
||||
render: (vectorDatabase: string) => (
|
||||
<span className="text-sm">
|
||||
{vectorDatabases.find((db) => db.id === vectorDatabase)?.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "文件数",
|
||||
dataIndex: "fileCount",
|
||||
key: "fileCount",
|
||||
render: (fileCount: number) => (
|
||||
<span className="font-medium">{fileCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "向量数",
|
||||
dataIndex: "vectorCount",
|
||||
key: "vectorCount",
|
||||
render: (vectorCount: number) => (
|
||||
<span className="font-medium">{vectorCount?.toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "size",
|
||||
key: "size",
|
||||
render: (size: string) => <span className="font-medium">{size}</span>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
render: (createdAt: string) => (
|
||||
<span className="text-sm text-gray-600">{createdAt}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
fixed: "right" as const,
|
||||
render: (_: any, kb: KnowledgeBase) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: "编辑",
|
||||
key: "edit",
|
||||
},
|
||||
{
|
||||
label: "导出",
|
||||
key: "download",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
label: "删除",
|
||||
key: "delete",
|
||||
danger: true,
|
||||
onClick: () => handleDeleteKB(kb),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
// Main list view
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">知识库管理</h1>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/data/knowledge-generation/create")}
|
||||
icon={<PlusOutlined className="w-4 h-4" />}
|
||||
>
|
||||
创建知识库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Controls */}
|
||||
<SearchControls
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="搜索知识库..."
|
||||
filters={filterOptions}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{viewMode === "card" ? (
|
||||
<CardView
|
||||
data={filteredData.map((kb) => ({
|
||||
id: kb.id,
|
||||
name: kb.name,
|
||||
type: kb.type,
|
||||
icon: kb.type === "structured" ? <Database /> : <BookOpen />,
|
||||
iconColor: "bg-blue-200",
|
||||
status: {
|
||||
label: getStatusLabel(kb.status),
|
||||
icon: getStatusIcon(kb.status),
|
||||
color: getStatusColor(kb.status),
|
||||
},
|
||||
description: kb.description,
|
||||
tags: [],
|
||||
statistics: [
|
||||
{ label: "文件", value: kb.fileCount },
|
||||
{ label: "分块", value: kb.chunkCount },
|
||||
{ label: "向量", value: kb.vectorCount },
|
||||
{ label: "大小", value: kb.size },
|
||||
],
|
||||
lastModified: kb.lastUpdated || kb.createdAt,
|
||||
}))}
|
||||
operations={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
onClick: (item) => {},
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
label: "导出",
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
onClick: (item) =>
|
||||
handleDeleteKB(knowledgeBases.find((kb) => kb.id === item.id)!),
|
||||
},
|
||||
]}
|
||||
onView={(item) =>
|
||||
navigate(`/data/knowledge-generation/detail/${item.id}`)
|
||||
}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
scroll={{ x: "max-content" }}
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
没有找到知识库
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
尝试调整筛选条件或创建新的知识库
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate("/knowledge-generation/create")}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建知识库
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
type: "unstructured" | "structured";
|
||||
status: "processing" | "ready" | "error" | "importing" | "vectorizing";
|
||||
fileCount: number;
|
||||
chunkCount: number;
|
||||
vectorCount: number;
|
||||
size: string;
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
lastUpdated: string;
|
||||
vectorDatabase: string;
|
||||
config: {
|
||||
embeddingModel: string;
|
||||
llmModel?: string;
|
||||
chunkSize: number;
|
||||
overlap: number;
|
||||
sliceMethod: "paragraph" | "length" | "delimiter" | "semantic";
|
||||
delimiter?: string;
|
||||
enableQA: boolean;
|
||||
vectorDimension: number;
|
||||
sliceOperators: string[];
|
||||
};
|
||||
files: KBFile[];
|
||||
vectorizationHistory: VectorizationRecord[];
|
||||
}
|
||||
|
||||
interface KBFile {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
size: string;
|
||||
status: "processing" | "completed" | "error" | "disabled" | "vectorizing";
|
||||
chunkCount: number;
|
||||
progress: number;
|
||||
uploadedAt: string;
|
||||
source: "upload" | "dataset";
|
||||
datasetId?: string;
|
||||
chunks?: Chunk[];
|
||||
vectorizationStatus?: "pending" | "processing" | "completed" | "failed";
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
id: number;
|
||||
content: string;
|
||||
position: number;
|
||||
tokens: number;
|
||||
embedding?: number[];
|
||||
similarity?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
vectorId?: string;
|
||||
sliceOperator?: string;
|
||||
parentChunkId?: number;
|
||||
metadata?: {
|
||||
source: string;
|
||||
page?: number;
|
||||
section?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface VectorizationRecord {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
operation: "create" | "update" | "delete" | "reprocess";
|
||||
fileId: number;
|
||||
fileName: string;
|
||||
chunksProcessed: number;
|
||||
vectorsGenerated: number;
|
||||
status: "success" | "failed" | "partial";
|
||||
duration: string;
|
||||
config: {
|
||||
embeddingModel: string;
|
||||
chunkSize: number;
|
||||
sliceMethod: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SliceOperator {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: "text" | "semantic" | "structure" | "custom";
|
||||
icon: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
23
frontend/src/pages/Layout/MainLayout.tsx
Normal file
23
frontend/src/pages/Layout/MainLayout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { memo } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
const MainLayout = () => {
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col bg-gray-50 min-w-6xl">
|
||||
<div className="w-full h-full flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-auto p-6">
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MainLayout);
|
||||
183
frontend/src/pages/Layout/Sidebar.tsx
Normal file
183
frontend/src/pages/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { memo, useEffect, useState } from "react";
|
||||
import { Button, Menu, Popover } from "antd";
|
||||
import {
|
||||
CloseOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ClipboardList, Sparkles, X } from "lucide-react";
|
||||
import { menuItems } from "@/pages/Layout/menu";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router";
|
||||
import TaskUpload from "./TaskUpload";
|
||||
|
||||
const AsiderAndHeaderLayout = () => {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [activeItem, setActiveItem] = useState<string>("management");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [taskCenterVisible, setTaskCenterVisible] = useState(false);
|
||||
|
||||
// Initialize active item based on current pathname
|
||||
const initActiveItem = () => {
|
||||
for (let index = 0; index < menuItems.length; index++) {
|
||||
const element = menuItems[index];
|
||||
if (element.children) {
|
||||
element.children.forEach((subItem) => {
|
||||
if (pathname.includes(subItem.id)) {
|
||||
setActiveItem(subItem.id);
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else if (pathname.includes(element.id)) {
|
||||
setActiveItem(element.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initActiveItem();
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowTaskPopover = (event: CustomEvent) => {
|
||||
const { show } = event.detail;
|
||||
setTaskCenterVisible(show);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"show:task-popover",
|
||||
handleShowTaskPopover as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"show:task-popover",
|
||||
handleShowTaskPopover as EventListener
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
sidebarOpen ? "w-64" : "w-20"
|
||||
} bg-white border-r border-gray-200 transition-all duration-300 flex flex-col relative`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
{sidebarOpen && (
|
||||
<NavLink to="/" className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">ModelEngine</span>
|
||||
</NavLink>
|
||||
)}
|
||||
<span
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <CloseOutlined /> : <MenuOutlined className="ml-4" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1">
|
||||
<Menu
|
||||
mode="inline"
|
||||
inlineCollapsed={!sidebarOpen}
|
||||
items={menuItems.map((item) => ({
|
||||
key: item.id,
|
||||
label: item.title,
|
||||
icon: item.icon ? <item.icon className="w-4 h-4" /> : null,
|
||||
children: item.children
|
||||
? item.children.map((subItem) => ({
|
||||
key: subItem.id,
|
||||
label: subItem.title,
|
||||
icon: subItem.icon ? (
|
||||
<subItem.icon className="w-4 h-4" />
|
||||
) : null,
|
||||
}))
|
||||
: undefined,
|
||||
}))}
|
||||
selectedKeys={[activeItem]}
|
||||
defaultOpenKeys={["synthesis"]}
|
||||
onClick={({ key }) => {
|
||||
setActiveItem(key);
|
||||
console.log(`/data/${key}`);
|
||||
navigate(`/data/${key}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
{sidebarOpen ? (
|
||||
<div className="space-y-2">
|
||||
<Popover
|
||||
forceRender
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2 border-b border-gray-200 pb-2 mb-2">
|
||||
<h4 className="font-bold">任务中心</h4>
|
||||
<X
|
||||
onClick={() => setTaskCenterVisible(false)}
|
||||
className="cursor-pointer w-4 h-4 text-gray-500 hover:text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
open={taskCenterVisible}
|
||||
content={<TaskUpload />}
|
||||
trigger="click"
|
||||
destroyOnHidden={false}
|
||||
>
|
||||
<Button block onClick={() => setTaskCenterVisible(true)}>
|
||||
任务中心
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button block onClick={() => navigate("/data/settings")}>
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Popover
|
||||
forceRender
|
||||
title="任务中心"
|
||||
open={taskCenterVisible}
|
||||
content={<TaskUpload />}
|
||||
trigger="click"
|
||||
destroyOnHidden={false}
|
||||
>
|
||||
<Button
|
||||
block
|
||||
onClick={() => setTaskCenterVisible(true)}
|
||||
icon={<ClipboardList className="w-4 h-4" />}
|
||||
></Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button block onClick={() => navigate("/data/settings")}>
|
||||
<SettingOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加遮罩层,点击外部区域时关闭 */}
|
||||
{taskCenterVisible && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
console.log("clicked outside");
|
||||
|
||||
setTaskCenterVisible(false);
|
||||
toggleShowTaskPopover(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(AsiderAndHeaderLayout);
|
||||
217
frontend/src/pages/Layout/TaskUpload.tsx
Normal file
217
frontend/src/pages/Layout/TaskUpload.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
cancelUploadUsingPut,
|
||||
preUploadUsingPost,
|
||||
uploadFileChunkUsingPost,
|
||||
} from "@/pages/DataManagement/dataset.api";
|
||||
import { TaskItem } from "@/pages/DataManagement/dataset.model";
|
||||
import { calculateSHA256, checkIsFilesExist } from "@/utils/file.util";
|
||||
import { App, Button, Empty, Progress } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
export default function TaskUpload() {
|
||||
const { message } = App.useApp();
|
||||
const [taskList, setTaskList] = useState<TaskItem[]>([]);
|
||||
const taskListRef = useRef<TaskItem[]>([]); // 用于固定任务顺序
|
||||
|
||||
const createTask = (detail: any = {}) => {
|
||||
const { dataset } = detail;
|
||||
const title = `上传数据集: ${dataset.name} `;
|
||||
const controller = new AbortController();
|
||||
const task: TaskItem = {
|
||||
key: dataset.id,
|
||||
title,
|
||||
percent: 0,
|
||||
reqId: -1,
|
||||
controller,
|
||||
};
|
||||
taskListRef.current = [task, ...taskListRef.current];
|
||||
|
||||
setTaskList(taskListRef.current);
|
||||
return task;
|
||||
};
|
||||
|
||||
const updateTaskList = (task: TaskItem) => {
|
||||
taskListRef.current = taskListRef.current.map((item) =>
|
||||
item.key === task.key ? task : item
|
||||
);
|
||||
setTaskList(taskListRef.current);
|
||||
};
|
||||
|
||||
const removeTask = (task: TaskItem) => {
|
||||
const { key } = task;
|
||||
taskListRef.current = taskListRef.current.filter(
|
||||
(item) => item.key !== key
|
||||
);
|
||||
setTaskList(taskListRef.current);
|
||||
if (task.isCancel && task.cancelFn) {
|
||||
task.cancelFn();
|
||||
}
|
||||
window.dispatchEvent(new Event("update:dataset"));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show:task-popover", { detail: { show: false } })
|
||||
);
|
||||
};
|
||||
|
||||
async function buildFormData({ file, reqId, i, j }) {
|
||||
const formData = new FormData();
|
||||
const { slices, name, size } = file;
|
||||
const checkSum = await calculateSHA256(slices[j]);
|
||||
formData.append("file", slices[j]);
|
||||
formData.append("reqId", reqId.toString());
|
||||
formData.append("fileNo", (i + 1).toString());
|
||||
formData.append("chunkNo", (j + 1).toString());
|
||||
formData.append("fileName", name);
|
||||
formData.append("fileSize", size.toString());
|
||||
formData.append("totalChunkNum", slices.length.toString());
|
||||
formData.append("checkSumHex", checkSum);
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function uploadSlice(task: TaskItem, fileInfo) {
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
const { reqId, key, signal } = task;
|
||||
const { loaded, i, j, files, totalSize } = fileInfo;
|
||||
const formData = await buildFormData({
|
||||
file: files[i],
|
||||
i,
|
||||
j,
|
||||
reqId,
|
||||
});
|
||||
|
||||
let newTask = { ...task };
|
||||
await uploadFileChunkUsingPost(key, formData, {
|
||||
onUploadProgress: (e) => {
|
||||
const loadedSize = loaded + e.loaded;
|
||||
const curPercent = Math.round(loadedSize / totalSize) * 100;
|
||||
newTask = {
|
||||
...newTask,
|
||||
...taskListRef.current.find((item) => item.key === key),
|
||||
percent: curPercent >= 100 ? 99.99 : curPercent,
|
||||
};
|
||||
updateTaskList(newTask);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFile({ task, files, totalSize }) {
|
||||
const { data: reqId } = await preUploadUsingPost(task.key, {
|
||||
totalFileNum: files.length,
|
||||
totalSize,
|
||||
datasetId: task.key,
|
||||
});
|
||||
|
||||
const newTask: TaskItem = {
|
||||
...task,
|
||||
reqId,
|
||||
isCancel: false,
|
||||
cancelFn: () => {
|
||||
task.controller.abort();
|
||||
cancelUploadUsingPut(reqId);
|
||||
window.dispatchEvent(new Event("update:dataset"));
|
||||
},
|
||||
};
|
||||
updateTaskList(newTask);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show:task-popover", { detail: { show: true } })
|
||||
);
|
||||
// 更新数据状态
|
||||
window.dispatchEvent(new Event("update:dataset-status"));
|
||||
|
||||
let loaded = 0;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { slices } = files[i];
|
||||
for (let j = 0; j < slices.length; j++) {
|
||||
await uploadSlice(newTask, {
|
||||
loaded,
|
||||
i,
|
||||
j,
|
||||
files,
|
||||
totalSize,
|
||||
});
|
||||
loaded += slices[j].size;
|
||||
}
|
||||
}
|
||||
removeTask(newTask);
|
||||
}
|
||||
|
||||
const handleUpload = async ({ task, files }) => {
|
||||
const isErrorFile = await checkIsFilesExist(files);
|
||||
if (isErrorFile) {
|
||||
message.error("文件被修改或删除,请重新选择文件上传");
|
||||
removeTask({
|
||||
...task,
|
||||
isCancel: false,
|
||||
...taskListRef.current.find((item) => item.key === task.key),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
|
||||
await uploadFile({ task, files, totalSize });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error("文件上传失败,请稍后重试");
|
||||
removeTask({
|
||||
...task,
|
||||
isCancel: true,
|
||||
...taskListRef.current.find((item) => item.key === task.key),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const uploadHandler = (e: any) => {
|
||||
const { files } = e.detail;
|
||||
const task = createTask(e.detail);
|
||||
handleUpload({ task, files });
|
||||
};
|
||||
window.addEventListener("upload:dataset", uploadHandler);
|
||||
return () => {
|
||||
window.removeEventListener("upload:dataset", uploadHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-90 max-w-90 max-h-96 overflow-y-auto p-2"
|
||||
id="header-task-popover"
|
||||
>
|
||||
{taskList.length > 0 &&
|
||||
taskList.map((task) => (
|
||||
<div key={task.key} className="border-b border-gray-200 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{task.title}</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
disabled={!task?.cancelFn}
|
||||
onClick={() =>
|
||||
removeTask({
|
||||
...task,
|
||||
isCancel: true,
|
||||
...taskListRef.current.find(
|
||||
(item) => item.key === task.key
|
||||
),
|
||||
})
|
||||
}
|
||||
icon={<DeleteOutlined />}
|
||||
></Button>
|
||||
</div>
|
||||
|
||||
<Progress size="small" percent={Number(task.percent.toFixed(2))} />
|
||||
</div>
|
||||
))}
|
||||
{taskList.length === 0 && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无上传任务"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/pages/Layout/menu.tsx
Normal file
115
frontend/src/pages/Layout/menu.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
FolderOpen,
|
||||
Tag,
|
||||
Target,
|
||||
BookOpen,
|
||||
Shuffle,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
GitBranch,
|
||||
Zap,
|
||||
Shield,
|
||||
Database,
|
||||
Store,
|
||||
Merge,
|
||||
} from "lucide-react";
|
||||
|
||||
export const menuItems = [
|
||||
{
|
||||
id: "collection",
|
||||
title: "数据归集",
|
||||
icon: Database,
|
||||
description: "创建、导入和管理数据集",
|
||||
color: "bg-orange-500",
|
||||
},
|
||||
{
|
||||
id: "management",
|
||||
title: "数据管理",
|
||||
icon: FolderOpen,
|
||||
description: "创建、导入和管理数据集",
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
id: "cleansing",
|
||||
title: "数据清洗",
|
||||
icon: GitBranch,
|
||||
description: "数据清洗和预处理",
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
{
|
||||
id: "annotation",
|
||||
title: "数据标注",
|
||||
icon: Tag,
|
||||
description: "对数据进行标注和标记",
|
||||
color: "bg-green-500",
|
||||
},
|
||||
{
|
||||
id: "synthesis",
|
||||
title: "数据合成",
|
||||
icon: Shuffle,
|
||||
description: "智能数据合成和配比",
|
||||
color: "bg-pink-500",
|
||||
children: [
|
||||
{
|
||||
id: "synthesis/task",
|
||||
title: "合成任务",
|
||||
icon: Merge,
|
||||
},
|
||||
{
|
||||
id: "synthesis/ratio-task",
|
||||
title: "配比任务",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "evaluation",
|
||||
title: "数据评估",
|
||||
icon: Target,
|
||||
badge: 4,
|
||||
description: "质量分析、性能评估和偏见检测",
|
||||
color: "bg-indigo-500",
|
||||
},
|
||||
{
|
||||
id: "knowledge-generation",
|
||||
title: "知识生成",
|
||||
icon: BookOpen,
|
||||
description: "面向RAG的知识库构建",
|
||||
color: "bg-teal-500",
|
||||
},
|
||||
{
|
||||
id: "operator-market",
|
||||
title: "算子市场",
|
||||
icon: Store,
|
||||
description: "算子上传与管理",
|
||||
color: "bg-yellow-500",
|
||||
},
|
||||
];
|
||||
|
||||
export const features = [
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: "智能编排",
|
||||
description: "可视化数据清洗流程编排,拖拽式设计复杂的数据清洗管道",
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: "对话助手",
|
||||
description: "通过自然语言对话完成复杂的数据集操作和业务流程",
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: "全面评估",
|
||||
description: "多维度数据质量评估,包含统计分析、性能测试和偏见检测",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "高效处理",
|
||||
description: "完整的数据清洗流水线,从原始数据到可用数据集",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "知识管理",
|
||||
description: "构建面向RAG的知识库,支持智能问答和检索",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Button, Steps } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
TagIcon,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import UploadStep from "./components/UploadStep";
|
||||
import ParsingStep from "./components/ParsingStep";
|
||||
import ConfigureStep from "./components/ConfigureStep";
|
||||
import PreviewStep from "./components/PreviewStep";
|
||||
|
||||
interface ParsedOperatorInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
category: string;
|
||||
modality: string[];
|
||||
type: "preprocessing" | "training" | "inference" | "postprocessing";
|
||||
framework: string;
|
||||
language: string;
|
||||
size: string;
|
||||
dependencies: string[];
|
||||
inputFormat: string[];
|
||||
outputFormat: string[];
|
||||
performance: {
|
||||
accuracy?: number;
|
||||
speed: string;
|
||||
memory: string;
|
||||
};
|
||||
documentation?: string;
|
||||
examples?: string[];
|
||||
}
|
||||
|
||||
export default function OperatorPluginCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [uploadStep, setUploadStep] = useState<
|
||||
"upload" | "parsing" | "configure" | "preview"
|
||||
>("upload");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [parseProgress, setParseProgress] = useState(0);
|
||||
const [parsedInfo, setParsedInfo] = useState<ParsedOperatorInfo | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
// 模拟文件上传
|
||||
const handleFileUpload = useCallback((files: FileList) => {
|
||||
setIsUploading(true);
|
||||
setParseError(null);
|
||||
|
||||
// 模拟文件上传过程
|
||||
setTimeout(() => {
|
||||
const fileArray = Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}));
|
||||
setUploadedFiles(fileArray);
|
||||
setIsUploading(false);
|
||||
setUploadStep("parsing");
|
||||
startParsing();
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
// 模拟解析过程
|
||||
const startParsing = useCallback(() => {
|
||||
setParseProgress(0);
|
||||
const interval = setInterval(() => {
|
||||
setParseProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval);
|
||||
// 模拟解析完成
|
||||
setTimeout(() => {
|
||||
setParsedInfo({
|
||||
name: "图像预处理算子",
|
||||
version: "1.2.0",
|
||||
description:
|
||||
"支持图像缩放、裁剪、旋转、颜色空间转换等常用预处理操作,优化了内存使用和处理速度",
|
||||
author: "当前用户",
|
||||
category: "图像处理",
|
||||
modality: ["image"],
|
||||
type: "preprocessing",
|
||||
framework: "PyTorch",
|
||||
language: "Python",
|
||||
size: "2.3MB",
|
||||
dependencies: [
|
||||
"opencv-python>=4.5.0",
|
||||
"pillow>=8.0.0",
|
||||
"numpy>=1.20.0",
|
||||
],
|
||||
inputFormat: ["jpg", "png", "bmp", "tiff"],
|
||||
outputFormat: ["jpg", "png", "tensor"],
|
||||
performance: {
|
||||
accuracy: 99.5,
|
||||
speed: "50ms/image",
|
||||
memory: "128MB",
|
||||
},
|
||||
documentation:
|
||||
"# 图像预处理算子\n\n这是一个高效的图像预处理算子...",
|
||||
examples: [
|
||||
"from operator import ImagePreprocessor\nprocessor = ImagePreprocessor()\nresult = processor.process(image)",
|
||||
],
|
||||
});
|
||||
setUploadStep("configure");
|
||||
}, 500);
|
||||
return 100;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handlePublish = () => {
|
||||
// 模拟发布过程
|
||||
setUploadStep("preview");
|
||||
setTimeout(() => {
|
||||
alert("算子发布成功!");
|
||||
// 这里可以重置状态或跳转到其他页面
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-auto bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="text" onClick={() => navigate("/data/operator-market")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-bold text-gray-900">上传算子</h1>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<Steps
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
title: "上传文件",
|
||||
icon: <Upload />,
|
||||
},
|
||||
{
|
||||
title: "解析文件",
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
title: "配置标签",
|
||||
icon: <TagIcon />,
|
||||
},
|
||||
{
|
||||
title: "发布完成",
|
||||
icon: <CheckCircle />,
|
||||
},
|
||||
]}
|
||||
current={
|
||||
uploadStep === "upload"
|
||||
? 0
|
||||
: uploadStep === "parsing"
|
||||
? 1
|
||||
: uploadStep === "configure"
|
||||
? 2
|
||||
: 3
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="h-full flex flex-col flex-1 overflow-y-auto p-6 mt-4 bg-white rounded-md shadow">
|
||||
<div className="h-full w-full flex flex-col flex-1 overflow-y-auto">
|
||||
{uploadStep === "upload" && (
|
||||
<UploadStep onUpload={handleFileUpload} isUploading={isUploading} />
|
||||
)}
|
||||
{uploadStep === "parsing" && (
|
||||
<ParsingStep
|
||||
parseProgress={parseProgress}
|
||||
uploadedFiles={uploadedFiles}
|
||||
/>
|
||||
)}
|
||||
{uploadStep === "configure" && (
|
||||
<ConfigureStep
|
||||
setUploadStep={setUploadStep}
|
||||
parseError={parseError}
|
||||
parsedInfo={parsedInfo}
|
||||
/>
|
||||
)}
|
||||
{uploadStep === "preview" && (
|
||||
<PreviewStep setUploadStep={setUploadStep} />
|
||||
)}
|
||||
</div>
|
||||
{uploadStep === "configure" && (
|
||||
<div className="flex justify-end gap-3 mt-8">
|
||||
<Button onClick={() => setUploadStep("upload")}>重新上传</Button>
|
||||
<Button onClick={() => setUploadStep("preview")}>预览</Button>
|
||||
<Button type="primary" onClick={handlePublish}>
|
||||
发布算子
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Alert, Input, Button } from "antd";
|
||||
import { CheckCircle, Plus, TagIcon, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ConfigureStep({ parsedInfo, parseError }) {
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [customTag, setCustomTag] = useState("");
|
||||
const availableTags = [
|
||||
"图像处理",
|
||||
"预处理",
|
||||
"缩放",
|
||||
"裁剪",
|
||||
"旋转",
|
||||
"文本处理",
|
||||
"分词",
|
||||
"中文",
|
||||
"NLP",
|
||||
"医学",
|
||||
"音频处理",
|
||||
"特征提取",
|
||||
"MFCC",
|
||||
"频谱分析",
|
||||
"视频处理",
|
||||
"帧提取",
|
||||
"关键帧",
|
||||
"采样",
|
||||
"多模态",
|
||||
"融合",
|
||||
"深度学习",
|
||||
"注意力机制",
|
||||
"推理加速",
|
||||
"TensorRT",
|
||||
"优化",
|
||||
"GPU",
|
||||
"数据增强",
|
||||
"几何变换",
|
||||
"颜色变换",
|
||||
"噪声",
|
||||
];
|
||||
|
||||
const handleAddCustomTag = () => {
|
||||
if (customTag.trim() && !selectedTags.includes(customTag.trim())) {
|
||||
setSelectedTags([...selectedTags, customTag.trim()]);
|
||||
setCustomTag("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setSelectedTags(selectedTags.filter((tag) => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 解析结果 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||
<h2 className="text-xl font-bold text-gray-900">解析完成</h2>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<Alert
|
||||
message="解析过程中发现问题"
|
||||
description={parseError}
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{parsedInfo && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">基本信息</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
算子名称
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
版本
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.version}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
作者
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.author}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
分类
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.category}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 技术规格 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">技术规格</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
框架
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.framework}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
语言
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.language}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
类型
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.type}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
模态
|
||||
</label>
|
||||
<div className="p-2 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.modality.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<div className="p-3 bg-gray-50 rounded border text-gray-900">
|
||||
{parsedInfo.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 依赖项 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
依赖项
|
||||
</label>
|
||||
<div className="p-3 bg-gray-50 rounded border">
|
||||
<div className="space-y-1">
|
||||
{parsedInfo.dependencies.map((dep, index) => (
|
||||
<div key={index} className="text-sm text-gray-900 font-mono">
|
||||
{dep}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 性能指标 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
性能指标
|
||||
</label>
|
||||
<div className="p-3 bg-gray-50 rounded border space-y-2">
|
||||
{parsedInfo.performance.accuracy && (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">准确率:</span>{" "}
|
||||
{parsedInfo.performance.accuracy}%
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">速度:</span>{" "}
|
||||
{parsedInfo.performance.speed}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">内存:</span>{" "}
|
||||
{parsedInfo.performance.memory}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签配置 */}
|
||||
{/* 预定义标签 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">推荐标签</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
handleRemoveTag(tag);
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag]);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium border transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
? "bg-blue-100 text-blue-800 border-blue-200"
|
||||
: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义标签 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
添加自定义标签
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入自定义标签..."
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
onPressEnter={handleAddCustomTag}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAddCustomTag} disabled={!customTag.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选标签 */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
已选标签 ({selectedTags.length})
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"
|
||||
>
|
||||
<TagIcon className="w-3 h-3" />
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="ml-1 hover:text-blue-600"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Progress } from "antd";
|
||||
import { Settings, FileText, CheckCircle } from "lucide-react";
|
||||
|
||||
export default function ParsingStep({ parseProgress, uploadedFiles }) {
|
||||
return (
|
||||
<div className="text-center py-2">
|
||||
<div className="w-24 h-24 mx-auto mb-6 bg-blue-50 rounded-full flex items-center justify-center">
|
||||
<Settings className="w-12 h-12 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
正在解析算子文件
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
系统正在自动分析您的算子文件,提取配置信息...
|
||||
</p>
|
||||
|
||||
{/* 已上传文件列表 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">已上传文件</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-gray-500" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 解析进度 */}
|
||||
<div className="max-w-md mx-auto">
|
||||
<Progress
|
||||
percent={parseProgress}
|
||||
status="active"
|
||||
strokeColor="#3B82F6"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-600">解析进度: {parseProgress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Button } from "antd";
|
||||
import { CheckCircle, Plus, Eye } from "lucide-react";
|
||||
|
||||
export default function PreviewStep({ setUploadStep }) {
|
||||
return (
|
||||
<div className="text-center py-2">
|
||||
<div className="w-24 h-24 mx-auto mb-6 bg-green-50 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">发布成功!</h2>
|
||||
<p className="text-gray-600 mb-8">您的算子已成功发布到算子市场</p>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button onClick={() => setUploadStep("upload")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
继续上传
|
||||
</Button>
|
||||
<Button type="primary">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
查看算子
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Spin } from "antd";
|
||||
import { Upload, FileText } from "lucide-react";
|
||||
|
||||
export default function UploadStep({ isUploading, onUpload }) {
|
||||
const supportedFormats = [
|
||||
{ ext: ".py", desc: "Python 脚本文件" },
|
||||
{ ext: ".zip", desc: "压缩包文件" },
|
||||
{ ext: ".tar.gz", desc: "压缩包文件" },
|
||||
{ ext: ".whl", desc: "Python Wheel 包" },
|
||||
{ ext: ".yaml", desc: "配置文件" },
|
||||
{ ext: ".yml", desc: "配置文件" },
|
||||
{ ext: ".json", desc: "JSON 配置文件" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-2 w-full text-center">
|
||||
<div className="w-24 h-24 mx-auto mb-6 bg-blue-50 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-12 h-12 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">上传算子文件</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
支持多种格式的算子文件,系统将自动解析配置信息
|
||||
</p>
|
||||
|
||||
{/* 支持的格式 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
支持的文件格式
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{supportedFormats.map((format, index) => (
|
||||
<div key={index} className="p-3 border border-gray-200 rounded-lg">
|
||||
<div className="font-medium text-gray-900">{format.ext}</div>
|
||||
<div className="text-sm text-gray-500">{format.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-8 hover:border-blue-400 transition-colors cursor-pointer"
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
onUpload(files);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = supportedFormats.map((f) => f.ext).join(",");
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) {
|
||||
onUpload(files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<Spin size="large" />
|
||||
<p className="mt-4 text-gray-600">正在上传文件...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-lg text-gray-600 mb-2">
|
||||
拖拽文件到此处或点击选择文件
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
支持单个文件或多个文件同时上传
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, Breadcrumb } from "antd";
|
||||
import {
|
||||
FireOutlined,
|
||||
ShareAltOutlined,
|
||||
StarOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Download, Clock, User } from "lucide-react";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { Link, useParams } from "react-router";
|
||||
import Overview from "./components/Overview";
|
||||
import Install from "./components/Install";
|
||||
import Documentation from "./components/Documentation";
|
||||
import Examples from "./components/Examples";
|
||||
import ChangeLog from "./components/ChangeLog";
|
||||
import Reviews from "./components/Reviews";
|
||||
import { queryOperatorByIdUsingGet } from "../operator.api";
|
||||
import { OperatorI } from "../operator.model";
|
||||
import { mapOperator } from "../operator.const";
|
||||
|
||||
export default function OperatorPluginDetail() {
|
||||
const { id } = useParams(); // 获取动态路由参数
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [operator, setOperator] = useState<OperatorI | null>(null);
|
||||
|
||||
const fetchOperator = async () => {
|
||||
try {
|
||||
const { data } = await queryOperatorByIdUsingGet(id as unknown as number);
|
||||
setOperator(mapOperator(data));
|
||||
} catch (error) {
|
||||
setOperator("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOperator();
|
||||
}, [id]);
|
||||
|
||||
if (!operator) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (operator === "error") {
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Failed to load operator details. Please try again later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟算子数据
|
||||
const mockOperator = {
|
||||
id: 1,
|
||||
name: "图像预处理算子",
|
||||
version: "1.2.0",
|
||||
description:
|
||||
"支持图像缩放、裁剪、旋转、颜色空间转换等常用预处理操作,优化了内存使用和处理速度。这是一个高效、易用的图像预处理工具,适用于各种机器学习和计算机视觉项目。",
|
||||
author: "张三",
|
||||
authorAvatar: "/placeholder-user.jpg",
|
||||
category: "图像处理",
|
||||
modality: ["image"],
|
||||
type: "preprocessing",
|
||||
tags: [
|
||||
"图像处理",
|
||||
"预处理",
|
||||
"缩放",
|
||||
"裁剪",
|
||||
"旋转",
|
||||
"计算机视觉",
|
||||
"深度学习",
|
||||
],
|
||||
createdAt: "2024-01-15",
|
||||
lastModified: "2024-01-23",
|
||||
status: "active",
|
||||
downloads: 1247,
|
||||
usage: 856,
|
||||
stars: 89,
|
||||
framework: "PyTorch",
|
||||
language: "Python",
|
||||
size: "2.3MB",
|
||||
license: "MIT",
|
||||
dependencies: [
|
||||
"opencv-python>=4.5.0",
|
||||
"pillow>=8.0.0",
|
||||
"numpy>=1.20.0",
|
||||
"torch>=1.9.0",
|
||||
"torchvision>=0.10.0",
|
||||
],
|
||||
inputFormat: ["jpg", "png", "bmp", "tiff", "webp"],
|
||||
outputFormat: ["jpg", "png", "tensor", "numpy"],
|
||||
performance: {
|
||||
accuracy: 99.5,
|
||||
speed: "50ms/image",
|
||||
memory: "128MB",
|
||||
throughput: "20 images/sec",
|
||||
},
|
||||
systemRequirements: {
|
||||
python: ">=3.7",
|
||||
memory: ">=2GB RAM",
|
||||
storage: ">=100MB",
|
||||
gpu: "Optional (CUDA support)",
|
||||
},
|
||||
installCommand: "pip install image-preprocessor==1.2.0",
|
||||
documentation: `# 图像预处理算子
|
||||
|
||||
## 概述
|
||||
这是一个高效的图像预处理算子,支持多种常用的图像处理操作。
|
||||
|
||||
## 主要功能
|
||||
- 图像缩放和裁剪
|
||||
- 旋转和翻转
|
||||
- 颜色空间转换
|
||||
- 噪声添加和去除
|
||||
- 批量处理支持
|
||||
|
||||
## 性能特点
|
||||
- 内存优化,支持大图像处理
|
||||
- GPU加速支持
|
||||
- 多线程并行处理
|
||||
- 自动批处理优化`,
|
||||
examples: [
|
||||
{
|
||||
title: "基本使用",
|
||||
code: `from image_preprocessor import ImagePreprocessor
|
||||
|
||||
# 初始化预处理器
|
||||
processor = ImagePreprocessor()
|
||||
|
||||
# 加载图像
|
||||
image = processor.load_image("input.jpg")
|
||||
|
||||
# 执行预处理
|
||||
result = processor.process(
|
||||
image,
|
||||
resize=(224, 224),
|
||||
normalize=True,
|
||||
augment=True
|
||||
)
|
||||
|
||||
# 保存结果
|
||||
processor.save_image(result, "output.jpg")`,
|
||||
},
|
||||
{
|
||||
title: "批量处理",
|
||||
code: `from image_preprocessor import ImagePreprocessor
|
||||
import glob
|
||||
|
||||
processor = ImagePreprocessor()
|
||||
|
||||
# 批量处理图像
|
||||
image_paths = glob.glob("images/*.jpg")
|
||||
results = processor.batch_process(
|
||||
image_paths,
|
||||
resize=(256, 256),
|
||||
crop_center=(224, 224),
|
||||
normalize=True
|
||||
)
|
||||
|
||||
# 保存批量结果
|
||||
for i, result in enumerate(results):
|
||||
processor.save_image(result, f"output_{i}.jpg")`,
|
||||
},
|
||||
{
|
||||
title: "高级配置",
|
||||
code: `from image_preprocessor import ImagePreprocessor, Config
|
||||
|
||||
# 自定义配置
|
||||
config = Config(
|
||||
resize_method="bilinear",
|
||||
color_space="RGB",
|
||||
normalize_mean=[0.485, 0.456, 0.406],
|
||||
normalize_std=[0.229, 0.224, 0.225],
|
||||
augmentation={
|
||||
"rotation": (-15, 15),
|
||||
"brightness": (0.8, 1.2),
|
||||
"contrast": (0.8, 1.2)
|
||||
}
|
||||
)
|
||||
|
||||
processor = ImagePreprocessor(config)
|
||||
result = processor.process(image)`,
|
||||
},
|
||||
],
|
||||
changelog: [
|
||||
{
|
||||
version: "1.2.0",
|
||||
date: "2024-01-23",
|
||||
changes: [
|
||||
"新增批量处理功能",
|
||||
"优化内存使用,减少50%内存占用",
|
||||
"添加GPU加速支持",
|
||||
"修复旋转操作的边界问题",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2024-01-10",
|
||||
changes: [
|
||||
"添加颜色空间转换功能",
|
||||
"支持WebP格式",
|
||||
"改进错误处理机制",
|
||||
"更新文档和示例",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2024-01-01",
|
||||
changes: [
|
||||
"首次发布",
|
||||
"支持基本图像预处理操作",
|
||||
"包含缩放、裁剪、旋转功能",
|
||||
],
|
||||
},
|
||||
],
|
||||
reviews: [
|
||||
{
|
||||
id: 1,
|
||||
user: "李四",
|
||||
avatar: "/placeholder-user.jpg",
|
||||
rating: 5,
|
||||
date: "2024-01-20",
|
||||
comment:
|
||||
"非常好用的图像预处理工具,性能优秀,文档清晰。在我们的项目中大大提高了数据预处理的效率。",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: "王五",
|
||||
avatar: "/placeholder-user.jpg",
|
||||
rating: 4,
|
||||
date: "2024-01-18",
|
||||
comment:
|
||||
"功能很全面,但是希望能添加更多的数据增强选项。整体来说是个不错的工具。",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: "赵六",
|
||||
avatar: "/placeholder-user.jpg",
|
||||
rating: 5,
|
||||
date: "2024-01-15",
|
||||
comment:
|
||||
"安装简单,使用方便,性能表现超出预期。推荐给所有做图像处理的同学。",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: "",
|
||||
value: operator?.downloads?.toLocaleString(),
|
||||
},
|
||||
{
|
||||
icon: <User className="w-4 h-4" />,
|
||||
label: "",
|
||||
value: operator?.author,
|
||||
},
|
||||
{
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
label: "",
|
||||
value: operator?.lastModified,
|
||||
},
|
||||
];
|
||||
|
||||
const operations = [
|
||||
{
|
||||
key: "favorite",
|
||||
label: "收藏",
|
||||
icon: (
|
||||
<StarOutlined
|
||||
className={`w-4 h-4 ${
|
||||
isFavorited ? "fill-yellow-400 text-yellow-400" : ""
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
onClick: () => setIsFavorited(!isFavorited),
|
||||
},
|
||||
{
|
||||
key: "share",
|
||||
label: "分享",
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => {
|
||||
/* 分享逻辑 */
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "report",
|
||||
label: "发布",
|
||||
icon: <FireOutlined />,
|
||||
onClick: () => {
|
||||
/* 发布逻辑 */
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: <Link to="/data/operator-market">算子市场</Link>,
|
||||
href: "/data/operator-market",
|
||||
},
|
||||
{
|
||||
title: operator?.name,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailHeader
|
||||
data={operator}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
/>
|
||||
<Card
|
||||
tabList={[
|
||||
{
|
||||
key: "overview",
|
||||
label: "概览",
|
||||
},
|
||||
{
|
||||
key: "install",
|
||||
label: "安装",
|
||||
},
|
||||
{
|
||||
key: "documentation",
|
||||
label: "文档",
|
||||
},
|
||||
{
|
||||
key: "examples",
|
||||
label: "示例",
|
||||
},
|
||||
{
|
||||
key: "changelog",
|
||||
label: "更新日志",
|
||||
},
|
||||
{
|
||||
key: "reviews",
|
||||
label: "评价",
|
||||
},
|
||||
]}
|
||||
activeTabKey={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
>
|
||||
{activeTab === "overview" && <Overview operator={operator} />}
|
||||
{activeTab === "install" && <Install operator={operator} />}
|
||||
{activeTab === "documentation" && <Documentation operator={operator} />}
|
||||
{activeTab === "examples" && <Examples operator={operator} />}
|
||||
{activeTab === "changelog" && <ChangeLog operator={operator} />}
|
||||
{activeTab === "reviews" && <Reviews operator={operator} />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Card } from "antd";
|
||||
import { Badge, ChevronRight } from "lucide-react";
|
||||
|
||||
export default function ChangeLog({ operator }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{operator.changelog.map((version, index) => (
|
||||
<Card key={index}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
版本 {version.version}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">{version.date}</p>
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-800 border border-blue-200">
|
||||
最新版本
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{version.changes.map((change, changeIndex) => (
|
||||
<li key={changeIndex} className="flex items-start gap-2">
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-700">{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Card } from "antd";
|
||||
|
||||
export default function Documentation({ operator }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<div className="prose max-w-none">
|
||||
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
||||
{operator.documentation}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { copyToClipboard } from "@/utils/unit";
|
||||
import { Card, Button } from "antd";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
export default function Examples({ operator }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{operator.examples.map((example, index) => (
|
||||
<Card key={index}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{example.title}
|
||||
</h3>
|
||||
<Button size="small" onClick={() => copyToClipboard(example.code)}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制代码
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
||||
<pre className="text-sm">
|
||||
<code>{example.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
frontend/src/pages/OperatorMarket/Detail/components/Install.tsx
Normal file
105
frontend/src/pages/OperatorMarket/Detail/components/Install.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Card, Button } from "antd";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
export default function renderInstallTab({ operator }) {
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
// 这里可以添加提示消息
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 安装命令 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">安装命令</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{operator.installCommand}</span>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(operator.installCommand)}
|
||||
className="ml-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 系统要求 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">系统要求</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span className="font-medium text-gray-700">Python 版本</span>
|
||||
<span className="text-gray-900">
|
||||
{operator.systemRequirements.python}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span className="font-medium text-gray-700">内存要求</span>
|
||||
<span className="text-gray-900">
|
||||
{operator.systemRequirements.memory}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span className="font-medium text-gray-700">存储空间</span>
|
||||
<span className="text-gray-900">
|
||||
{operator.systemRequirements.storage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium text-gray-700">GPU 支持</span>
|
||||
<span className="text-gray-900">
|
||||
{operator.systemRequirements.gpu}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 依赖项 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">依赖项</h3>
|
||||
<div className="space-y-2">
|
||||
{operator.dependencies.map((dep, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<span className="font-mono text-sm text-gray-900">{dep}</span>
|
||||
<Button size="small" onClick={() => copyToClipboard(dep)}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 快速开始 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">快速开始</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">1. 安装算子</h4>
|
||||
<div className="bg-gray-900 text-gray-100 p-3 rounded font-mono text-sm">
|
||||
{operator.installCommand}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">2. 导入并使用</h4>
|
||||
<div className="bg-gray-900 text-gray-100 p-3 rounded font-mono text-sm">
|
||||
{`from image_preprocessor import ImagePreprocessor
|
||||
processor = ImagePreprocessor()
|
||||
result = processor.process(image)`}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">3. 查看结果</h4>
|
||||
<p className="text-gray-600">
|
||||
处理后的图像将保存在指定路径,可以直接用于后续的机器学习任务。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
frontend/src/pages/OperatorMarket/Detail/components/Overview.tsx
Normal file
167
frontend/src/pages/OperatorMarket/Detail/components/Overview.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { DescriptionsProps, Card, Descriptions, Tag } from "antd";
|
||||
import { FileText, ImageIcon, Music, Video } from "lucide-react";
|
||||
|
||||
export default function Overview({ operator }) {
|
||||
const getModalityIcon = (modality: string) => {
|
||||
const iconMap = {
|
||||
text: FileText,
|
||||
image: ImageIcon,
|
||||
audio: Music,
|
||||
video: Video,
|
||||
};
|
||||
const IconComponent = iconMap[modality as keyof typeof iconMap] || FileText;
|
||||
return <IconComponent className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const descriptionItems: DescriptionsProps["items"] = [
|
||||
{
|
||||
key: "version",
|
||||
label: "版本",
|
||||
children: operator.version,
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "分类",
|
||||
children: operator.category,
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "语言",
|
||||
children: operator.language,
|
||||
},
|
||||
{
|
||||
key: "modality",
|
||||
label: "模态",
|
||||
children: (
|
||||
<div className="flex items-center gap-2">
|
||||
{operator.modality.map((mod, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-100 rounded text-sm"
|
||||
>
|
||||
{getModalityIcon(mod)}
|
||||
{mod}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "framework",
|
||||
label: "框架",
|
||||
children: operator.framework,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: "类型",
|
||||
children: operator.type,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
label: "大小",
|
||||
children: operator.size,
|
||||
},
|
||||
{
|
||||
key: "license",
|
||||
label: "许可证",
|
||||
children: operator.license,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "创建时间",
|
||||
children: operator.createdAt,
|
||||
},
|
||||
{
|
||||
key: "lastModified",
|
||||
label: "最后修改",
|
||||
children: operator.lastModified,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 基本信息 */}
|
||||
<Card>
|
||||
<Descriptions column={2} title="基本信息" items={descriptionItems} />
|
||||
</Card>
|
||||
|
||||
{/* 标签 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">标签</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{operator.tags.map((tag, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
className="px-3 py-1 bg-blue-50 text-blue-700 border border-blue-200 rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 性能指标 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">性能指标</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{operator.performance.accuracy && (
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{operator.performance.accuracy}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">准确率</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{operator.performance.speed}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">处理速度</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{operator.performance.memory}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">内存使用</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{operator.performance.throughput}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">吞吐量</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 输入输出格式 */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">支持格式</h3>
|
||||
<Descriptions column={2} bordered size="middle">
|
||||
<Descriptions.Item label="输入格式">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{operator.inputFormat.map((format, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-green-50 text-green-700 border border-green-200 rounded text-sm"
|
||||
>
|
||||
.{format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="输出格式">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{operator.outputFormat.map((format, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-50 text-blue-700 border border-blue-200 rounded text-sm"
|
||||
>
|
||||
.{format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Card } from "antd";
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
export default function Reviews({ operator }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 评分统计 */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">4.7</div>
|
||||
<div className="flex items-center justify-center gap-1 mt-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className="w-4 h-4 fill-yellow-400 text-yellow-400"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
基于 {operator.reviews.length} 个评价
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = operator.reviews.filter(
|
||||
(r) => r.rating === rating
|
||||
).length;
|
||||
const percentage = (count / operator.reviews.length) * 100;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 w-8">
|
||||
{rating}星
|
||||
</span>
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-yellow-400 h-2 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 w-8">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 评价列表 */}
|
||||
{operator.reviews.map((review) => (
|
||||
<Card key={review.id}>
|
||||
<div className="flex items-start gap-4">
|
||||
<img
|
||||
src={review.avatar || "/placeholder.svg"}
|
||||
alt={review.user}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{review.user}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${
|
||||
star <= review.rating
|
||||
? "fill-yellow-400 text-yellow-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">{review.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700">{review.comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
frontend/src/pages/OperatorMarket/Home/OperatorMarket.tsx
Normal file
181
frontend/src/pages/OperatorMarket/Home/OperatorMarket.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "antd";
|
||||
import { FilterOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import { useNavigate } from "react-router";
|
||||
import type {
|
||||
CategoryTreeI,
|
||||
OperatorI,
|
||||
} from "@/pages/OperatorMarket/operator.model";
|
||||
import Filters from "./components/Filters";
|
||||
import TagManagement from "@/components/TagManagement";
|
||||
import { ListView } from "./components/List";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
queryCategoryTreeUsingGet,
|
||||
queryOperatorsUsingPost,
|
||||
} from "../operator.api";
|
||||
import { mapOperator } from "../operator.const";
|
||||
|
||||
export default function OperatorMarketPage() {
|
||||
const navigate = useNavigate();
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
|
||||
const filterOptions = [];
|
||||
|
||||
const [selectedFilters, setSelectedFilters] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const [categoriesTree, setCategoriesTree] = useState<CategoryTreeI[]>([]);
|
||||
|
||||
const initCategoriesTree = async () => {
|
||||
const { data } = await queryCategoryTreeUsingGet({ page: 0, size: 1000 });
|
||||
setCategoriesTree(data.content || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initCategoriesTree();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryOperatorsUsingPost, mapOperator);
|
||||
|
||||
const handleViewOperator = (operator: OperatorI) => {
|
||||
navigate(`/data/operator-market/plugin-detail/${operator.id}`);
|
||||
};
|
||||
|
||||
const handleUploadOperator = () => {
|
||||
navigate(`/data/operator-market/create`);
|
||||
};
|
||||
|
||||
const handleUpdateOperator = (operator: OperatorI) => {
|
||||
navigate(`/data/operator-market/edit/${operator.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteTag = (operator: OperatorI) => {
|
||||
// 删除算子逻辑
|
||||
console.log("删除算子", operator);
|
||||
};
|
||||
|
||||
const operations = [
|
||||
{
|
||||
key: "edit",
|
||||
label: "更新算子",
|
||||
onClick: handleUpdateOperator,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除算子",
|
||||
onClick: handleDeleteTag,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(selectedFilters).length === 0) {
|
||||
return;
|
||||
}
|
||||
const filteredIds = Object.values(selectedFilters).reduce(
|
||||
(acc, filter: string[]) => {
|
||||
if (filter.length) {
|
||||
acc.push(...filter.map(Number));
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
fetchData({ categories: filteredIds?.length ? filteredIds : undefined });
|
||||
}, [selectedFilters]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900">算子市场</h1>
|
||||
{/* <div className="flex gap-2">
|
||||
<TagManagement />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleUploadOperator}
|
||||
>
|
||||
上传算子
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 overflow-auto h-full bg-white rounded-lg">
|
||||
<div
|
||||
className={`border-r border-gray-200 transition-all duration-300 ${
|
||||
showFilters
|
||||
? "translate-x-0 w-56"
|
||||
: "-translate-x-full w-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<Filters
|
||||
hideFilter={() => setShowFilters(false)}
|
||||
categoriesTree={categoriesTree}
|
||||
selectedFilters={selectedFilters}
|
||||
setSelectedFilters={setSelectedFilters}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-yellow flex flex-col px-4 my-4">
|
||||
<div className="flex w-full items-top gap-4 border-b border-gray-200 mb-4">
|
||||
{!showFilters && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setShowFilters(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<SearchControls
|
||||
className="mb-4"
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={(keyword) =>
|
||||
setSearchParams({ ...searchParams, keyword })
|
||||
}
|
||||
searchPlaceholder="搜索算子名称、描述..."
|
||||
filters={filterOptions}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
onReload={fetchData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
{tableData.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Boxes className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
没有找到匹配的算子
|
||||
</h3>
|
||||
<p className="text-gray-500">尝试调整筛选条件或搜索关键词</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === "card" ? (
|
||||
<CardView data={tableData} pagination={pagination} />
|
||||
) : (
|
||||
<ListView operators={tableData} pagination={pagination} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
frontend/src/pages/OperatorMarket/Home/components/Filters.tsx
Normal file
179
frontend/src/pages/OperatorMarket/Home/components/Filters.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Button, Checkbox, Tooltip } from "antd";
|
||||
import { FilterOutlined } from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { CategoryI, CategoryTreeI } from "../../operator.model";
|
||||
|
||||
interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
icon?: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
total: number;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
showIcons?: boolean;
|
||||
badgeColor?: string;
|
||||
}
|
||||
|
||||
const FilterSection: React.FC<FilterSectionProps> = ({
|
||||
total,
|
||||
title,
|
||||
options,
|
||||
selectedValues,
|
||||
onSelectionChange,
|
||||
showIcons = false,
|
||||
}) => {
|
||||
const handleCheckboxChange = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onSelectionChange([...selectedValues, value]);
|
||||
} else {
|
||||
onSelectionChange(selectedValues.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
// 全选功能
|
||||
const isAllSelected =
|
||||
options.length > 0 && selectedValues.length === options.length;
|
||||
const isIndeterminate =
|
||||
selectedValues.length > 0 && selectedValues.length < options.length;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// 全选
|
||||
onSelectionChange(options.map((option) => option.key));
|
||||
} else {
|
||||
// 全不选
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900">{title}</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
{/* 全选选项 */}
|
||||
{options.length > 1 && (
|
||||
<label className="flex items-center space-x-2 cursor-pointer border-b border-gray-100 pb-1 ">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-1 ml-1">
|
||||
<span className="text-gray-600 font-medium">全选</span>
|
||||
</div>
|
||||
<span className="text-gray-400">({total})</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 各个选项 */}
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(option.key)}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange(option.key, e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-1 ml-1">
|
||||
{showIcons && option.icon}
|
||||
<span className={`text-gray-700 ${option.color || ""}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FiltersProps {
|
||||
categoriesTree: CategoryTreeI[];
|
||||
selectedFilters: { [key: string]: string[] };
|
||||
hideFilter: () => void;
|
||||
setSelectedFilters: (filters: { [key: string]: string[] }) => void;
|
||||
}
|
||||
|
||||
const Filters: React.FC<FiltersProps> = ({
|
||||
categoriesTree,
|
||||
selectedFilters,
|
||||
hideFilter,
|
||||
setSelectedFilters,
|
||||
}) => {
|
||||
const clearAllFilters = () => {
|
||||
const newFilters = Object.keys(selectedFilters).reduce((acc, key) => {
|
||||
acc[key] = [];
|
||||
return acc;
|
||||
}, {} as { [key: string]: string[] });
|
||||
setSelectedFilters(newFilters);
|
||||
};
|
||||
|
||||
console.log(categoriesTree);
|
||||
|
||||
const hasActiveFilters = Object.values(selectedFilters).some(
|
||||
(filters) => Array.isArray(filters) && filters.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-y-auto">
|
||||
{/* Filter Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
<Tooltip title="隐藏筛选器">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FilterOutlined />}
|
||||
onClick={hideFilter}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
筛选器
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
onClick={clearAllFilters}
|
||||
className="cursor-pointer text-sm text-gray-500 hover:text-blue-500"
|
||||
>
|
||||
清除
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Sections */}
|
||||
{categoriesTree.map((category: CategoryTreeI) => (
|
||||
<FilterSection
|
||||
key={category.id}
|
||||
total={category.count}
|
||||
title={category.name}
|
||||
options={category.categories.map((cat: CategoryI) => ({
|
||||
key: cat.id.toString(),
|
||||
label: cat.name,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedValues={selectedFilters[category.id] || []}
|
||||
onSelectionChange={(values) =>
|
||||
setSelectedFilters({ ...selectedFilters, [category.id]: values })
|
||||
}
|
||||
showIcons={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
150
frontend/src/pages/OperatorMarket/Home/components/List.tsx
Normal file
150
frontend/src/pages/OperatorMarket/Home/components/List.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Button, Avatar, List, Tag, Badge } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, StarFilled } from "@ant-design/icons";
|
||||
import { Brain, Code, Cpu, Package, Zap, Settings, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Operator } from "../../operator.model";
|
||||
|
||||
export function ListView({ operators, pagination }) {
|
||||
const navigate = useNavigate();
|
||||
const [favoriteOperators, setFavoriteOperators] = useState<Set<number>>(
|
||||
new Set([1, 3, 6])
|
||||
);
|
||||
const handleUpdateOperator = (operator: Operator) => {
|
||||
navigate(`/data/operator-market/create/${operator.id}`);
|
||||
};
|
||||
const handleViewOperator = (operator: Operator) => {
|
||||
navigate(`/data/operator-market/plugin-detail/${operator.id}`);
|
||||
};
|
||||
const handleToggleFavorite = (operatorId: number) => {
|
||||
setFavoriteOperators((prev) => {
|
||||
const newFavorites = new Set(prev);
|
||||
if (newFavorites.has(operatorId)) {
|
||||
newFavorites.delete(operatorId);
|
||||
} else {
|
||||
newFavorites.add(operatorId);
|
||||
}
|
||||
return newFavorites;
|
||||
});
|
||||
};
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
active: {
|
||||
label: "活跃",
|
||||
color: "green",
|
||||
icon: <Zap className="w-3 h-3" />,
|
||||
},
|
||||
beta: {
|
||||
label: "测试版",
|
||||
color: "blue",
|
||||
icon: <Settings className="w-3 h-3" />,
|
||||
},
|
||||
deprecated: {
|
||||
label: "已弃用",
|
||||
color: "gray",
|
||||
icon: <X className="w-3 h-3" />,
|
||||
},
|
||||
};
|
||||
return (
|
||||
statusConfig[status as keyof typeof statusConfig] || statusConfig.active
|
||||
);
|
||||
};
|
||||
const getTypeIcon = (type: string) => {
|
||||
const iconMap = {
|
||||
preprocessing: Code,
|
||||
training: Brain,
|
||||
inference: Cpu,
|
||||
postprocessing: Package,
|
||||
};
|
||||
const IconComponent = iconMap[type as keyof typeof iconMap] || Code;
|
||||
return <IconComponent className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<List
|
||||
className="p-4 overflow-auto mx-4"
|
||||
dataSource={operators}
|
||||
pagination={pagination}
|
||||
renderItem={(operator) => (
|
||||
<List.Item
|
||||
className="hover:bg-gray-50 transition-colors px-6 py-4"
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => handleUpdateOperator(operator)}
|
||||
icon={<EditOutlined className="w-4 h-4" />}
|
||||
title="更新算子"
|
||||
/>,
|
||||
<Button
|
||||
key="favorite"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => handleToggleFavorite(operator.id)}
|
||||
className={
|
||||
favoriteOperators.has(operator.id)
|
||||
? "text-yellow-500 hover:text-yellow-600"
|
||||
: "text-gray-400 hover:text-yellow-500"
|
||||
}
|
||||
icon={
|
||||
<StarFilled
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
color: favoriteOperators.has(operator.id)
|
||||
? "#ffcc00ff"
|
||||
: "#d1d5db",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleToggleFavorite(operator.id)}
|
||||
/>
|
||||
}
|
||||
title="收藏"
|
||||
/>,
|
||||
<Button
|
||||
key="delete"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined className="w-4 h-4" />}
|
||||
title="删除算子"
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg flex items-center justify-center">
|
||||
{operator?.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
onClick={() => handleViewOperator(operator)}
|
||||
>
|
||||
{operator.name}
|
||||
</span>
|
||||
<Tag color="default">v{operator.version}</Tag>
|
||||
<Badge color={getStatusBadge(operator.status).color}>
|
||||
{getStatusBadge(operator.status).label}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<div className="text-gray-600 ">{operator.description}</div>
|
||||
{/* <div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>作者: {operator.author}</span>
|
||||
<span>类型: {operator.type}</span>
|
||||
<span>框架: {operator.framework}</span>
|
||||
<span>使用次数: {operator?.usage?.toLocaleString()}</span>
|
||||
</div> */}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
frontend/src/pages/OperatorMarket/OperatorPluginEdit.tsx
Normal file
26
frontend/src/pages/OperatorMarket/OperatorPluginEdit.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
|
||||
import { Button } from "antd";
|
||||
import OperatorUpload from "@/app/(layout)/operator-market/components/operator-upload";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function OperatorUpdatePage() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={() => router.push("/operator-market")} className="flex items-center gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">更新算子</h1>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<OperatorUpload />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
frontend/src/pages/OperatorMarket/operator.api.ts
Normal file
243
frontend/src/pages/OperatorMarket/operator.api.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { get, post, put, del, download } from "@/utils/request";
|
||||
|
||||
// 算子列表查询
|
||||
export function queryOperatorsUsingPost(data: any) {
|
||||
return post("/api/operators/list", data);
|
||||
}
|
||||
|
||||
// 获取算子分类树
|
||||
export function queryCategoryTreeUsingGet() {
|
||||
return get("/api/categories/tree");
|
||||
}
|
||||
|
||||
// 根据ID获取算子详情
|
||||
export function queryOperatorByIdUsingGet(operatorId: string | number) {
|
||||
return get(`/api/operators/${operatorId}`);
|
||||
}
|
||||
|
||||
// 创建算子
|
||||
export function createOperatorUsingPost(data: any) {
|
||||
return post("/api/operators/create", data);
|
||||
}
|
||||
|
||||
// 更新算子
|
||||
export function updateOperatorByIdUsingPut(operatorId: string | number, data: any) {
|
||||
return put(`/api/operators/${operatorId}`, data);
|
||||
}
|
||||
|
||||
// 删除算子
|
||||
export function deleteOperatorByIdUsingDelete(operatorId: string | number) {
|
||||
return del(`/api/operators/${operatorId}`);
|
||||
}
|
||||
|
||||
// 上传算子
|
||||
export function uploadOperatorUsingPost(data: any) {
|
||||
return post("/api/operators/upload", data);
|
||||
}
|
||||
|
||||
// 发布算子
|
||||
export function publishOperatorUsingPost(operatorId: string | number) {
|
||||
return post(`/api/operators/${operatorId}/publish`);
|
||||
}
|
||||
|
||||
// 下架算子
|
||||
export function unpublishOperatorUsingPost(operatorId: string | number) {
|
||||
return post(`/api/operators/${operatorId}/unpublish`);
|
||||
}
|
||||
|
||||
// 算子标签管理
|
||||
export function queryLabelsUsingGet(params?: any) {
|
||||
return get("/api/labels", params);
|
||||
}
|
||||
|
||||
// 创建算子标签
|
||||
export function createLabelUsingPost(data: any) {
|
||||
return post("/api/operators/labels", data);
|
||||
}
|
||||
|
||||
// 更新算子标签
|
||||
export function updateLabelByIdUsingPut(labelId: string | number, data: any) {
|
||||
return put(`/api/labels/${labelId}`, data);
|
||||
}
|
||||
|
||||
// 删除算子标签
|
||||
export function deleteLabelsUsingDelete(labelIds: string[]) {
|
||||
return del("/api/labels", labelIds);
|
||||
}
|
||||
|
||||
// 创建算子分类
|
||||
export function createCategoryUsingPost(data: any) {
|
||||
return post("/api/category", data);
|
||||
}
|
||||
|
||||
// 删除算子分类
|
||||
export function deleteCategoryUsingDelete(data: { id: string | number }) {
|
||||
return del("/api/category", data);
|
||||
}
|
||||
|
||||
// 扩展功能接口(基于常见需求)
|
||||
|
||||
// 收藏/取消收藏算子
|
||||
export function starOperatorUsingPost(operatorId: string | number) {
|
||||
return post(`/api/operators/${operatorId}/star`);
|
||||
}
|
||||
|
||||
// 下载算子
|
||||
export function downloadOperatorUsingGet(operatorId: string | number, filename?: string) {
|
||||
return download(`/api/operators/${operatorId}/download`, null, filename);
|
||||
}
|
||||
|
||||
// 算子评分
|
||||
export function rateOperatorUsingPost(operatorId: string | number, data: { rating: number; comment?: string }) {
|
||||
return post(`/api/operators/${operatorId}/rating`, data);
|
||||
}
|
||||
|
||||
// 获取算子统计信息
|
||||
export function getOperatorStatisticsUsingGet(params?: any) {
|
||||
return get("/api/operators/statistics", params);
|
||||
}
|
||||
|
||||
// 获取我的算子列表
|
||||
export function queryMyOperatorsUsingPost(data: any) {
|
||||
return post("/api/operators/my-operators", data);
|
||||
}
|
||||
|
||||
// 获取收藏的算子列表
|
||||
export function queryFavoriteOperatorsUsingPost(data: any) {
|
||||
return post("/api/operators/favorites", data);
|
||||
}
|
||||
|
||||
// 算子使用统计
|
||||
export function getOperatorUsageStatsUsingGet(operatorId: string | number) {
|
||||
return get(`/api/operators/${operatorId}/usage-stats`);
|
||||
}
|
||||
|
||||
// 算子依赖检查
|
||||
export function checkOperatorDependenciesUsingPost(operatorId: string | number) {
|
||||
return post(`/api/operators/${operatorId}/check-dependencies`);
|
||||
}
|
||||
|
||||
// 算子兼容性检查
|
||||
export function checkOperatorCompatibilityUsingPost(operatorId: string | number, data: any) {
|
||||
return post(`/api/operators/${operatorId}/check-compatibility`, data);
|
||||
}
|
||||
|
||||
// 克隆算子
|
||||
export function cloneOperatorUsingPost(operatorId: string | number, data: any) {
|
||||
return post(`/api/operators/${operatorId}/clone`, data);
|
||||
}
|
||||
|
||||
// 获取算子版本列表
|
||||
export function queryOperatorVersionsUsingGet(operatorId: string | number, params?: any) {
|
||||
return get(`/api/operators/${operatorId}/versions`, params);
|
||||
}
|
||||
|
||||
// 创建算子版本
|
||||
export function createOperatorVersionUsingPost(operatorId: string | number, data: any) {
|
||||
return post(`/api/operators/${operatorId}/versions`, data);
|
||||
}
|
||||
|
||||
// 切换算子版本
|
||||
export function switchOperatorVersionUsingPut(operatorId: string | number, versionId: string | number) {
|
||||
return put(`/api/operators/${operatorId}/versions/${versionId}/switch`);
|
||||
}
|
||||
|
||||
// 删除算子版本
|
||||
export function deleteOperatorVersionUsingDelete(operatorId: string | number, versionId: string | number) {
|
||||
return del(`/api/operators/${operatorId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
// 算子测试
|
||||
export function testOperatorUsingPost(operatorId: string | number, data: any) {
|
||||
return post(`/api/operators/${operatorId}/test`, data);
|
||||
}
|
||||
|
||||
// 获取算子测试结果
|
||||
export function getOperatorTestResultUsingGet(operatorId: string | number, testId: string | number) {
|
||||
return get(`/api/operators/${operatorId}/test/${testId}/result`);
|
||||
}
|
||||
|
||||
// 算子审核相关
|
||||
export function submitOperatorForReviewUsingPost(operatorId: string | number) {
|
||||
return post(`/api/operators/${operatorId}/submit-review`);
|
||||
}
|
||||
|
||||
export function approveOperatorUsingPost(operatorId: string | number, data?: any) {
|
||||
return post(`/api/operators/${operatorId}/approve`, data);
|
||||
}
|
||||
|
||||
export function rejectOperatorUsingPost(operatorId: string | number, data: { reason: string }) {
|
||||
return post(`/api/operators/${operatorId}/reject`, data);
|
||||
}
|
||||
|
||||
// 获取算子评论列表
|
||||
export function queryOperatorCommentsUsingGet(operatorId: string | number, params?: any) {
|
||||
return get(`/api/operators/${operatorId}/comments`, params);
|
||||
}
|
||||
|
||||
// 添加算子评论
|
||||
export function addOperatorCommentUsingPost(operatorId: string | number, data: any) {
|
||||
return post(`/api/operators/${operatorId}/comments`, data);
|
||||
}
|
||||
|
||||
// 删除算子评论
|
||||
export function deleteOperatorCommentUsingDelete(operatorId: string | number, commentId: string | number) {
|
||||
return del(`/api/operators/${operatorId}/comments/${commentId}`);
|
||||
}
|
||||
|
||||
// 搜索算子
|
||||
export function searchOperatorsUsingPost(data: any) {
|
||||
return post("/api/operators/search", data);
|
||||
}
|
||||
|
||||
// 获取热门算子
|
||||
export function queryPopularOperatorsUsingGet(params?: any) {
|
||||
return get("/api/operators/popular", params);
|
||||
}
|
||||
|
||||
// 获取推荐算子
|
||||
export function queryRecommendedOperatorsUsingGet(params?: any) {
|
||||
return get("/api/operators/recommended", params);
|
||||
}
|
||||
|
||||
// 获取最新算子
|
||||
export function queryLatestOperatorsUsingGet(params?: any) {
|
||||
return get("/api/operators/latest", params);
|
||||
}
|
||||
|
||||
// 算子使用示例
|
||||
export function getOperatorExamplesUsingGet(operatorId: string | number) {
|
||||
return get(`/api/operators/${operatorId}/examples`);
|
||||
}
|
||||
|
||||
// 创建算子使用示例
|
||||
export function createOperatorExampleUsingPost(operatorId: string | number, data: any) {
|
||||
return post(`/api/operators/${operatorId}/examples`, data);
|
||||
}
|
||||
|
||||
// 算子文档
|
||||
export function getOperatorDocumentationUsingGet(operatorId: string | number) {
|
||||
return get(`/api/operators/${operatorId}/documentation`);
|
||||
}
|
||||
|
||||
// 更新算子文档
|
||||
export function updateOperatorDocumentationUsingPut(operatorId: string | number, data: any) {
|
||||
return put(`/api/operators/${operatorId}/documentation`, data);
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
export function batchDeleteOperatorsUsingPost(data: { operatorIds: string[] }) {
|
||||
return post("/api/operators/batch-delete", data);
|
||||
}
|
||||
|
||||
export function batchUpdateOperatorsUsingPost(data: any) {
|
||||
return post("/api/operators/batch-update", data);
|
||||
}
|
||||
|
||||
export function batchPublishOperatorsUsingPost(data: { operatorIds: string[] }) {
|
||||
return post("/api/operators/batch-publish", data);
|
||||
}
|
||||
|
||||
export function batchUnpublishOperatorsUsingPost(data: { operatorIds: string[] }) {
|
||||
return post("/api/operators/batch-unpublish", data);
|
||||
}
|
||||
9
frontend/src/pages/OperatorMarket/operator.const.tsx
Normal file
9
frontend/src/pages/OperatorMarket/operator.const.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Boxes } from "lucide-react";
|
||||
import { OperatorI } from "./operator.model";
|
||||
|
||||
export const mapOperator = (op: OperatorI) => {
|
||||
return {
|
||||
...op,
|
||||
icon: <Boxes className="w-5 h-5 text-gray-500" />,
|
||||
};
|
||||
};
|
||||
55
frontend/src/pages/OperatorMarket/operator.model.ts
Normal file
55
frontend/src/pages/OperatorMarket/operator.model.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface ConfigI {
|
||||
type:
|
||||
| "input"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "range"
|
||||
| "slider"
|
||||
| "inputNumber"
|
||||
| "switch"
|
||||
| "multiple";
|
||||
value?: number | string | boolean | string[] | number[];
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
key: string;
|
||||
defaultVal: number | string | boolean | string[];
|
||||
options?: string[] | { label: string; value: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
properties?: ConfigI[]; // 用于嵌套配置
|
||||
}
|
||||
|
||||
export interface OperatorI {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
tags: string[];
|
||||
isStar?: boolean;
|
||||
originalId?: string; // 用于标识原始算子ID,便于去重
|
||||
categories: number[]; // 分类列表
|
||||
settings: string;
|
||||
overrides?: { [key: string]: any }; // 用户配置的参数
|
||||
defaultParams?: { [key: string]: any }; // 默认参数
|
||||
configs: {
|
||||
[key: string]: ConfigI;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CategoryI {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number; // 该分类下的算子数量
|
||||
type: string; // e.g., "数据源", "数据清洗", "数据分析", "数据可视化"
|
||||
parentId?: number; // 父分类ID,若无父分类则为null
|
||||
}
|
||||
|
||||
export interface CategoryTreeI {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
categories: CategoryI[];
|
||||
}
|
||||
842
frontend/src/pages/Orchestration/Orchestration.tsx
Normal file
842
frontend/src/pages/Orchestration/Orchestration.tsx
Normal file
@@ -0,0 +1,842 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Progress, Badge, Tabs } from "antd";
|
||||
import {
|
||||
GitBranch,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Download,
|
||||
Upload,
|
||||
Plus,
|
||||
Settings,
|
||||
Database,
|
||||
Filter,
|
||||
Shuffle,
|
||||
Target,
|
||||
Zap,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
Copy,
|
||||
Edit,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import DevelopmentInProgress from "@/components/DevelopmentInProgress.tsx";
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
interface FlowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
position: { x: number; y: number };
|
||||
config: any;
|
||||
status: "idle" | "running" | "completed" | "error";
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface FlowConnection {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface FlowTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
nodes: FlowNode[];
|
||||
connections: FlowConnection[];
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
interface FlowExecution {
|
||||
id: number;
|
||||
templateName: string;
|
||||
status: "running" | "completed" | "failed" | "paused";
|
||||
progress: number;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: string;
|
||||
processedRecords: number;
|
||||
totalRecords: number;
|
||||
}
|
||||
|
||||
const nodeTypes = [
|
||||
{
|
||||
type: "data-source",
|
||||
name: "数据源",
|
||||
icon: Database,
|
||||
description: "从各种数据源读取数据",
|
||||
color: "bg-blue-500",
|
||||
category: "输入",
|
||||
},
|
||||
{
|
||||
type: "data-filter",
|
||||
name: "数据过滤",
|
||||
icon: Filter,
|
||||
description: "根据条件过滤数据",
|
||||
color: "bg-green-500",
|
||||
category: "处理",
|
||||
},
|
||||
{
|
||||
type: "data-transform",
|
||||
name: "数据转换",
|
||||
icon: Shuffle,
|
||||
description: "转换数据格式和结构",
|
||||
color: "bg-purple-500",
|
||||
category: "处理",
|
||||
},
|
||||
{
|
||||
type: "data-validation",
|
||||
name: "数据验证",
|
||||
icon: Target,
|
||||
description: "验证数据质量和完整性",
|
||||
color: "bg-orange-500",
|
||||
category: "处理",
|
||||
},
|
||||
{
|
||||
type: "data-enhancement",
|
||||
name: "数据增强",
|
||||
icon: Zap,
|
||||
description: "增强和丰富数据内容",
|
||||
color: "bg-pink-500",
|
||||
category: "处理",
|
||||
},
|
||||
{
|
||||
type: "data-output",
|
||||
name: "数据输出",
|
||||
icon: Download,
|
||||
description: "将处理后的数据输出到目标位置",
|
||||
color: "bg-indigo-500",
|
||||
category: "输出",
|
||||
},
|
||||
];
|
||||
|
||||
const mockTemplates: FlowTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "WSI病理图像预处理流程",
|
||||
description: "专用于WSI病理图像的标准化预处理流程",
|
||||
category: "医学影像",
|
||||
nodes: [
|
||||
{
|
||||
id: "node1",
|
||||
type: "data-source",
|
||||
name: "WSI图像源",
|
||||
description: "读取WSI病理图像",
|
||||
position: { x: 100, y: 100 },
|
||||
config: { source: "wsi_pathology", format: "svs" },
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "node2",
|
||||
type: "data-validation",
|
||||
name: "图像质量检查",
|
||||
description: "检查图像质量和完整性",
|
||||
position: { x: 300, y: 100 },
|
||||
config: { minSize: "1GB", maxSize: "5GB" },
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "node3",
|
||||
type: "data-transform",
|
||||
name: "图像标准化",
|
||||
description: "标准化图像格式和尺寸",
|
||||
position: { x: 500, y: 100 },
|
||||
config: { targetFormat: "tiff", normalize: true },
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "node4",
|
||||
type: "data-output",
|
||||
name: "处理结果输出",
|
||||
description: "输出处理后的图像",
|
||||
position: { x: 700, y: 100 },
|
||||
config: { destination: "processed_wsi" },
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ id: "conn1", source: "node1", target: "node2" },
|
||||
{ id: "conn2", source: "node2", target: "node3" },
|
||||
{ id: "conn3", source: "node3", target: "node4" },
|
||||
],
|
||||
createdAt: "2024-01-20",
|
||||
lastUsed: "2024-01-23",
|
||||
usageCount: 15,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "文本数据清洗流程",
|
||||
description: "通用文本数据清洗和标准化流程",
|
||||
category: "文本处理",
|
||||
nodes: [
|
||||
{
|
||||
id: "node1",
|
||||
type: "data-source",
|
||||
name: "文本数据源",
|
||||
description: "读取原始文本数据",
|
||||
position: { x: 100, y: 100 },
|
||||
config: { source: "text_corpus", encoding: "utf-8" },
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "node2",
|
||||
type: "data-filter",
|
||||
name: "内容过滤",
|
||||
description: "过滤无效和重复内容",
|
||||
position: { x: 300, y: 100 },
|
||||
config: { minLength: 10, removeDuplicates: true },
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "node3",
|
||||
type: "data-enhancement",
|
||||
name: "文本增强",
|
||||
description: "文本清洗和格式化",
|
||||
position: { x: 500, y: 100 },
|
||||
config: { removeHtml: true, normalizeWhitespace: true },
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "node4",
|
||||
type: "data-output",
|
||||
name: "清洗结果输出",
|
||||
description: "输出清洗后的文本",
|
||||
position: { x: 700, y: 100 },
|
||||
config: { format: "jsonl" },
|
||||
status: "idle",
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ id: "conn1", source: "node1", target: "node2" },
|
||||
{ id: "conn2", source: "node2", target: "node3" },
|
||||
{ id: "conn3", source: "node3", target: "node4" },
|
||||
],
|
||||
createdAt: "2024-01-18",
|
||||
lastUsed: "2024-01-22",
|
||||
usageCount: 28,
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecutions: FlowExecution[] = [
|
||||
{
|
||||
id: 1,
|
||||
templateName: "WSI病理图像预处理流程",
|
||||
status: "running",
|
||||
progress: 65,
|
||||
startTime: "2024-01-23 14:30:00",
|
||||
processedRecords: 650,
|
||||
totalRecords: 1000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
templateName: "文本数据清洗流程",
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
startTime: "2024-01-23 10:15:00",
|
||||
endTime: "2024-01-23 12:45:00",
|
||||
duration: "2h 30m",
|
||||
processedRecords: 50000,
|
||||
totalRecords: 50000,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
templateName: "WSI病理图像预处理流程",
|
||||
status: "failed",
|
||||
progress: 25,
|
||||
startTime: "2024-01-22 16:20:00",
|
||||
endTime: "2024-01-22 16:45:00",
|
||||
duration: "25m",
|
||||
processedRecords: 250,
|
||||
totalRecords: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
export default function OrchestrationPage() {
|
||||
return <DevelopmentInProgress />;
|
||||
const navigate = useNavigate();
|
||||
const [templates, setTemplates] = useState<FlowTemplate[]>(mockTemplates);
|
||||
const [executions, setExecutions] = useState<FlowExecution[]>(mockExecutions);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<FlowTemplate | null>(
|
||||
null
|
||||
);
|
||||
const [showWorkflowEditor, setShowWorkflowEditor] = useState(false);
|
||||
|
||||
const startNewFlow = () => {
|
||||
setShowWorkflowEditor(true);
|
||||
};
|
||||
|
||||
const handleSaveWorkflow = (workflow: any) => {
|
||||
setTemplates([workflow, ...templates]);
|
||||
setShowWorkflowEditor(false);
|
||||
};
|
||||
|
||||
const handleBackFromEditor = () => {
|
||||
setShowWorkflowEditor(false);
|
||||
};
|
||||
|
||||
if (showWorkflowEditor) {
|
||||
const WorkflowEditor = React.lazy(() => import("./WorkflowEditor.tsx"));
|
||||
return (
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<WorkflowEditor
|
||||
onBack={handleBackFromEditor}
|
||||
onSave={handleSaveWorkflow}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const executeTemplate = (template: FlowTemplate) => {
|
||||
const newExecution: FlowExecution = {
|
||||
id: Date.now(),
|
||||
templateName: template.name,
|
||||
status: "running",
|
||||
progress: 0,
|
||||
startTime: new Date().toLocaleString(),
|
||||
processedRecords: 0,
|
||||
totalRecords: 1000,
|
||||
};
|
||||
|
||||
setExecutions([newExecution, ...executions]);
|
||||
|
||||
// 模拟执行进度
|
||||
const interval = setInterval(() => {
|
||||
setExecutions((prev) =>
|
||||
prev.map((exec) => {
|
||||
if (exec.id === newExecution.id) {
|
||||
const newProgress = Math.min(
|
||||
exec.progress + Math.random() * 10,
|
||||
100
|
||||
);
|
||||
return {
|
||||
...exec,
|
||||
progress: newProgress,
|
||||
status: newProgress >= 100 ? "completed" : "running",
|
||||
processedRecords: Math.floor(
|
||||
(newProgress / 100) * exec.totalRecords
|
||||
),
|
||||
endTime:
|
||||
newProgress >= 100 ? new Date().toLocaleString() : undefined,
|
||||
};
|
||||
}
|
||||
return exec;
|
||||
})
|
||||
);
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => clearInterval(interval), 10000);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Clock className="w-4 h-4 text-blue-500" />;
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case "paused":
|
||||
return <Pause className="w-4 h-4 text-yellow-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
running: { label: "运行中", color: "processing" as const },
|
||||
completed: { label: "已完成", color: "success" as const },
|
||||
failed: { label: "失败", color: "error" as const },
|
||||
paused: { label: "已暂停", color: "warning" as const },
|
||||
};
|
||||
return (
|
||||
statusConfig[status as keyof typeof statusConfig] || statusConfig.running
|
||||
);
|
||||
};
|
||||
|
||||
const getNodeIcon = (nodeType: string) => {
|
||||
const nodeTypeInfo = nodeTypes.find((nt) => nt.type === nodeType);
|
||||
const IconComponent = nodeTypeInfo?.icon || Settings;
|
||||
return <IconComponent className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 bg-gray-50 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => navigate(-1)}
|
||||
icon={<ArrowLeft className="w-4 h-4 mr-2" />}
|
||||
></Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据智能编排</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setSelectedTemplate(null)}
|
||||
icon={<Upload className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
导入模板
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={startNewFlow}
|
||||
icon={<Plus className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
新建流程
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs defaultActiveKey="templates">
|
||||
<TabPane
|
||||
tab={<span>流程模板 ({templates.length})</span>}
|
||||
key="templates"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
{templates.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<GitBranch className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">{template.name}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge color="blue">{template.category}</Badge>
|
||||
<Button
|
||||
onClick={() => setSelectedTemplate(template)}
|
||||
icon={<Eye className="w-4 h-4 mr-1" />}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => executeTemplate(template)}
|
||||
icon={<Play className="w-4 h-4 mr-1" />}
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>{template.nodes.length} 个节点</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<span>{template.connections.length} 个连接</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>创建于 {template.createdAt}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Target className="w-4 h-4" />
|
||||
<span>使用 {template.usageCount} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Preview */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
流程预览:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{template.nodes.map((node, index) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-white rounded px-3 py-1 border">
|
||||
{getNodeIcon(node.type)}
|
||||
<span className="text-xs font-medium">
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
{index < template.nodes.length - 1 && (
|
||||
<ArrowRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span>执行历史 ({executions.length})</span>}
|
||||
key="executions"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
{executions.map((execution) => (
|
||||
<Card key={execution.id}>
|
||||
<div className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(execution.status)}
|
||||
<div>
|
||||
<h4 className="font-semibold">
|
||||
{execution.templateName}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
执行ID: {execution.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge status={getStatusBadge(execution.status).color}>
|
||||
{getStatusBadge(execution.status).label}
|
||||
</Badge>
|
||||
{execution.status === "running" && (
|
||||
<div className="flex gap-1">
|
||||
<Button>
|
||||
<Pause className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button>
|
||||
<Square className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{execution.status === "running" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>执行进度</span>
|
||||
<span>
|
||||
{execution.processedRecords.toLocaleString()} /{" "}
|
||||
{execution.totalRecords.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Progress percent={execution.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">开始时间:</span>
|
||||
<div className="font-medium">{execution.startTime}</div>
|
||||
</div>
|
||||
{execution.endTime && (
|
||||
<div>
|
||||
<span className="text-gray-500">结束时间:</span>
|
||||
<div className="font-medium">{execution.endTime}</div>
|
||||
</div>
|
||||
)}
|
||||
{execution.duration && (
|
||||
<div>
|
||||
<span className="text-gray-500">执行时长:</span>
|
||||
<div className="font-medium">
|
||||
{execution.duration}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">处理记录:</span>
|
||||
<div className="font-medium">
|
||||
{execution.processedRecords.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tab={<span>实时监控</span>} key="monitoring">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<div className="pt-6 flex items-center gap-2">
|
||||
<Play className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{executions.filter((e) => e.status === "running").length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">运行中</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="pt-6 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{executions.filter((e) => e.status === "completed").length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">已完成</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="pt-6 flex items-center gap-2">
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{executions.filter((e) => e.status === "failed").length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">失败</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="pt-6 flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{templates.length}</p>
|
||||
<p className="text-sm text-gray-500">流程模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Real-time Execution Monitor */}
|
||||
<Card>
|
||||
<div style={{ padding: 24 }}>
|
||||
<h3>实时执行监控</h3>
|
||||
<div style={{ color: "#888", marginBottom: 16 }}>
|
||||
当前正在执行的流程实时状态
|
||||
</div>
|
||||
{executions.filter((e) => e.status === "running").length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{executions
|
||||
.filter((e) => e.status === "running")
|
||||
.map((execution) => (
|
||||
<div
|
||||
key={execution.id}
|
||||
className="border rounded-lg p-4 mb-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">
|
||||
{execution.templateName}
|
||||
</h4>
|
||||
<Badge status="processing">运行中</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>进度: {Math.round(execution.progress)}%</span>
|
||||
<span>
|
||||
{execution.processedRecords.toLocaleString()} /{" "}
|
||||
{execution.totalRecords.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Progress percent={execution.progress} />
|
||||
<div className="text-xs text-gray-500">
|
||||
开始时间: {execution.startTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Clock className="w-12 h-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>当前没有正在执行的流程</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
{/* Template Detail Modal */}
|
||||
{selectedTemplate && (
|
||||
<Card style={{ border: "2px solid #91caff" }}>
|
||||
<div style={{ padding: 24, borderBottom: "1px solid #f0f0f0" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{ fontSize: 20, fontWeight: 600 }}
|
||||
>
|
||||
<GitBranch className="w-5 h-5 text-orange-500" />
|
||||
{selectedTemplate.name}
|
||||
</div>
|
||||
<div style={{ color: "#888" }}>
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<Copy className="w-4 h-4 mr-1" />}>复制</Button>
|
||||
<Button icon={<Edit className="w-4 h-4 mr-1" />}>编辑</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Play className="w-4 h-4 mr-1" />}
|
||||
onClick={() => executeTemplate(selectedTemplate)}
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
<Button onClick={() => setSelectedTemplate(null)}>关闭</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
className="text-center p-4"
|
||||
style={{ background: "#e6f7ff", borderRadius: 8 }}
|
||||
>
|
||||
<Settings
|
||||
className="w-8 h-8 mx-auto mb-2"
|
||||
style={{ color: "#1890ff" }}
|
||||
/>
|
||||
<div
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: "#1890ff" }}
|
||||
>
|
||||
{selectedTemplate.nodes.length}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: "#888" }}>
|
||||
处理节点
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center p-4"
|
||||
style={{ background: "#f6ffed", borderRadius: 8 }}
|
||||
>
|
||||
<ArrowRight
|
||||
className="w-8 h-8 mx-auto mb-2"
|
||||
style={{ color: "#52c41a" }}
|
||||
/>
|
||||
<div
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: "#52c41a" }}
|
||||
>
|
||||
{selectedTemplate.connections.length}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: "#888" }}>
|
||||
节点连接
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center p-4"
|
||||
style={{ background: "#f9f0ff", borderRadius: 8 }}
|
||||
>
|
||||
<Target
|
||||
className="w-8 h-8 mx-auto mb-2"
|
||||
style={{ color: "#722ed1" }}
|
||||
/>
|
||||
<div
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: "#722ed1" }}
|
||||
>
|
||||
{selectedTemplate.usageCount}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: "#888" }}>
|
||||
使用次数
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center p-4"
|
||||
style={{ background: "#fff7e6", borderRadius: 8 }}
|
||||
>
|
||||
<Clock
|
||||
className="w-8 h-8 mx-auto mb-2"
|
||||
style={{ color: "#fa8c16" }}
|
||||
/>
|
||||
<div
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: "#fa8c16" }}
|
||||
>
|
||||
{selectedTemplate.createdAt}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: "#888" }}>
|
||||
创建日期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<h4 style={{ fontWeight: 600, marginBottom: 16 }}>
|
||||
流程节点详情
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedTemplate.nodes.map((node, index) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-start gap-3 p-4"
|
||||
style={{
|
||||
border: "1px solid #f0f0f0",
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getNodeIcon(node.type)}
|
||||
<span style={{ fontWeight: 500 }}>{node.name}</span>
|
||||
<Badge color="blue">
|
||||
{
|
||||
nodeTypes.find((nt) => nt.type === node.type)
|
||||
?.category
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
<div style={{ color: "#888", marginBottom: 8 }}>
|
||||
{node.description}
|
||||
</div>
|
||||
{Object.keys(node.config).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#888",
|
||||
background: "#fafafa",
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<strong>配置:</strong>{" "}
|
||||
{JSON.stringify(node.config, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
462
frontend/src/pages/Orchestration/WorkflowEditor.tsx
Normal file
462
frontend/src/pages/Orchestration/WorkflowEditor.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import type React from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Connection,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { Button, Card, Input, Badge, Typography } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import {
|
||||
Play,
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
Download,
|
||||
Bug,
|
||||
Search,
|
||||
MessageSquare,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import CustomNode from "./components/CustomNode";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
customNode: CustomNode,
|
||||
};
|
||||
|
||||
interface WorkflowEditorProps {
|
||||
onBack: () => void;
|
||||
onSave: (workflow: any) => void;
|
||||
initialWorkflow?: any;
|
||||
}
|
||||
|
||||
const nodeTypeTemplates = [
|
||||
{
|
||||
type: "knowledge-search",
|
||||
name: "知识库搜索",
|
||||
description: "查询、过滤和检索知识库中的文档内容,为AI模型提供上下文信息",
|
||||
icon: Database,
|
||||
category: "数据源",
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
},
|
||||
{
|
||||
type: "ai-dialogue",
|
||||
name: "AI 对话",
|
||||
description: "AI 大模型对话",
|
||||
icon: MessageSquare,
|
||||
category: "AI处理",
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
},
|
||||
{
|
||||
type: "data-processing",
|
||||
name: "数据处理",
|
||||
description: "对数据进行清洗、转换和处理",
|
||||
icon: Cpu,
|
||||
category: "数据处理",
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
},
|
||||
{
|
||||
type: "data-output",
|
||||
name: "数据输出",
|
||||
description: "将处理后的数据输出到指定位置",
|
||||
icon: Download,
|
||||
category: "数据输出",
|
||||
inputs: 1,
|
||||
outputs: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export default function WorkflowEditor({
|
||||
onBack,
|
||||
onSave,
|
||||
initialWorkflow,
|
||||
}: WorkflowEditorProps) {
|
||||
const [workflow, setWorkflow] = useState({
|
||||
id: initialWorkflow?.id || Date.now(),
|
||||
name: initialWorkflow?.name || "新建流程",
|
||||
description: initialWorkflow?.description || "描述您的数据处理流程",
|
||||
category: initialWorkflow?.category || "自定义",
|
||||
});
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredNodeTypes = nodeTypeTemplates.filter(
|
||||
(nodeType) =>
|
||||
nodeType.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
nodeType.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
setEdges((eds) => addEdge(params, eds));
|
||||
},
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
}, []);
|
||||
|
||||
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
||||
event.dataTransfer.setData("application/reactflow", nodeType);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const deleteNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
|
||||
setEdges((eds) =>
|
||||
eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
|
||||
);
|
||||
},
|
||||
[setNodes, setEdges]
|
||||
);
|
||||
|
||||
const duplicateNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
const nodeToDuplicate = nodes.find((node) => node.id === nodeId);
|
||||
if (!nodeToDuplicate) return;
|
||||
|
||||
const newNode: Node = {
|
||||
...nodeToDuplicate,
|
||||
id: `${nodeToDuplicate.data.type}_${Date.now()}`,
|
||||
position: {
|
||||
x: nodeToDuplicate.position.x + 50,
|
||||
y: nodeToDuplicate.position.y + 50,
|
||||
},
|
||||
data: {
|
||||
...nodeToDuplicate.data,
|
||||
id: `${nodeToDuplicate.data.type}_${Date.now()}`,
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((nds) => nds.concat(newNode));
|
||||
},
|
||||
[nodes, setNodes]
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
const workflowData = {
|
||||
...workflow,
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
name: node.data.name,
|
||||
description: node.data.description,
|
||||
position: node.position,
|
||||
config: node.data.config || {},
|
||||
})),
|
||||
connections: edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
})),
|
||||
};
|
||||
onSave(workflowData);
|
||||
};
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (typeof type === "undefined" || !type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: event.clientX - 400, // Adjust for sidebar width
|
||||
y: event.clientY - 100, // Adjust for header height
|
||||
};
|
||||
|
||||
const nodeTemplate = nodeTypeTemplates.find(
|
||||
(template) => template.type === type
|
||||
);
|
||||
if (!nodeTemplate) return;
|
||||
|
||||
const newNode: Node = {
|
||||
id: `${type}_${Date.now()}`,
|
||||
type: "customNode",
|
||||
position,
|
||||
data: {
|
||||
id: `${type}_${Date.now()}`,
|
||||
type: type,
|
||||
name: nodeTemplate.name,
|
||||
description: nodeTemplate.description,
|
||||
onDelete: deleteNode,
|
||||
onDuplicate: duplicateNode,
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((nds) => nds.concat(newNode));
|
||||
},
|
||||
[setNodes, deleteNode, duplicateNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
icon={<ArrowLeft className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<div>
|
||||
<Input
|
||||
value={workflow.name}
|
||||
onChange={(e) =>
|
||||
setWorkflow((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
className="text-lg font-semibold border-none p-0 h-auto bg-transparent focus-visible:ring-0"
|
||||
placeholder="流程名称"
|
||||
bordered={false}
|
||||
/>
|
||||
<Input
|
||||
value={workflow.description}
|
||||
onChange={(e) =>
|
||||
setWorkflow((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="text-sm text-gray-600 border-none p-0 h-auto bg-transparent focus-visible:ring-0 mt-1"
|
||||
placeholder="流程描述"
|
||||
bordered={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<Bug className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
调试
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<Play className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
运行
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
size="small"
|
||||
icon={<Save className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component Library Sidebar */}
|
||||
<div className="w-80 bg-white border-r border-gray-200 flex flex-col mt-20">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
<div className="p-4 space-y-3">
|
||||
{filteredNodeTypes.map((nodeType) => (
|
||||
<Card
|
||||
key={nodeType.type}
|
||||
className="cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, nodeType.type)}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<nodeType.icon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 mb-1">
|
||||
{nodeType.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 leading-relaxed">
|
||||
{nodeType.description}
|
||||
</div>
|
||||
<Badge color="blue" style={{ marginTop: 8, fontSize: 12 }}>
|
||||
{nodeType.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas */}
|
||||
<div className="flex-1 mt-20">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-gray-50"
|
||||
connectionLineStyle={{
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 3,
|
||||
strokeDasharray: "5,5",
|
||||
}}
|
||||
defaultEdgeOptions={{
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 3,
|
||||
strokeDasharray: "0",
|
||||
},
|
||||
markerEnd: {
|
||||
type: "arrowclosed",
|
||||
color: "#3b82f6",
|
||||
},
|
||||
}}
|
||||
isValidConnection={(connection) =>
|
||||
connection.source !== connection.target
|
||||
}
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
{selectedNodeId && (
|
||||
<div className="w-80 bg-white border-l border-gray-200 mt-20">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
节点配置
|
||||
</Title>
|
||||
</div>
|
||||
<div style={{ height: "calc(100% - 56px)", overflowY: "auto" }}>
|
||||
<div className="p-4 ">
|
||||
{(() => {
|
||||
const selectedNode = nodes.find(
|
||||
(node) => node.id === selectedNodeId
|
||||
);
|
||||
if (!selectedNode) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="node-name"
|
||||
className="block font-medium mb-1"
|
||||
>
|
||||
节点名称
|
||||
</label>
|
||||
<Input
|
||||
id="node-name"
|
||||
value={selectedNode.data.name}
|
||||
onChange={(e) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) =>
|
||||
node.id === selectedNode.id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
name: e.target.value,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="node-description"
|
||||
className="block font-medium mb-1"
|
||||
>
|
||||
节点描述
|
||||
</label>
|
||||
<TextArea
|
||||
id="node-description"
|
||||
value={selectedNode.data.description}
|
||||
onChange={(e) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) =>
|
||||
node.id === selectedNode.id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
description: e.target.value,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="mt-1"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user