You've already forked DataMate
Revert "feat: fix the problem in the Operator Market frontend pages"
This commit is contained in:
@@ -1,346 +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="flex-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="flex-overflow-auto 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>
|
||||
);
|
||||
}
|
||||
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="flex-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="flex-overflow-auto 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,192 +1,192 @@
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,225 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
import { Button, Card, Input, InputNumber, Popconfirm, Select, Switch, Tooltip } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { useState, useImperativeHandle, forwardRef } from "react";
|
||||
|
||||
type LabelType = "string" | "number" | "enum";
|
||||
|
||||
type LabelItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: LabelType;
|
||||
required?: boolean;
|
||||
// for enum: values; for number: min/max
|
||||
values?: string[];
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
step?: number | null;
|
||||
};
|
||||
|
||||
type LabelingConfigEditorProps = {
|
||||
initial?: any;
|
||||
onGenerate: (config: any) => void;
|
||||
hideFooter?: boolean;
|
||||
};
|
||||
|
||||
export default forwardRef<any, LabelingConfigEditorProps>(function LabelingConfigEditor(
|
||||
{ initial, onGenerate, hideFooter }: LabelingConfigEditorProps,
|
||||
ref: any
|
||||
) {
|
||||
const [labels, setLabels] = useState<LabelItem[]>(() => {
|
||||
if (initial && initial.labels && Array.isArray(initial.labels)) {
|
||||
return initial.labels.map((l: any, idx: number) => ({
|
||||
id: `${Date.now()}-${idx}`,
|
||||
name: l.name || "",
|
||||
type: l.type || "string",
|
||||
required: !!l.required,
|
||||
values: l.values || (l.type === "enum" ? [] : undefined),
|
||||
min: l.min ?? null,
|
||||
max: l.max ?? null,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const addLabel = () => {
|
||||
setLabels((s) => [
|
||||
...s,
|
||||
{ id: `${Date.now()}-${Math.random()}`, name: "", type: "string", required: false, step: null },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateLabel = (id: string, patch: Partial<LabelItem>) => {
|
||||
setLabels((s) => s.map((l) => (l.id === id ? { ...l, ...patch } : l)));
|
||||
};
|
||||
|
||||
const removeLabel = (id: string) => {
|
||||
setLabels((s) => s.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
// basic validation: label name non-empty
|
||||
for (const l of labels) {
|
||||
if (!l.name || l.name.trim() === "") {
|
||||
// focus not available here, just abort
|
||||
// Could show a more friendly UI; keep simple for now
|
||||
// eslint-disable-next-line no-alert
|
||||
alert("请为所有标签填写名称");
|
||||
return;
|
||||
}
|
||||
if (l.type === "enum") {
|
||||
if (!l.values || l.values.length === 0) {
|
||||
alert(`枚举标签 ${l.name} 需要至少一个取值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (l.type === "number") {
|
||||
// validate min/max
|
||||
if (l.min != null && l.max != null && l.min > l.max) {
|
||||
alert(`数值标签 ${l.name} 的最小值不能大于最大值`);
|
||||
return;
|
||||
}
|
||||
// validate step
|
||||
if (l.step != null && (!(typeof l.step === "number") || l.step <= 0)) {
|
||||
alert(`数值标签 ${l.name} 的步长必须为大于 0 的数值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
labels: labels.map((l) => {
|
||||
const item: any = { name: l.name, type: l.type, required: !!l.required };
|
||||
if (l.type === "enum") item.values = l.values || [];
|
||||
if (l.type === "number") {
|
||||
if (l.min != null) item.min = l.min;
|
||||
if (l.max != null) item.max = l.max;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
|
||||
onGenerate(config);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addLabel,
|
||||
generate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{labels.map((label) => (
|
||||
<Card size="small" key={label.id} styles={{ body: { padding: 10 } }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="标签名称"
|
||||
value={label.name}
|
||||
onChange={(e) => updateLabel(label.id, { name: e.target.value })}
|
||||
style={{ flex: "1 1 160px", minWidth: 120 }}
|
||||
/>
|
||||
<Select
|
||||
value={label.type}
|
||||
onChange={(v) => updateLabel(label.id, { type: v as LabelType })}
|
||||
options={[{ label: "文本", value: "string" }, { label: "数值", value: "number" }, { label: "枚举", value: "enum" }]}
|
||||
style={{ width: 120, flex: "0 0 120px" }}
|
||||
/>
|
||||
|
||||
{label.type === "enum" && (
|
||||
<Input.TextArea
|
||||
placeholder="每行一个枚举值,按回车换行"
|
||||
value={(label.values || []).join("\n")}
|
||||
onChange={(e) => updateLabel(label.id, { values: e.target.value.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) })}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent parent handlers (like Form submit or modal shortcuts) from intercepting Enter
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rows={3}
|
||||
style={{ flex: "1 1 220px", minWidth: 160, width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{label.type === "number" && (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flex: "0 0 auto" }}>
|
||||
<Tooltip title="最小值">
|
||||
<InputNumber value={label.min ?? null} onChange={(v) => updateLabel(label.id, { min: v ?? null })} placeholder="min" />
|
||||
</Tooltip>
|
||||
<Tooltip title="最大值">
|
||||
<InputNumber value={label.max ?? null} onChange={(v) => updateLabel(label.id, { max: v ?? null })} placeholder="max" />
|
||||
</Tooltip>
|
||||
<Tooltip title="步长 (step)">
|
||||
<InputNumber value={label.step ?? null} onChange={(v) => updateLabel(label.id, { step: v ?? null })} placeholder="step" min={0} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginLeft: "auto" }}>
|
||||
<span style={{ fontSize: 12, color: "rgba(0,0,0,0.65)" }}>必填</span>
|
||||
<Switch checked={!!label.required} onChange={(v) => updateLabel(label.id, { required: v })} />
|
||||
<Popconfirm title="确认删除该标签?" onConfirm={() => removeLabel(label.id)}>
|
||||
<Button type="text" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "rgba(0,0,0,0.45)", fontSize: 12 }}>
|
||||
{label.type === "string" && <span>类型:文本</span>}
|
||||
{label.type === "number" && <span>类型:数值,支持 min / max / step</span>}
|
||||
{label.type === "enum" && <span>类型:枚举,每行一个取值(示例:一行写一个值)</span>}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!hideFooter && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button icon={<PlusOutlined />} onClick={addLabel}>
|
||||
添加标签
|
||||
</Button>
|
||||
<Button type="primary" onClick={generate}>
|
||||
生成 JSON 配置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
);
|
||||
import { Button, Card, Input, InputNumber, Popconfirm, Select, Switch, Tooltip } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { useState, useImperativeHandle, forwardRef } from "react";
|
||||
|
||||
type LabelType = "string" | "number" | "enum";
|
||||
|
||||
type LabelItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: LabelType;
|
||||
required?: boolean;
|
||||
// for enum: values; for number: min/max
|
||||
values?: string[];
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
step?: number | null;
|
||||
};
|
||||
|
||||
type LabelingConfigEditorProps = {
|
||||
initial?: any;
|
||||
onGenerate: (config: any) => void;
|
||||
hideFooter?: boolean;
|
||||
};
|
||||
|
||||
export default forwardRef<any, LabelingConfigEditorProps>(function LabelingConfigEditor(
|
||||
{ initial, onGenerate, hideFooter }: LabelingConfigEditorProps,
|
||||
ref: any
|
||||
) {
|
||||
const [labels, setLabels] = useState<LabelItem[]>(() => {
|
||||
if (initial && initial.labels && Array.isArray(initial.labels)) {
|
||||
return initial.labels.map((l: any, idx: number) => ({
|
||||
id: `${Date.now()}-${idx}`,
|
||||
name: l.name || "",
|
||||
type: l.type || "string",
|
||||
required: !!l.required,
|
||||
values: l.values || (l.type === "enum" ? [] : undefined),
|
||||
min: l.min ?? null,
|
||||
max: l.max ?? null,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const addLabel = () => {
|
||||
setLabels((s) => [
|
||||
...s,
|
||||
{ id: `${Date.now()}-${Math.random()}`, name: "", type: "string", required: false, step: null },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateLabel = (id: string, patch: Partial<LabelItem>) => {
|
||||
setLabels((s) => s.map((l) => (l.id === id ? { ...l, ...patch } : l)));
|
||||
};
|
||||
|
||||
const removeLabel = (id: string) => {
|
||||
setLabels((s) => s.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
// basic validation: label name non-empty
|
||||
for (const l of labels) {
|
||||
if (!l.name || l.name.trim() === "") {
|
||||
// focus not available here, just abort
|
||||
// Could show a more friendly UI; keep simple for now
|
||||
// eslint-disable-next-line no-alert
|
||||
alert("请为所有标签填写名称");
|
||||
return;
|
||||
}
|
||||
if (l.type === "enum") {
|
||||
if (!l.values || l.values.length === 0) {
|
||||
alert(`枚举标签 ${l.name} 需要至少一个取值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (l.type === "number") {
|
||||
// validate min/max
|
||||
if (l.min != null && l.max != null && l.min > l.max) {
|
||||
alert(`数值标签 ${l.name} 的最小值不能大于最大值`);
|
||||
return;
|
||||
}
|
||||
// validate step
|
||||
if (l.step != null && (!(typeof l.step === "number") || l.step <= 0)) {
|
||||
alert(`数值标签 ${l.name} 的步长必须为大于 0 的数值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
labels: labels.map((l) => {
|
||||
const item: any = { name: l.name, type: l.type, required: !!l.required };
|
||||
if (l.type === "enum") item.values = l.values || [];
|
||||
if (l.type === "number") {
|
||||
if (l.min != null) item.min = l.min;
|
||||
if (l.max != null) item.max = l.max;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
|
||||
onGenerate(config);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addLabel,
|
||||
generate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{labels.map((label) => (
|
||||
<Card size="small" key={label.id} styles={{ body: { padding: 10 } }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="标签名称"
|
||||
value={label.name}
|
||||
onChange={(e) => updateLabel(label.id, { name: e.target.value })}
|
||||
style={{ flex: "1 1 160px", minWidth: 120 }}
|
||||
/>
|
||||
<Select
|
||||
value={label.type}
|
||||
onChange={(v) => updateLabel(label.id, { type: v as LabelType })}
|
||||
options={[{ label: "文本", value: "string" }, { label: "数值", value: "number" }, { label: "枚举", value: "enum" }]}
|
||||
style={{ width: 120, flex: "0 0 120px" }}
|
||||
/>
|
||||
|
||||
{label.type === "enum" && (
|
||||
<Input.TextArea
|
||||
placeholder="每行一个枚举值,按回车换行"
|
||||
value={(label.values || []).join("\n")}
|
||||
onChange={(e) => updateLabel(label.id, { values: e.target.value.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) })}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent parent handlers (like Form submit or modal shortcuts) from intercepting Enter
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rows={3}
|
||||
style={{ flex: "1 1 220px", minWidth: 160, width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{label.type === "number" && (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flex: "0 0 auto" }}>
|
||||
<Tooltip title="最小值">
|
||||
<InputNumber value={label.min ?? null} onChange={(v) => updateLabel(label.id, { min: v ?? null })} placeholder="min" />
|
||||
</Tooltip>
|
||||
<Tooltip title="最大值">
|
||||
<InputNumber value={label.max ?? null} onChange={(v) => updateLabel(label.id, { max: v ?? null })} placeholder="max" />
|
||||
</Tooltip>
|
||||
<Tooltip title="步长 (step)">
|
||||
<InputNumber value={label.step ?? null} onChange={(v) => updateLabel(label.id, { step: v ?? null })} placeholder="step" min={0} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginLeft: "auto" }}>
|
||||
<span style={{ fontSize: 12, color: "rgba(0,0,0,0.65)" }}>必填</span>
|
||||
<Switch checked={!!label.required} onChange={(v) => updateLabel(label.id, { required: v })} />
|
||||
<Popconfirm title="确认删除该标签?" onConfirm={() => removeLabel(label.id)}>
|
||||
<Button type="text" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "rgba(0,0,0,0.45)", fontSize: 12 }}>
|
||||
{label.type === "string" && <span>类型:文本</span>}
|
||||
{label.type === "number" && <span>类型:数值,支持 min / max / step</span>}
|
||||
{label.type === "enum" && <span>类型:枚举,每行一个取值(示例:一行写一个值)</span>}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!hideFooter && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button icon={<PlusOutlined />} onClick={addLabel}>
|
||||
添加标签
|
||||
</Button>
|
||||
<Button type="primary" onClick={generate}>
|
||||
生成 JSON 配置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user