feat(annotation): 替换模板配置表单为树形编辑器组件

- 移除 TemplateConfigurationForm 组件并引入 TemplateConfigurationTreeEditor
- 使用 useTagConfig Hook 获取标签配置
- 将自定义XML状态 customXml 替换为 labelConfig
- 删除模板编辑标签页和选择模板状态管理
- 更新XML解析逻辑支持更多对象和标注控件类型
- 添加配置验证功能确保至少包含数据对象和标注控件
- 在模板详情页面使用树形编辑器显示配置详情
- 更新任务创建页面集成新的树形配置编辑器
- 调整预览数据生成功能适配新的XML解析方式
This commit is contained in:
2026-01-23 16:11:59 +08:00
parent 76d06b9809
commit 3f566a0b08
14 changed files with 1383 additions and 900 deletions

View File

@@ -1,180 +1,108 @@
import type React from "react";
import { useEffect, useState } from "react";
import { Card, Button, Input, Select, Divider, Form, message, Radio } from "antd";
import { Button, Input, Select, Form, message, Radio } 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 TemplateConfigurationForm from "../components/TemplateConfigurationForm";
import { DatabaseOutlined } from "@ant-design/icons";
import { Link, useNavigate } from "react-router";
import { ArrowLeft } from "lucide-react";
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import type { Dataset } from "@/pages/DataManagement/dataset.model";
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"];
createAnnotationTaskUsingPost,
queryAnnotationTemplatesUsingGet,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
export default function AnnotationTaskCreate() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
useState(false);
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
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 [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [labelConfig, setLabelConfig] = useState("");
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
const [submitting, setSubmitting] = useState(false);
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet();
setDatasets(data.results || []);
try {
const { data } = await queryDatasetsUsingGet({ page: 0, pageSize: 1000 });
const list = data?.content || [];
setDatasets(list.map((item: any) => mapDataset(item)) || []);
} catch (error) {
console.error("加载数据集失败:", error);
message.error("加载数据集失败");
}
};
const fetchTemplates = async () => {
try {
const response = await queryAnnotationTemplatesUsingGet({ page: 1, size: 200 });
if (response.code === 200 && response.data) {
setTemplates(response.data.content || []);
} else {
message.error(response.message || "加载模板失败");
}
} catch (error) {
console.error("加载模板失败:", error);
message.error("加载模板失败");
}
};
useEffect(() => {
fetchDatasets();
fetchTemplates();
}, []);
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");
const handleTemplateSelect = (value?: string) => {
if (!value) {
setLabelConfig("");
return;
}
setSelectedTemplate(null);
setFormValues((prev) => ({ ...prev, templateId: "" }));
const selectedTemplate = templates.find((template) => template.id === value);
setLabelConfig(selectedTemplate?.labelConfig || "");
};
const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template);
setFormValues((prev) => ({ ...prev, templateId: template.id }));
};
const handleValuesChange = (_, allValues) => {
setFormValues({ ...formValues, ...allValues });
};
const handleConfigModeChange = (e) => {
const handleConfigModeChange = (e: any) => {
const mode = e.target.value;
setConfigMode(mode);
if (mode === "custom") {
// Initialize default values for custom configuration
form.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
// Clear template selection
setSelectedTemplate(null);
setFormValues((prev) => ({ ...prev, templateId: "" }));
form.setFieldsValue({ templateId: undefined });
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const dataset = datasets.find((ds) => ds.id === values.datasetId);
let template;
if (configMode === "template") {
template = mockTemplates.find(
(tpl) => tpl.id === values.templateId
);
if (!template) {
message.error("请选择标注模板");
return;
}
} else {
// Custom configuration
const objects = values.objects;
const labels = values.labels;
if (!objects || objects.length === 0) {
message.error("请至少配置一个数据对象");
return;
}
if (!labels || labels.length === 0) {
message.error("请至少配置一个标签控件");
return;
}
// Construct a temporary custom template object
template = {
id: `custom-${Date.now()}`,
name: "自定义配置",
description: "任务特定的自定义配置",
type: selectedDataset?.type === DatasetType.PRETRAIN_TEXT ? "text" : "image",
isCustom: true,
configuration: {
objects,
labels
}
};
}
if (!dataset) {
message.error("请选择数据集");
if (!labelConfig.trim()) {
message.error("请配置标注模板");
return;
}
const taskData = {
setSubmitting(true);
await createAnnotationTaskUsingPost({
name: values.name,
description: values.description,
dataset,
template,
};
// onCreateTask(taskData); // 实际创建逻辑
console.log("Submitting task data:", taskData);
datasetId: values.datasetId,
templateId: configMode === "template" ? values.templateId : undefined,
labelConfig: labelConfig.trim(),
});
message.success("标注任务创建成功");
navigate("/data/annotation");
} catch (e) {
// 校验失败
console.error(e);
} catch (error: any) {
if (error?.errorFields) {
message.error("请完善必填信息");
} else {
const msg = error?.message || error?.data?.message || "创建失败,请稍后重试";
message.error(msg);
console.error(error);
}
} finally {
setSubmitting(false);
}
};
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">
@@ -186,13 +114,7 @@ export default function AnnotationTaskCreate() {
<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"
>
{/* 基本信息 */}
<Form form={form} layout="vertical">
<h2 className="font-medium text-gray-900 text-lg mb-2"></h2>
<Form.Item
label="任务名称"
@@ -201,11 +123,7 @@ export default function AnnotationTaskCreate() {
>
<Input placeholder="输入任务名称" />
</Form.Item>
<Form.Item
label="任务描述"
name="description"
rules={[{ required: true, message: "请输入任务描述" }]}
>
<Form.Item label="任务描述" name="description">
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
</Form.Item>
<Form.Item
@@ -215,8 +133,6 @@ export default function AnnotationTaskCreate() {
>
<Select
optionFilterProp="children"
value={formValues.datasetId}
onChange={handleDatasetSelect}
placeholder="请选择数据集"
size="large"
options={datasets.map((dataset) => ({
@@ -236,10 +152,9 @@ export default function AnnotationTaskCreate() {
/>
</Form.Item>
{/* 模板配置 */}
<div className="flex items-center justify-between mt-6 mb-2">
<h2 className="font-medium text-gray-900 text-lg flex items-center gap-2">
</h2>
<Radio.Group value={configMode} onChange={handleConfigModeChange} buttonStyle="solid">
<Radio.Button value="template"></Radio.Button>
@@ -247,163 +162,56 @@ export default function AnnotationTaskCreate() {
</Radio.Group>
</div>
{configMode === "template" ? (
{configMode === "template" && (
<Form.Item
label="加载现有模板"
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>
<Select
placeholder="选择一个模板作为基础"
showSearch
allowClear
optionFilterProp="label"
options={templates.map((template) => ({
label: template.name,
value: template.id,
title: template.description,
config: template.labelConfig,
}))}
onChange={handleTemplateSelect}
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>
)}
</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>
) : (
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50">
<TemplateConfigurationForm form={form} />
</div>
)}
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={420}
/>
</div>
</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 onClick={() => navigate("/data/annotation")} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</div>
</div>
{/* Custom Template Dialog */}
<CustomTemplateDialog
open={showCustomTemplateDialog}
onOpenChange={setShowCustomTemplateDialog}
onSaveTemplate={handleSaveCustomTemplate}
datasetType={selectedDataset?.type || "image"}
/>
</div>
);
}