feat(DataAnnotation): 新增模板配置表单组件

- 实现了数据对象配置区域,支持添加、删除数据对象字段
- 添加了标签控件配置区域,支持多种控件类型的动态配置
- 集成了TagSelector组件用于对象类型和控件类型的选择
- 实现了表单验证规则,包括必填项和值格式校验
- 添加了动态选项渲染功能,根据控件类型显示相应配置项
- 实现了表单联动逻辑,支持对象选择和控件配置的关联
- 添加了用户友好的界面布局和交互提示功能
This commit is contained in:
2026-01-18 21:08:47 +08:00
parent 668432cc1b
commit 6e08255820
3 changed files with 486 additions and 353 deletions

View File

@@ -1,6 +1,6 @@
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, Button, Input, Select, Divider, Form, message } from "antd"; import { Card, Button, Input, Select, Divider, Form, message, Radio } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { import {
DatabaseOutlined, DatabaseOutlined,
@@ -9,6 +9,7 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { mockTemplates } from "@/mock/annotation"; import { mockTemplates } from "@/mock/annotation";
import CustomTemplateDialog from "./components/CustomTemplateDialog"; import CustomTemplateDialog from "./components/CustomTemplateDialog";
import TemplateConfigurationForm from "../../components/TemplateConfigurationForm";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api"; import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
@@ -36,6 +37,7 @@ export default function AnnotationTaskCreate() {
const [showCustomTemplateDialog, setShowCustomTemplateDialog] = const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
useState(false); useState(false);
const [selectedCategory, setSelectedCategory] = useState("Computer Vision"); const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [datasetFilter, setDatasetFilter] = useState("all"); const [datasetFilter, setDatasetFilter] = useState("all");
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>( const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
@@ -87,21 +89,67 @@ export default function AnnotationTaskCreate() {
setFormValues({ ...formValues, ...allValues }); setFormValues({ ...formValues, ...allValues });
}; };
const handleConfigModeChange = (e) => {
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: "" }));
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
const dataset = datasets.find((ds) => ds.id === values.datasetId); const dataset = datasets.find((ds) => ds.id === values.datasetId);
const template = mockTemplates.find(
let template;
if (configMode === "template") {
template = mockTemplates.find(
(tpl) => tpl.id === values.templateId (tpl) => tpl.id === values.templateId
); );
if (!dataset) {
message.error("请选择数据集");
return;
}
if (!template) { if (!template) {
message.error("请选择标注模板"); message.error("请选择标注模板");
return; 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("请选择数据集");
return;
}
const taskData = { const taskData = {
name: values.name, name: values.name,
description: values.description, description: values.description,
@@ -109,10 +157,12 @@ export default function AnnotationTaskCreate() {
template, template,
}; };
// onCreateTask(taskData); // 实际创建逻辑 // onCreateTask(taskData); // 实际创建逻辑
console.log("Submitting task data:", taskData);
message.success("标注任务创建成功"); message.success("标注任务创建成功");
navigate("/data/annotation"); navigate("/data/annotation");
} catch (e) { } catch (e) {
// 校验失败 // 校验失败
console.error(e);
} }
}; };
@@ -186,10 +236,18 @@ export default function AnnotationTaskCreate() {
/> />
</Form.Item> </Form.Item>
{/* 模板选择 */} {/* 模板配置 */}
<h2 className="font-medium text-gray-900 text-lg mt-6 mb-2 flex items-center gap-2"> <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> </h2>
<Radio.Group value={configMode} onChange={handleConfigModeChange} buttonStyle="solid">
<Radio.Button value="template"></Radio.Button>
<Radio.Button value="custom"></Radio.Button>
</Radio.Group>
</div>
{configMode === "template" ? (
<Form.Item <Form.Item
name="templateId" name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]} rules={[{ required: true, message: "请选择标注模板" }]}
@@ -324,6 +382,11 @@ export default function AnnotationTaskCreate() {
</div> </div>
)} )}
</Form.Item> </Form.Item>
) : (
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50">
<TemplateConfigurationForm form={form} />
</div>
)}
</Form> </Form>
</div> </div>
<div className="flex gap-2 justify-end border-t border-gray-200 p-6"> <div className="flex gap-2 justify-end border-t border-gray-200 p-6">

View File

@@ -4,21 +4,16 @@ import {
Form, Form,
Input, Input,
Select, Select,
Button,
Space, Space,
message, message,
Divider,
Card,
Checkbox,
} from "antd"; } from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import { import {
createAnnotationTemplateUsingPost, createAnnotationTemplateUsingPost,
updateAnnotationTemplateByIdUsingPut, updateAnnotationTemplateByIdUsingPut,
} from "../annotation.api"; } from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model"; import type { AnnotationTemplate } from "../annotation.model";
import { DataTypeMap, ClassificationMap, AnnotationTypeMap } from "../annotation.const"; import { DataTypeMap, ClassificationMap, AnnotationTypeMap } from "../annotation.const";
import TagSelector from "./components/TagSelector"; import TemplateConfigurationForm from "../../components/TemplateConfigurationForm";
const { TextArea } = Input; const { TextArea } = Input;
const { Option } = Select; const { Option } = Select;
@@ -113,10 +108,6 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
} }
}; };
const needsOptions = (type: string) => {
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
};
return ( return (
<Modal <Modal
title={mode === "create" ? "创建模板" : "编辑模板"} title={mode === "create" ? "创建模板" : "编辑模板"}
@@ -199,222 +190,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
</Form.Item> </Form.Item>
</Space> </Space>
<Divider></Divider> <TemplateConfigurationForm form={form} />
<Form.List name="objects">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
<Space align="start" style={{ width: "100%" }}>
<Form.Item
{...field}
label="名称"
name={[field.name, "name"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="例如:image" />
</Form.Item>
<Form.Item
{...field}
label="类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<TagSelector type="object" />
</Form.Item>
<Form.Item
{...field}
label="值"
name={[field.name, "value"]}
rules={[
{ required: true, message: "必填" },
{ pattern: /^\$/, message: "必须以 $ 开头" },
]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="$image" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined
style={{ marginTop: 30, color: "red" }}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Card>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
<Divider></Divider>
<Form.List name="labels">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card
key={field.key}
size="small"
style={{ marginBottom: 12 }}
title={
<Space>
<span> {fields.indexOf(field) + 1}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const controlType = form.getFieldValue(["labels", field.name, "type"]);
const fromName = form.getFieldValue(["labels", field.name, "fromName"]);
if (controlType || fromName) {
return (
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#999' }}>
({fromName || '未命名'} - {controlType || '未设置类型'})
</span>
);
}
return null;
}}
</Form.Item>
</Space>
}
extra={
<MinusCircleOutlined
style={{ color: "red" }}
onClick={() => remove(field.name)}
/>
}
>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
<div style={{ display: 'grid', gridTemplateColumns: '180px 220px 1fr auto', gap: 12, alignItems: 'flex-end' }}>
<Form.Item
{...field}
label="来源名称"
name={[field.name, "fromName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="此控件的唯一标识符"
>
<Input placeholder="例如:choice" />
</Form.Item>
<Form.Item
{...field}
label="标注目标对象"
name={[field.name, "toName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="选择此控件将标注哪个数据对象"
dependencies={['objects']}
>
<Select placeholder="选择数据对象">
{(form.getFieldValue("objects") || []).map((obj: any, idx: number) => (
<Option key={idx} value={obj?.name || ''}>
{obj?.name || `对象 ${idx + 1}`} ({obj?.type || '未知类型'})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
{...field}
label="控件类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<TagSelector type="control" />
</Form.Item>
<Form.Item
{...field}
label=" "
name={[field.name, "required"]}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Checkbox></Checkbox>
</Form.Item>
</div>
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
const prevType = prevValues.labels?.[field.name]?.type;
const currType = currentValues.labels?.[field.name]?.type;
return prevType !== currType;
}}
>
{({ getFieldValue }) => {
const controlType = getFieldValue(["labels", field.name, "type"]);
const fieldName = controlType === "Choices" ? "options" : "labels";
if (needsOptions(controlType)) {
return (
<Form.Item
{...field}
label={controlType === "Choices" ? "选项" : "标签"}
name={[field.name, fieldName]}
rules={[{ required: true, message: "至少需要一个选项" }]}
style={{ marginBottom: 0 }}
>
<Select
mode="tags"
open={false}
placeholder={
controlType === "Choices"
? "输入选项内容,按回车添加。例如:是、否、不确定"
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
}
style={{ width: "100%" }}
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
{/* Row 3: 描述 */}
<Form.Item
{...field}
label="描述"
name={[field.name, "description"]}
style={{ marginBottom: 0 }}
tooltip="向标注人员显示的帮助信息"
>
<Input placeholder="为标注人员提供此控件的使用说明" maxLength={200} />
</Form.Item>
</Space>
</Card>
))}
<Button
type="dashed"
onClick={() =>
add({
fromName: "",
toName: "",
type: "Choices",
required: false,
})
}
block
icon={<PlusOutlined />}
>
</Button>
</>
)}
</Form.List>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -0,0 +1,294 @@
import React from "react";
import {
Form,
Input,
Select,
Button,
Space,
Divider,
Card,
Checkbox,
} from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import TagSelector from "../Template/components/TagSelector";
const { Option } = Select;
interface TemplateConfigurationFormProps {
form: any;
}
const TemplateConfigurationForm: React.FC<TemplateConfigurationFormProps> = ({
form,
}) => {
const needsOptions = (type: string) => {
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(
type
);
};
return (
<>
<Divider orientation="left"></Divider>
<Form.List name="objects">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
<Space align="start" style={{ width: "100%" }}>
<Form.Item
{...field}
label="名称"
name={[field.name, "name"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="例如:image" />
</Form.Item>
<Form.Item
{...field}
label="类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<TagSelector type="object" />
</Form.Item>
<Form.Item
{...field}
label="值"
name={[field.name, "value"]}
rules={[
{ required: true, message: "必填" },
{ pattern: /^\$/, message: "必须以 $ 开头" },
]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="$image" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined
style={{ marginTop: 30, color: "red" }}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Card>
))}
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
</Button>
</>
)}
</Form.List>
<Divider orientation="left"></Divider>
<Form.List name="labels">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card
key={field.key}
size="small"
style={{ marginBottom: 12 }}
title={
<Space>
<span> {fields.indexOf(field) + 1}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const controlType = form.getFieldValue([
"labels",
field.name,
"type",
]);
const fromName = form.getFieldValue([
"labels",
field.name,
"fromName",
]);
if (controlType || fromName) {
return (
<span
style={{
fontSize: 12,
fontWeight: "normal",
color: "#999",
}}
>
({fromName || "未命名"} -{" "}
{controlType || "未设置类型"})
</span>
);
}
return null;
}}
</Form.Item>
</Space>
}
extra={
<MinusCircleOutlined
style={{ color: "red" }}
onClick={() => remove(field.name)}
/>
}
>
<Space
direction="vertical"
style={{ width: "100%" }}
size="middle"
>
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
<div
style={{
display: "grid",
gridTemplateColumns: "180px 220px 1fr auto",
gap: 12,
alignItems: "flex-end",
}}
>
<Form.Item
{...field}
label="来源名称"
name={[field.name, "fromName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="此控件的唯一标识符"
>
<Input placeholder="例如:choice" />
</Form.Item>
<Form.Item
{...field}
label="标注目标对象"
name={[field.name, "toName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="选择此控件将标注哪个数据对象"
dependencies={["objects"]}
>
<Select placeholder="选择数据对象">
{(form.getFieldValue("objects") || []).map(
(obj: any, idx: number) => (
<Option key={idx} value={obj?.name || ""}>
{obj?.name || `对象 ${idx + 1}`} (
{obj?.type || "未知类型"})
</Option>
)
)}
</Select>
</Form.Item>
<Form.Item
{...field}
label="控件类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<TagSelector type="control" />
</Form.Item>
<Form.Item
{...field}
label=" "
name={[field.name, "required"]}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Checkbox></Checkbox>
</Form.Item>
</div>
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
const prevType = prevValues.labels?.[field.name]?.type;
const currType = currentValues.labels?.[field.name]?.type;
return prevType !== currType;
}}
>
{({ getFieldValue }) => {
const controlType = getFieldValue([
"labels",
field.name,
"type",
]);
const fieldName =
controlType === "Choices" ? "options" : "labels";
if (needsOptions(controlType)) {
return (
<Form.Item
{...field}
label={controlType === "Choices" ? "选项" : "标签"}
name={[field.name, fieldName]}
rules={[
{ required: true, message: "至少需要一个选项" },
]}
style={{ marginBottom: 0 }}
>
<Select
mode="tags"
open={false}
placeholder={
controlType === "Choices"
? "输入选项内容,按回车添加。例如:是、否、不确定"
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
}
style={{ width: "100%" }}
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
{/* Row 3: 描述 */}
<Form.Item
{...field}
label="描述"
name={[field.name, "description"]}
style={{ marginBottom: 0 }}
tooltip="向标注人员显示的帮助信息"
>
<Input
placeholder="为标注人员提供此控件的使用说明"
maxLength={200}
/>
</Form.Item>
</Space>
</Card>
))}
<Button
type="dashed"
onClick={() =>
add({
fromName: "",
toName: "",
type: "Choices",
required: false,
})
}
block
icon={<PlusOutlined />}
>
</Button>
</>
)}
</Form.List>
</>
);
};
export default TemplateConfigurationForm;