feat: Add labeling template (#72)

* feat: Enhance annotation module with template management and validation

- Added DatasetMappingCreateRequest and DatasetMappingUpdateRequest schemas to handle dataset mapping requests with camelCase and snake_case support.
- Introduced Annotation Template schemas including CreateAnnotationTemplateRequest, UpdateAnnotationTemplateRequest, and AnnotationTemplateResponse for managing annotation templates.
- Implemented AnnotationTemplateService for creating, updating, retrieving, and deleting annotation templates, including validation of configurations and XML generation.
- Added utility class LabelStudioConfigValidator for validating Label Studio configurations and XML formats.
- Updated database schema for annotation templates and labeling projects to include new fields and constraints.
- Seeded initial annotation templates for various use cases including image classification, object detection, and text classification.

* feat: Enhance TemplateForm with improved validation and dynamic field rendering; update LabelStudio config validation for camelCase support

* feat: Update docker-compose.yml to mark datamate dataset volume and network as external
This commit is contained in:
Jason Wang
2025-11-11 09:14:14 +08:00
committed by GitHub
parent 451d3c8207
commit c5ccc56cca
24 changed files with 2794 additions and 253 deletions

View File

@@ -0,0 +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,
size: 1000,
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100, // Backend max is 100
});
// 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>
);
}

View File

@@ -3,10 +3,9 @@ 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 } from "../../annotation.api";
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import LabelingConfigEditor from "./LabelingConfigEditor";
import { useRef } from "react";
import type { AnnotationTemplate } from "../../annotation.model";
export default function CreateAnnotationTask({
open,
@@ -19,21 +18,42 @@ export default function CreateAnnotationTask({
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
const editorRef = useRef<any>(null);
const EDITOR_LIST_HEIGHT = 420;
useEffect(() => {
if (!open) return;
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet({
page: 0,
size: 1000,
});
setDatasets(data.content.map(mapDataset) || []);
const fetchData = async () => {
try {
// Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({
page: 0,
size: 1000,
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100, // Backend max is 100
});
// 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([]);
}
};
fetchDatasets();
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
@@ -48,26 +68,28 @@ export default function CreateAnnotationTask({
try {
const values = await form.validateFields();
setSubmitting(true);
await createAnnotationTaskUsingPost(values);
// 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 || "创建失败,请稍后重试";
// show a user friendly message
(message as any)?.error?.(msg);
} finally {
setSubmitting(false);
}
};
// Placeholder function: generates labeling interface from config
// For now it simply returns the parsed config (per requirement)
const generateLabelingInterface = (config: any) => {
return config;
};
return (
<Modal
open={open}
@@ -83,7 +105,7 @@ export default function CreateAnnotationTask({
</Button>
</>
}
width={1200}
width={800}
>
<Form form={form} layout="vertical">
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
@@ -132,67 +154,41 @@ export default function CreateAnnotationTask({
/>
</Form.Item>
</div>
{/* 描述变为可选 */}
<Form.Item label="描述" name="description">
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
</Form.Item>
{/* 标注页面设计 模块:左侧为配置编辑,右侧为预览(作为表单的一部分,与其他字段同级) */}
<div style={{ marginTop: 8 }}>
<label className="block font-medium mb-2"></label>
<div style={{ display: "grid", gridTemplateColumns: "minmax(360px, 1fr) 1fr", gridTemplateRows: "auto 1fr", gap: 16 }}>
{/* Row 1: buttons on the left, spacer on the right so preview aligns with editor below */}
<div style={{ gridColumn: 1, gridRow: 1, display: 'flex', gap: 8 }}>
<Button onClick={() => editorRef.current?.addLabel?.()}></Button>
<Button type="primary" onClick={() => editorRef.current?.generate?.()}></Button>
</div>
{/* empty spacer to occupy top-right cell so preview starts on the second row */}
<div style={{ gridColumn: 2, gridRow: 1 }} />
{/* Row 2, Col 1: 编辑列表(固定高度) */}
<div style={{ gridColumn: 1, gridRow: 2, height: EDITOR_LIST_HEIGHT, overflowY: 'auto', paddingRight: 8, border: '1px solid #e6e6e6', borderRadius: 6, padding: 12 }}>
<LabelingConfigEditor
ref={editorRef}
hideFooter={true}
initial={undefined}
onGenerate={(config: any) => {
form.setFieldsValue({ labelingConfig: JSON.stringify(config, null, 2), labelingInterface: JSON.stringify(generateLabelingInterface(config), null, 2) });
}}
/>
<Form.Item
name="labelingConfig"
rules={[
{
validator: async (_, value) => {
if (!value || value === "") return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error("请输入有效的 JSON"));
}
},
},
]}
style={{ display: "none" }}
>
<Input />
</Form.Item>
</div>
{/* Row 2, Col 2: 预览,与编辑列表在同一行,保持一致高度 */}
<div style={{ gridColumn: 2, gridRow: 2, display: 'flex', flexDirection: 'column' }}>
<Form.Item name="labelingInterface" style={{ flex: 1 }}>
<TextArea
placeholder="标注页面设计(只读,由标注配置生成)"
disabled
style={{ height: EDITOR_LIST_HEIGHT, resize: 'none' }}
/>
</Form.Item>
</div>
</div>
</div>
{/* 标注模板选择 */}
<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>
);

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal } from "antd";
import { Card, Button, Table, message, Modal, Tabs } from "antd";
import {
PlusOutlined,
EditOutlined,
@@ -16,13 +16,14 @@ import {
syncAnnotationTaskUsingPost,
} from "../annotation.api";
import { mapAnnotationTask } from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnptationTaskDialog";
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
import { ColumnType } from "antd/es/table";
import { TemplateList } from "../Template";
// Note: DevelopmentInProgress intentionally not used here
export default function DataAnnotation() {
// return <DevelopmentInProgress showTime="2025.10.30" />;
// navigate not needed for label studio external redirect
const [activeTab, setActiveTab] = useState("tasks");
const [viewMode, setViewMode] = useState<"list" | "card">("list");
const [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -46,10 +47,10 @@ export default function DataAnnotation() {
let mounted = true;
(async () => {
try {
const baseUrl = `http://${window.location.hostname}:${window.location.port + 1}`;
if (mounted) setLabelStudioBase(baseUrl);
const baseUrl = `http://${window.location.hostname}:${parseInt(window.location.port) + 1}`;
if (mounted) setLabelStudioBase(baseUrl);
} catch (e) {
if (mounted) setLabelStudioBase(null);
if (mounted) setLabelStudioBase(null);
}
})();
return () => {
@@ -294,73 +295,104 @@ export default function DataAnnotation() {
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center space-x-2">
{/* Batch action buttons - availability depends on selection count */}
<div className="flex items-center space-x-1">
<Button
onClick={() => handleBatchSync(50)}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Filters Toolbar */}
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={(keyword) =>
setSearchParams({ ...searchParams, keyword })
}
searchPlaceholder="搜索任务名称、描述"
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 20rem)" }}
/>
</Card>
) : (
<CardView data={tableData} operations={operations as any} pagination={pagination} loading={loading} />
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={fetchData}
{/* Tabs */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "tasks",
label: "标注任务",
children: (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and view controls */}
<div className="flex items-center gap-2">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={(keyword) =>
setSearchParams({ ...searchParams, keyword })
}
searchPlaceholder="搜索任务名称、描述"
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
</div>
{/* Right side: All action buttons */}
<div className="flex items-center gap-2">
<Button
onClick={() => handleBatchSync(50)}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
) : (
<CardView
data={tableData}
operations={operations as any}
pagination={pagination}
loading={loading}
/>
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={fetchData}
/>
</div>
),
},
{
key: "templates",
label: "标注模板",
children: <TemplateList />,
},
]}
/>
</div>
);

View File

@@ -0,0 +1,152 @@
import React from "react";
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
import type { AnnotationTemplate } from "../annotation.model";
const { Text, Paragraph } = Typography;
interface TemplateDetailProps {
visible: boolean;
template?: AnnotationTemplate;
onClose: () => void;
}
const TemplateDetail: React.FC<TemplateDetailProps> = ({
visible,
template,
onClose,
}) => {
if (!template) return null;
return (
<Modal
title="Template Details"
open={visible}
onCancel={onClose}
footer={null}
width={800}
>
<Descriptions bordered column={2}>
<Descriptions.Item label="Name" span={2}>
{template.name}
</Descriptions.Item>
<Descriptions.Item label="Description" span={2}>
{template.description || "-"}
</Descriptions.Item>
<Descriptions.Item label="Data Type">
<Tag color="cyan">{template.dataType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Labeling Type">
<Tag color="geekblue">{template.labelingType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Category">
<Tag color="blue">{template.category}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Style">
{template.style}
</Descriptions.Item>
<Descriptions.Item label="Type">
<Tag color={template.builtIn ? "gold" : "default"}>
{template.builtIn ? "Built-in" : "Custom"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Version">
{template.version}
</Descriptions.Item>
<Descriptions.Item label="Created At" span={2}>
{new Date(template.createdAt).toLocaleString()}
</Descriptions.Item>
{template.updatedAt && (
<Descriptions.Item label="Updated At" span={2}>
{new Date(template.updatedAt).toLocaleString()}
</Descriptions.Item>
)}
</Descriptions>
<Divider>Configuration</Divider>
<Card title="Data Objects" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }}>
{template.configuration.objects.map((obj, index) => (
<Card key={index} size="small" type="inner">
<Space>
<Text strong>Name:</Text>
<Tag>{obj.name}</Tag>
<Text strong>Type:</Text>
<Tag color="blue">{obj.type}</Tag>
<Text strong>Value:</Text>
<Tag color="green">{obj.value}</Tag>
</Space>
</Card>
))}
</Space>
</Card>
<Card title="Label Controls" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{template.configuration.labels.map((label, index) => (
<Card key={index} size="small" type="inner" title={`Control ${index + 1}`}>
<Space direction="vertical" style={{ width: "100%" }}>
<div>
<Text strong>From Name: </Text>
<Tag>{label.fromName}</Tag>
<Text strong style={{ marginLeft: 16 }}>To Name: </Text>
<Tag>{label.toName}</Tag>
<Text strong style={{ marginLeft: 16 }}>Type: </Text>
<Tag color="purple">{label.type}</Tag>
{label.required && <Tag color="red">Required</Tag>}
</div>
{label.description && (
<div>
<Text strong>Description: </Text>
<Text type="secondary">{label.description}</Text>
</div>
)}
{label.options && label.options.length > 0 && (
<div>
<Text strong>Options: </Text>
<div style={{ marginTop: 4 }}>
{label.options.map((opt, i) => (
<Tag key={i} color="cyan">{opt}</Tag>
))}
</div>
</div>
)}
{label.labels && label.labels.length > 0 && (
<div>
<Text strong>Labels: </Text>
<div style={{ marginTop: 4 }}>
{label.labels.map((lbl, i) => (
<Tag key={i} color="geekblue">{lbl}</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
))}
</Space>
</Card>
{template.labelConfig && (
<Card title="Label Studio XML Configuration" size="small">
<Paragraph>
<pre style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
maxHeight: 300
}}>
{template.labelConfig}
</pre>
</Paragraph>
</Card>
)}
</Modal>
);
};
export default TemplateDetail;

View File

@@ -0,0 +1,454 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Form,
Input,
Select,
Button,
Space,
message,
Divider,
Card,
Checkbox,
} from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import {
createAnnotationTemplateUsingPost,
updateAnnotationTemplateByIdUsingPut,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
const { TextArea } = Input;
const { Option } = Select;
interface TemplateFormProps {
visible: boolean;
mode: "create" | "edit";
template?: AnnotationTemplate;
onSuccess: () => void;
onCancel: () => void;
}
const TemplateForm: React.FC<TemplateFormProps> = ({
visible,
mode,
template,
onSuccess,
onCancel,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (visible && template && mode === "edit") {
form.setFieldsValue({
name: template.name,
description: template.description,
dataType: template.dataType,
labelingType: template.labelingType,
style: template.style,
category: template.category,
labels: template.configuration.labels,
objects: template.configuration.objects,
});
} else if (visible && mode === "create") {
form.resetFields();
// Set default values
form.setFieldsValue({
style: "horizontal",
category: "custom",
labels: [],
objects: [{ name: "image", type: "Image", value: "$image" }],
});
}
}, [visible, template, mode, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
console.log("Form values:", values);
const requestData = {
name: values.name,
description: values.description,
dataType: values.dataType,
labelingType: values.labelingType,
style: values.style,
category: values.category,
configuration: {
labels: values.labels,
objects: values.objects,
},
};
console.log("Request data:", requestData);
let response;
if (mode === "create") {
response = await createAnnotationTemplateUsingPost(requestData);
} else {
response = await updateAnnotationTemplateByIdUsingPut(template!.id, requestData);
}
if (response.code === 200) {
message.success(`模板${mode === "create" ? "创建" : "更新"}成功`);
form.resetFields();
onSuccess();
} else {
message.error(response.message || `模板${mode === "create" ? "创建" : "更新"}失败`);
}
} catch (error: any) {
if (error.errorFields) {
message.error("请填写所有必填字段");
} else {
message.error(`模板${mode === "create" ? "创建" : "更新"}失败`);
console.error(error);
}
} finally {
setLoading(false);
}
};
const controlTypes = [
{ value: "Choices", label: "选项 (单选/多选)" },
{ value: "RectangleLabels", label: "矩形框 (目标检测)" },
{ value: "PolygonLabels", label: "多边形" },
{ value: "Labels", label: "标签 (文本高亮)" },
{ value: "TextArea", label: "文本区域" },
{ value: "Rating", label: "评分" },
];
const objectTypes = [
{ value: "Image", label: "图像" },
{ value: "Text", label: "文本" },
{ value: "Audio", label: "音频" },
{ value: "Video", label: "视频" },
];
const needsOptions = (type: string) => {
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
};
return (
<Modal
title={mode === "create" ? "创建模板" : "编辑模板"}
open={visible}
onCancel={onCancel}
onOk={handleSubmit}
confirmLoading={loading}
width={900}
destroyOnClose
>
<Form
form={form}
layout="vertical"
style={{ maxHeight: "70vh", overflowY: "auto", paddingRight: 8 }}
>
<Form.Item
label="模板名称"
name="name"
rules={[{ required: true, message: "请输入模板名称" }]}
>
<Input placeholder="例如:产品质量分类" maxLength={100} />
</Form.Item>
<Form.Item label="描述" name="description">
<TextArea
placeholder="描述此模板的用途"
rows={2}
maxLength={500}
/>
</Form.Item>
<Space style={{ width: "100%" }} size="large">
<Form.Item
label="数据类型"
name="dataType"
rules={[{ required: true, message: "请选择数据类型" }]}
style={{ width: 200 }}
>
<Select placeholder="选择数据类型">
<Option value="image"></Option>
<Option value="text"></Option>
<Option value="audio"></Option>
<Option value="video"></Option>
</Select>
</Form.Item>
<Form.Item
label="标注类型"
name="labelingType"
rules={[{ required: true, message: "请选择标注类型" }]}
style={{ width: 220 }}
>
<Select placeholder="选择标注类型">
<Option value="classification"></Option>
<Option value="object-detection"></Option>
<Option value="segmentation"></Option>
<Option value="ner"></Option>
<Option value="multi-stage"></Option>
</Select>
</Form.Item>
<Form.Item
label="样式"
name="style"
style={{ width: 150 }}
>
<Select>
<Option value="horizontal"></Option>
<Option value="vertical"></Option>
</Select>
</Form.Item>
<Form.Item
label="分类"
name="category"
style={{ width: 180 }}
>
<Select>
<Option value="computer-vision"></Option>
<Option value="nlp"></Option>
<Option value="audio"></Option>
<Option value="quality-control"></Option>
<Option value="custom"></Option>
</Select>
</Form.Item>
</Space>
<Divider></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 }}
>
<Select>
{objectTypes.map((t) => (
<Option key={t.value} value={t.value}>
{t.label}
</Option>
))}
</Select>
</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 }}
>
<Select placeholder="选择控件类型">
{controlTypes.map((t) => (
<Option key={t.value} value={t.value}>
{t.label}
</Option>
))}
</Select>
</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>
</Modal>
);
};
export default TemplateForm;

View File

@@ -0,0 +1,393 @@
import React, { useState, useEffect } from "react";
import {
Button,
Table,
Space,
Tag,
message,
Input,
Select,
Tooltip,
Popconfirm,
Card,
} from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
FilterOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
queryAnnotationTemplatesUsingGet,
deleteAnnotationTemplateByIdUsingDelete,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TemplateForm from "./TemplateForm.tsx";
import TemplateDetail from "./TemplateDetail.tsx";
const { Search } = Input;
const { Option } = Select;
const TemplateList: React.FC = () => {
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
// Filters
const [searchText, setSearchText] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
const [dataTypeFilter, setDataTypeFilter] = useState<string | undefined>();
const [labelingTypeFilter, setLabelingTypeFilter] = useState<string | undefined>();
const [builtInFilter, setBuiltInFilter] = useState<boolean | undefined>();
// Modals
const [isFormVisible, setIsFormVisible] = useState(false);
const [isDetailVisible, setIsDetailVisible] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<AnnotationTemplate | undefined>();
const [formMode, setFormMode] = useState<"create" | "edit">("create");
useEffect(() => {
fetchTemplates();
}, [page, size, categoryFilter, dataTypeFilter, labelingTypeFilter, builtInFilter]);
const fetchTemplates = async () => {
setLoading(true);
try {
const params: any = {
page,
size,
};
if (categoryFilter) params.category = categoryFilter;
if (dataTypeFilter) params.dataType = dataTypeFilter;
if (labelingTypeFilter) params.labelingType = labelingTypeFilter;
if (builtInFilter !== undefined) params.builtIn = builtInFilter;
const response = await queryAnnotationTemplatesUsingGet(params);
if (response.code === 200 && response.data) {
setTemplates(response.data.content || []);
setTotal(response.data.total || 0);
}
} catch (error) {
message.error("获取模板列表失败");
console.error(error);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setFormMode("create");
setSelectedTemplate(undefined);
setIsFormVisible(true);
};
const handleEdit = (template: AnnotationTemplate) => {
setFormMode("edit");
setSelectedTemplate(template);
setIsFormVisible(true);
};
const handleView = (template: AnnotationTemplate) => {
setSelectedTemplate(template);
setIsDetailVisible(true);
};
const handleDelete = async (templateId: string) => {
try {
const response = await deleteAnnotationTemplateByIdUsingDelete(templateId);
if (response.code === 200) {
message.success("模板删除成功");
fetchTemplates();
} else {
message.error(response.message || "删除模板失败");
}
} catch (error) {
message.error("删除模板失败");
console.error(error);
}
};
const handleFormSuccess = () => {
setIsFormVisible(false);
fetchTemplates();
};
const handleClearFilters = () => {
setCategoryFilter(undefined);
setDataTypeFilter(undefined);
setLabelingTypeFilter(undefined);
setBuiltInFilter(undefined);
setSearchText("");
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
"computer-vision": "blue",
"nlp": "green",
"audio": "purple",
"quality-control": "orange",
"custom": "default",
};
return colors[category] || "default";
};
const columns: ColumnsType<AnnotationTemplate> = [
{
title: "模板名称",
dataIndex: "name",
key: "name",
width: 200,
ellipsis: true,
filteredValue: searchText ? [searchText] : null,
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toString().toLowerCase()) ||
(record.description?.toLowerCase().includes(value.toString().toLowerCase()) ?? false),
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: {
showTitle: false,
},
render: (description: string) => (
<Tooltip title={description}>
<div
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'normal',
lineHeight: '1.5em',
maxHeight: '3em',
}}
>
{description}
</div>
</Tooltip>
),
},
{
title: "数据类型",
dataIndex: "dataType",
key: "dataType",
width: 120,
render: (dataType: string) => (
<Tag color="cyan">{dataType}</Tag>
),
},
{
title: "标注类型",
dataIndex: "labelingType",
key: "labelingType",
width: 150,
render: (labelingType: string) => (
<Tag color="geekblue">{labelingType}</Tag>
),
},
{
title: "分类",
dataIndex: "category",
key: "category",
width: 150,
render: (category: string) => (
<Tag color={getCategoryColor(category)}>{category}</Tag>
),
},
{
title: "类型",
dataIndex: "builtIn",
key: "builtIn",
width: 100,
render: (builtIn: boolean) => (
<Tag color={builtIn ? "gold" : "default"}>
{builtIn ? "系统内置" : "自定义"}
</Tag>
),
},
{
title: "版本",
dataIndex: "version",
key: "version",
width: 80,
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: "操作",
key: "action",
width: 200,
fixed: "right",
render: (_, record) => (
<Space size="small">
<Tooltip title="查看详情">
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
/>
</Tooltip>
{!record.builtIn && (
<>
<Tooltip title="编辑">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Popconfirm
title="确定要删除这个模板吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="link"
danger
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</>
)}
</Space>
),
},
];
const hasActiveFilters = categoryFilter || dataTypeFilter || labelingTypeFilter || builtInFilter !== undefined;
return (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and Filters */}
<div className="flex items-center gap-2 flex-wrap">
<Search
placeholder="搜索模板..."
allowClear
style={{ width: 300 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<Select
placeholder="分类"
allowClear
style={{ width: 140 }}
value={categoryFilter}
onChange={setCategoryFilter}
>
<Option value="computer-vision"></Option>
<Option value="nlp"></Option>
<Option value="audio"></Option>
<Option value="quality-control"></Option>
<Option value="custom"></Option>
</Select>
<Select
placeholder="数据类型"
allowClear
style={{ width: 120 }}
value={dataTypeFilter}
onChange={setDataTypeFilter}
>
<Option value="image"></Option>
<Option value="text"></Option>
<Option value="audio"></Option>
<Option value="video"></Option>
</Select>
<Select
placeholder="标注类型"
allowClear
style={{ width: 140 }}
value={labelingTypeFilter}
onChange={setLabelingTypeFilter}
>
<Option value="classification"></Option>
<Option value="object-detection"></Option>
<Option value="segmentation"></Option>
<Option value="ner"></Option>
</Select>
<Select
placeholder="模板类型"
allowClear
style={{ width: 120 }}
value={builtInFilter}
onChange={setBuiltInFilter}
>
<Option value={true}></Option>
<Option value={false}></Option>
</Select>
{hasActiveFilters && (
<Button icon={<FilterOutlined />} onClick={handleClearFilters}>
</Button>
)}
</div>
{/* Right side: Create button */}
<div className="flex items-center gap-2">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
</div>
<Card>
<Table
columns={columns}
dataSource={templates}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total} 个模板`,
onChange: (page, pageSize) => {
setPage(page);
setSize(pageSize);
},
}}
scroll={{ x: 1400, y: "calc(100vh - 24rem)" }}
/>
</Card>
<TemplateForm
visible={isFormVisible}
mode={formMode}
template={selectedTemplate}
onSuccess={handleFormSuccess}
onCancel={() => setIsFormVisible(false)}
/>
<TemplateDetail
visible={isDetailVisible}
template={selectedTemplate}
onClose={() => setIsDetailVisible(false)}
/>
</div>
);
};
export default TemplateList;
export { TemplateList };

View File

@@ -0,0 +1,3 @@
export { default as TemplateList } from "./TemplateList";
export { default as TemplateForm } from "./TemplateForm";
export { default as TemplateDetail } from "./TemplateDetail";

View File

@@ -102,30 +102,30 @@ export function getAnnotationStatisticsUsingGet(params?: any) {
// 标注模板管理
export function queryAnnotationTemplatesUsingGet(params?: any) {
return get("/api/v1/annotation/templates", params);
return get("/api/annotation/templates", params);
}
export function createAnnotationTemplateUsingPost(data: any) {
return post("/api/v1/annotation/templates", data);
return post("/api/annotation/templates", data);
}
export function queryAnnotationTemplateByIdUsingGet(
templateId: string | number
) {
return get(`/api/v1/annotation/templates/${templateId}`);
return get(`/api/annotation/templates/${templateId}`);
}
export function updateAnnotationTemplateByIdUsingPut(
templateId: string | number,
data: any
) {
return put(`/api/v1/annotation/templates/${templateId}`, data);
return put(`/api/annotation/templates/${templateId}`, data);
}
export function deleteAnnotationTemplateByIdUsingDelete(
templateId: string | number
) {
return del(`/api/v1/annotation/templates/${templateId}`);
return del(`/api/annotation/templates/${templateId}`);
}
// 主动学习相关接口

View File

@@ -51,8 +51,9 @@ export function mapAnnotationTask(task: any) {
projId: labelingProjId,
name: task.name,
description: task.description || "",
createdAt: task.createdAt,
updatedAt: task.updatedAt,
datasetName: task.datasetName || task.dataset_name || "-",
createdAt: task.createdAt || task.created_at || "-",
updatedAt: task.updatedAt || task.updated_at || "-",
icon: <StickyNote />,
iconColor: "bg-blue-100",
status: {

View File

@@ -13,9 +13,9 @@ export interface AnnotationTask {
name: string;
labelingProjId: string;
datasetId: string;
annotationCount: number;
description?: string;
assignedTo?: string;
progress: number;
@@ -27,7 +27,54 @@ export interface AnnotationTask {
status: AnnotationTaskStatus;
totalDataCount: number;
type: DatasetType;
createdAt: string;
updatedAt: string;
}
// 标注模板相关类型
export interface LabelDefinition {
fromName: string;
toName: string;
type: string;
options?: string[];
labels?: string[];
required?: boolean;
description?: string;
}
export interface ObjectDefinition {
name: string;
type: string;
value: string;
}
export interface TemplateConfiguration {
labels: LabelDefinition[];
objects: ObjectDefinition[];
metadata?: Record<string, any>;
}
export interface AnnotationTemplate {
id: string;
name: string;
description?: string;
dataType: string;
labelingType: string;
configuration: TemplateConfiguration;
labelConfig?: string;
style: string;
category: string;
builtIn: boolean;
version: string;
createdAt: string;
updatedAt?: string;
}
export interface AnnotationTemplateListResponse {
content: AnnotationTemplate[];
total: number;
page: number;
size: number;
totalPages: number;
}