You've already forked DataMate
984 lines
33 KiB
TypeScript
984 lines
33 KiB
TypeScript
import { queryDatasetsUsingGet, queryDatasetFilesUsingGet } from "@/pages/DataManagement/dataset.api";
|
|
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
|
import { App, Button, Form, Input, Modal, Select, Radio, Table } from "antd";
|
|
import type { RadioChangeEvent } from "antd";
|
|
import TextArea from "antd/es/input/TextArea";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { Eye } from "lucide-react";
|
|
import {
|
|
createAnnotationTaskUsingPost,
|
|
getAnnotationTaskByIdUsingGet,
|
|
updateAnnotationTaskByIdUsingPut,
|
|
queryAnnotationTemplatesUsingGet,
|
|
} from "../../annotation.api";
|
|
import { DatasetType, type Dataset } from "@/pages/DataManagement/dataset.model";
|
|
import { DataType, type AnnotationTemplate, type AnnotationTask } from "../../annotation.model";
|
|
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
|
|
import TemplateConfigurationTreeEditor from "../../components/TemplateConfigurationTreeEditor";
|
|
import { useTagConfig } from "@/hooks/useTagConfig";
|
|
|
|
interface AnnotationTaskDialogProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onRefresh: () => void;
|
|
/** 编辑模式:传入要编辑的任务数据 */
|
|
editTask?: AnnotationTask | null;
|
|
}
|
|
|
|
type DatasetOption = Dataset & { icon?: ReactNode };
|
|
|
|
type DatasetPreviewFile = {
|
|
id: string;
|
|
fileName?: string;
|
|
fileSize?: number;
|
|
};
|
|
|
|
type AnnotationTaskDetail = {
|
|
name?: string;
|
|
description?: string;
|
|
datasetId?: string;
|
|
labelConfig?: string;
|
|
template?: { labelConfig?: string };
|
|
segmentationEnabled?: boolean;
|
|
};
|
|
|
|
type ApiResponse<T> = {
|
|
code?: number;
|
|
message?: string;
|
|
data?: T;
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const DEFAULT_SEGMENTATION_ENABLED = true;
|
|
const SEGMENTATION_OPTIONS = [
|
|
{ label: "需要切片段", value: true },
|
|
{ label: "不需要切片段", value: false },
|
|
];
|
|
const resolveTemplateDataType = (datasetType?: DatasetType) => {
|
|
switch (datasetType) {
|
|
case DatasetType.TEXT:
|
|
return DataType.TEXT;
|
|
case DatasetType.IMAGE:
|
|
return DataType.IMAGE;
|
|
case DatasetType.AUDIO:
|
|
return DataType.AUDIO;
|
|
case DatasetType.VIDEO:
|
|
return DataType.VIDEO;
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
const resolveTemplateTimestamp = (template: AnnotationTemplate) => {
|
|
const timestamp = template.updatedAt || template.createdAt;
|
|
const parsed = Date.parse(timestamp);
|
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
};
|
|
const resolveDefaultTemplate = (items: AnnotationTemplate[]) =>
|
|
items.reduce<AnnotationTemplate | undefined>((latest, current) => {
|
|
if (!latest) {
|
|
return current;
|
|
}
|
|
return resolveTemplateTimestamp(current) > resolveTemplateTimestamp(latest)
|
|
? current
|
|
: latest;
|
|
}, undefined);
|
|
|
|
export default function CreateAnnotationTask({
|
|
open,
|
|
onClose,
|
|
onRefresh,
|
|
editTask,
|
|
}: AnnotationTaskDialogProps) {
|
|
const isEditMode = !!editTask;
|
|
const { message } = App.useApp();
|
|
const [manualForm] = Form.useForm();
|
|
const [datasets, setDatasets] = useState<DatasetOption[]>([]);
|
|
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
|
|
|
const [labelConfig, setLabelConfig] = useState("");
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [previewTaskData, setPreviewTaskData] = useState<Record<string, unknown>>({});
|
|
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
|
|
|
|
// 数据集预览相关状态
|
|
const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false);
|
|
const [datasetPreviewData, setDatasetPreviewData] = useState<DatasetPreviewFile[]>([]);
|
|
const [datasetPreviewLoading, setDatasetPreviewLoading] = useState(false);
|
|
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
|
|
|
|
// 文件内容预览相关状态
|
|
const [fileContentVisible, setFileContentVisible] = useState(false);
|
|
const [fileContent, setFileContent] = useState("");
|
|
const [fileContentLoading, setFileContentLoading] = useState(false);
|
|
const [previewFileName, setPreviewFileName] = useState("");
|
|
const [previewFileType, setPreviewFileType] = useState<"text" | "image" | "video" | "audio">("text");
|
|
const [previewMediaUrl, setPreviewMediaUrl] = useState("");
|
|
|
|
// 任务详情加载状态(编辑模式)
|
|
const [taskDetailLoading, setTaskDetailLoading] = useState(false);
|
|
const { config: tagConfig } = useTagConfig(false);
|
|
|
|
const selectedDataset = useMemo(
|
|
() => datasets.find((dataset) => dataset.id === selectedDatasetId),
|
|
[datasets, selectedDatasetId]
|
|
);
|
|
const isTextDataset = selectedDataset?.datasetType === DatasetType.TEXT;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const fetchData = async () => {
|
|
try {
|
|
// Fetch datasets
|
|
const { data: datasetData } = await queryDatasetsUsingGet({
|
|
page: 0,
|
|
pageSize: 1000,
|
|
});
|
|
setDatasets(datasetData.content.map(mapDataset) || []);
|
|
|
|
} catch (error) {
|
|
console.error("Error fetching data:", error);
|
|
setTemplates([]);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, [open]);
|
|
|
|
const fetchTemplates = async (dataType?: string) => {
|
|
if (!dataType) {
|
|
setTemplates([]);
|
|
return;
|
|
}
|
|
try {
|
|
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
|
page: 1,
|
|
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
|
dataType,
|
|
});
|
|
|
|
if (templateResponse.code === 200 && templateResponse.data) {
|
|
const fetchedTemplates = templateResponse.data.content || [];
|
|
setTemplates(fetchedTemplates);
|
|
} else {
|
|
console.error("Failed to fetch templates:", templateResponse);
|
|
setTemplates([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching templates:", error);
|
|
setTemplates([]);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open || isEditMode) {
|
|
return;
|
|
}
|
|
if (!selectedDataset) {
|
|
setTemplates([]);
|
|
manualForm.setFieldsValue({ templateId: undefined });
|
|
setLabelConfig("");
|
|
return;
|
|
}
|
|
const dataType = resolveTemplateDataType(selectedDataset.datasetType);
|
|
fetchTemplates(dataType);
|
|
}, [isEditMode, manualForm, open, selectedDataset]);
|
|
|
|
useEffect(() => {
|
|
if (!open || isEditMode || configMode !== "template" || !selectedDataset) {
|
|
return;
|
|
}
|
|
if (templates.length === 0) {
|
|
manualForm.setFieldsValue({ templateId: undefined });
|
|
setLabelConfig("");
|
|
return;
|
|
}
|
|
const currentTemplateId = manualForm.getFieldValue("templateId");
|
|
const currentTemplate = templates.find((template) => template.id === currentTemplateId);
|
|
if (currentTemplate) {
|
|
return;
|
|
}
|
|
const defaultTemplate = resolveDefaultTemplate(templates);
|
|
if (defaultTemplate) {
|
|
manualForm.setFieldsValue({ templateId: defaultTemplate.id });
|
|
setLabelConfig(defaultTemplate.labelConfig || "");
|
|
}
|
|
}, [configMode, isEditMode, manualForm, open, selectedDataset, templates]);
|
|
|
|
// Reset form and manual-edit flag when modal opens, or load task data in edit mode
|
|
useEffect(() => {
|
|
if (open) {
|
|
manualForm.resetFields();
|
|
setNameManuallyEdited(false);
|
|
setLabelConfig("");
|
|
setShowPreview(false);
|
|
setPreviewTaskData({});
|
|
setDatasetPreviewData([]);
|
|
|
|
if (isEditMode && editTask) {
|
|
// 编辑模式:加载任务详情
|
|
setTaskDetailLoading(true);
|
|
getAnnotationTaskByIdUsingGet(editTask.id)
|
|
.then((res: ApiResponse<AnnotationTaskDetail>) => {
|
|
if (res.code === 200 && res.data) {
|
|
const taskDetail = res.data;
|
|
// 填充基本信息
|
|
manualForm.setFieldsValue({
|
|
name: taskDetail.name,
|
|
description: taskDetail.description,
|
|
datasetId: taskDetail.datasetId,
|
|
segmentationEnabled: typeof taskDetail.segmentationEnabled === "boolean"
|
|
? taskDetail.segmentationEnabled
|
|
: DEFAULT_SEGMENTATION_ENABLED,
|
|
});
|
|
if (taskDetail.datasetId) {
|
|
setSelectedDatasetId(taskDetail.datasetId);
|
|
}
|
|
|
|
// 获取实际的 labelConfig(优先使用任务自身的配置,回退到模板配置)
|
|
const configXml = taskDetail.labelConfig || taskDetail.template?.labelConfig;
|
|
|
|
if (configXml) {
|
|
setLabelConfig(configXml);
|
|
}
|
|
|
|
// 编辑模式始终使用 custom 配置模式(不改变结构,只改属性)
|
|
setConfigMode("custom");
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to load task detail:", err);
|
|
message.error("加载任务详情失败");
|
|
})
|
|
.finally(() => {
|
|
setTaskDetailLoading(false);
|
|
});
|
|
} else {
|
|
// 创建模式:重置为默认状态
|
|
setConfigMode("template");
|
|
setSelectedDatasetId(null);
|
|
manualForm.setFieldsValue({
|
|
segmentationEnabled: DEFAULT_SEGMENTATION_ENABLED,
|
|
});
|
|
}
|
|
}
|
|
}, [open, manualForm, isEditMode, editTask, message]);
|
|
|
|
// 预览数据集
|
|
const handlePreviewDataset = async () => {
|
|
if (!selectedDatasetId) {
|
|
message.warning("请先选择数据集");
|
|
return;
|
|
}
|
|
setDatasetPreviewLoading(true);
|
|
try {
|
|
const res = await queryDatasetFilesUsingGet(selectedDatasetId, { page: 0, size: 10 });
|
|
if (res.code === '0' && res.data) {
|
|
setDatasetPreviewData((res.data.content || []) as DatasetPreviewFile[]);
|
|
setDatasetPreviewVisible(true);
|
|
} else {
|
|
message.error("获取数据集预览失败");
|
|
}
|
|
} catch (error) {
|
|
console.error("Preview dataset error:", error);
|
|
message.error("获取数据集预览失败");
|
|
} finally {
|
|
setDatasetPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
// 预览文件内容
|
|
const handlePreviewFileContent = async (file: DatasetPreviewFile) => {
|
|
const fileName = file.fileName?.toLowerCase() || '';
|
|
|
|
// 文件类型扩展名映射
|
|
const textExtensions = ['.json', '.jsonl', '.txt', '.csv', '.tsv', '.xml', '.md', '.yaml', '.yml'];
|
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
|
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi'];
|
|
const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.m4a'];
|
|
|
|
const isTextFile = textExtensions.some(ext => fileName.endsWith(ext));
|
|
const isImageFile = imageExtensions.some(ext => fileName.endsWith(ext));
|
|
const isVideoFile = videoExtensions.some(ext => fileName.endsWith(ext));
|
|
const isAudioFile = audioExtensions.some(ext => fileName.endsWith(ext));
|
|
|
|
if (!isTextFile && !isImageFile && !isVideoFile && !isAudioFile) {
|
|
message.warning("不支持预览该文件类型");
|
|
return;
|
|
}
|
|
|
|
setFileContentLoading(true);
|
|
setPreviewFileName(file.fileName);
|
|
|
|
const fileUrl = `/api/data-management/datasets/${selectedDatasetId}/files/${file.id}/download`;
|
|
|
|
try {
|
|
if (isTextFile) {
|
|
// 文本文件:获取内容
|
|
const response = await fetch(fileUrl);
|
|
if (!response.ok) {
|
|
throw new Error('下载失败');
|
|
}
|
|
const text = await response.text();
|
|
// 限制预览内容长度
|
|
const maxLength = 50000;
|
|
if (text.length > maxLength) {
|
|
setFileContent(text.substring(0, maxLength) + '\n\n... (内容过长,仅显示前 50000 字符)');
|
|
} else {
|
|
setFileContent(text);
|
|
}
|
|
setPreviewFileType("text");
|
|
} else if (isImageFile) {
|
|
// 图片文件:直接使用 URL
|
|
setPreviewMediaUrl(fileUrl);
|
|
setPreviewFileType("image");
|
|
} else if (isVideoFile) {
|
|
// 视频文件:使用 URL
|
|
setPreviewMediaUrl(fileUrl);
|
|
setPreviewFileType("video");
|
|
} else if (isAudioFile) {
|
|
// 音频文件:使用 URL
|
|
setPreviewMediaUrl(fileUrl);
|
|
setPreviewFileType("audio");
|
|
}
|
|
setFileContentVisible(true);
|
|
} catch (error) {
|
|
console.error("Preview file content error:", error);
|
|
message.error("获取文件内容失败");
|
|
} finally {
|
|
setFileContentLoading(false);
|
|
}
|
|
};
|
|
|
|
const DEFAULT_OBJECT_TAGS = [
|
|
"Image",
|
|
"Text",
|
|
"Audio",
|
|
"Video",
|
|
"HyperText",
|
|
"PDF",
|
|
"Markdown",
|
|
"Paragraphs",
|
|
"Table",
|
|
"AudioPlus",
|
|
"Timeseries",
|
|
"TimeSeries",
|
|
"Vector",
|
|
"Chat",
|
|
];
|
|
const DEFAULT_LABELING_CONTROL_TAGS = [
|
|
"Choices",
|
|
"Labels",
|
|
"RectangleLabels",
|
|
"PolygonLabels",
|
|
"EllipseLabels",
|
|
"KeyPointLabels",
|
|
"BrushLabels",
|
|
"TextArea",
|
|
"Number",
|
|
"DateTime",
|
|
"Rating",
|
|
"Taxonomy",
|
|
"ParagraphLabels",
|
|
"HyperTextLabels",
|
|
"Relations",
|
|
"Relation",
|
|
"Pairwise",
|
|
"TimeseriesLabels",
|
|
"TimeSeriesLabels",
|
|
"VectorLabels",
|
|
"VideoRectangle",
|
|
"MagicWand",
|
|
"BitmaskLabels",
|
|
];
|
|
|
|
const resolveObjectTags = () => {
|
|
const configTags = tagConfig?.objects ? Object.keys(tagConfig.objects) : [];
|
|
return new Set(configTags.length > 0 ? configTags : DEFAULT_OBJECT_TAGS);
|
|
};
|
|
|
|
const resolveLabelingControlTags = () => {
|
|
if (tagConfig?.controls) {
|
|
const labelingTags = Object.entries(tagConfig.controls)
|
|
.filter(([, config]) => config.category === "labeling")
|
|
.map(([tag]) => tag);
|
|
if (labelingTags.length > 0) {
|
|
return new Set(labelingTags);
|
|
}
|
|
}
|
|
return new Set(DEFAULT_LABELING_CONTROL_TAGS);
|
|
};
|
|
|
|
const parseXmlElements = (xml: string): Element[] => {
|
|
if (!xml) return [];
|
|
try {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xml, "text/xml");
|
|
if (doc.getElementsByTagName("parsererror").length > 0) {
|
|
return [];
|
|
}
|
|
return Array.from(doc.getElementsByTagName("*"));
|
|
} catch (error) {
|
|
console.error("解析 XML 失败", error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const extractObjectsFromLabelConfig = (xml: string) => {
|
|
const objectTags = resolveObjectTags();
|
|
const elements = parseXmlElements(xml);
|
|
return elements
|
|
.filter((element) => objectTags.has(element.tagName))
|
|
.map((element) => ({
|
|
name: element.getAttribute("name") || "",
|
|
type: element.tagName,
|
|
value: element.getAttribute("value") || "",
|
|
}))
|
|
.filter((item) => item.name || item.value);
|
|
};
|
|
|
|
const generatePreviewTaskDataFromLabelConfig = (xml: string) => {
|
|
const exampleDataByType: Record<string, unknown> = {
|
|
Image: "https://labelstud.io/images/opa-header.png",
|
|
Audio: "https://labelstud.io/files/sample.wav",
|
|
AudioPlus: "https://labelstud.io/files/sample.wav",
|
|
Video: "https://labelstud.io/files/sample.mp4",
|
|
Text: "这是示例文本,用于预览标注界面。",
|
|
HyperText: "<p>这是示例 HTML 内容</p>",
|
|
Markdown: "# 示例标题\n\n这里是示例 Markdown 内容。",
|
|
Paragraphs: "段落一\n\n段落二\n\n段落三",
|
|
PDF: "https://labelstud.io/files/sample.pdf",
|
|
Table: [
|
|
{ key: "字段A", value: "示例值A" },
|
|
{ key: "字段B", value: "示例值B" },
|
|
],
|
|
Chat: [
|
|
{ text: "你好,我想了解一下产品。", author: "user" },
|
|
{ text: "当然可以,请告诉我你的需求。", author: "assistant" },
|
|
],
|
|
Timeseries: "https://labelstud.io/files/sample.csv",
|
|
TimeSeries: "https://labelstud.io/files/sample.csv",
|
|
Vector: [0.12, 0.52, 0.33],
|
|
};
|
|
|
|
const objects = extractObjectsFromLabelConfig(xml);
|
|
if (objects.length === 0) {
|
|
return {
|
|
image: exampleDataByType.Image,
|
|
text: exampleDataByType.Text,
|
|
audio: exampleDataByType.Audio,
|
|
};
|
|
}
|
|
|
|
const data: Record<string, unknown> = {};
|
|
objects.forEach((obj) => {
|
|
const name = obj.name || "";
|
|
const value = obj.value || "";
|
|
const varName = value.startsWith("$") ? value.slice(1) : name || value;
|
|
if (!varName) return;
|
|
|
|
if (exampleDataByType[obj.type]) {
|
|
data[varName] = exampleDataByType[obj.type];
|
|
return;
|
|
}
|
|
|
|
const lowerName = varName.toLowerCase();
|
|
if (lowerName.includes("image") || lowerName.includes("img")) {
|
|
data[varName] = exampleDataByType.Image;
|
|
} else if (lowerName.includes("audio") || lowerName.includes("sound")) {
|
|
data[varName] = exampleDataByType.Audio;
|
|
} else if (lowerName.includes("video")) {
|
|
data[varName] = exampleDataByType.Video;
|
|
} else if (lowerName.includes("chat")) {
|
|
data[varName] = exampleDataByType.Chat;
|
|
} else {
|
|
data[varName] = exampleDataByType.Text;
|
|
}
|
|
});
|
|
|
|
return data;
|
|
};
|
|
|
|
// 当选择模板时,加载 XML 配置到树编辑器(仅快速填充)
|
|
const handleTemplateSelect = (value: string, option: unknown) => {
|
|
if (!value) {
|
|
setLabelConfig("");
|
|
return;
|
|
}
|
|
|
|
const selectedTemplate = templates.find((template) => template.id === value);
|
|
const configXml = selectedTemplate?.labelConfig
|
|
|| (isRecord(option) && typeof option.config === "string" ? option.config : "")
|
|
|| "";
|
|
setLabelConfig(configXml);
|
|
};
|
|
|
|
const validateLabelConfigForSubmit = () => {
|
|
const xml = labelConfig.trim();
|
|
if (!xml) {
|
|
message.error("请配置标注模板");
|
|
return false;
|
|
}
|
|
|
|
const elements = parseXmlElements(xml);
|
|
if (elements.length === 0) {
|
|
message.error("标注配置 XML 格式有误");
|
|
return false;
|
|
}
|
|
|
|
const objectTags = resolveObjectTags();
|
|
const labelingControlTags = resolveLabelingControlTags();
|
|
const objectCount = elements.filter((element) => objectTags.has(element.tagName)).length;
|
|
const labelingControlCount = elements.filter((element) => labelingControlTags.has(element.tagName)).length;
|
|
|
|
if (objectCount === 0) {
|
|
message.error("至少需要一个数据对象标签");
|
|
return false;
|
|
}
|
|
|
|
if (labelingControlCount === 0) {
|
|
message.error("至少需要一个标注控件标签");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleManualSubmit = async () => {
|
|
try {
|
|
const values = await manualForm.validateFields();
|
|
if (!validateLabelConfigForSubmit()) {
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
const requestData = {
|
|
name: values.name,
|
|
description: values.description,
|
|
datasetId: values.datasetId,
|
|
templateId: configMode === "template" ? values.templateId : undefined,
|
|
labelConfig: labelConfig.trim(),
|
|
};
|
|
if (!isEditMode && isTextDataset) {
|
|
requestData.segmentationEnabled =
|
|
values.segmentationEnabled ?? DEFAULT_SEGMENTATION_ENABLED;
|
|
}
|
|
|
|
if (isEditMode && editTask) {
|
|
// 编辑模式:调用更新接口
|
|
await updateAnnotationTaskByIdUsingPut(editTask.id, requestData);
|
|
message.success("更新标注任务成功");
|
|
} else {
|
|
// 创建模式:调用创建接口
|
|
await createAnnotationTaskUsingPost(requestData);
|
|
message.success("创建标注任务成功");
|
|
}
|
|
onClose();
|
|
onRefresh();
|
|
} catch (err: unknown) {
|
|
console.error(isEditMode ? "Update annotation task failed" : "Create annotation task failed", err);
|
|
const error = err as { message?: string; data?: { message?: string } };
|
|
const msg = error?.message || error?.data?.message || (isEditMode ? "更新失败,请稍后重试" : "创建失败,请稍后重试");
|
|
message.error(msg);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleConfigModeChange = (e: RadioChangeEvent) => {
|
|
const mode = e.target.value;
|
|
setConfigMode(mode);
|
|
if (mode === "custom") {
|
|
manualForm.setFieldsValue({ templateId: undefined });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
open={open}
|
|
onCancel={onClose}
|
|
title={isEditMode ? "编辑标注任务" : "创建标注任务"}
|
|
loading={taskDetailLoading}
|
|
footer={
|
|
<>
|
|
<Button onClick={onClose} disabled={submitting}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
onClick={handleManualSubmit}
|
|
loading={submitting}
|
|
>
|
|
确定
|
|
</Button>
|
|
</>
|
|
}
|
|
width={800}
|
|
>
|
|
<Form form={manualForm} layout="vertical">
|
|
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Form.Item
|
|
label={
|
|
<div className="flex items-center justify-between w-full">
|
|
<span>数据集</span>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<Eye size={14} />}
|
|
disabled={!selectedDatasetId}
|
|
loading={datasetPreviewLoading}
|
|
onClick={handlePreviewDataset}
|
|
className="p-0 h-auto"
|
|
>
|
|
预览
|
|
</Button>
|
|
</div>
|
|
}
|
|
name="datasetId"
|
|
rules={[{ required: true, message: "请选择数据集" }]}
|
|
help={isEditMode ? "数据集不可修改" : undefined}
|
|
>
|
|
<Select
|
|
placeholder="请选择数据集"
|
|
disabled={isEditMode}
|
|
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.icon}</span>
|
|
<span>{dataset.name}</span>
|
|
</div>
|
|
<div className="text-xs text-gray-500">{dataset.size}</div>
|
|
</div>
|
|
),
|
|
value: dataset.id,
|
|
};
|
|
})}
|
|
onChange={(value) => {
|
|
setSelectedDatasetId(value);
|
|
if (!isEditMode) {
|
|
manualForm.setFieldsValue({ templateId: undefined });
|
|
setLabelConfig("");
|
|
}
|
|
const dataset = datasets.find((item) => item.id === value);
|
|
if (dataset?.datasetType === DatasetType.TEXT) {
|
|
const currentValue = manualForm.getFieldValue("segmentationEnabled");
|
|
if (currentValue === undefined) {
|
|
manualForm.setFieldsValue({
|
|
segmentationEnabled: DEFAULT_SEGMENTATION_ENABLED,
|
|
});
|
|
}
|
|
} else if (dataset) {
|
|
manualForm.setFieldsValue({ segmentationEnabled: false });
|
|
}
|
|
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
|
if (!nameManuallyEdited) {
|
|
const ds = datasets.find((d) => d.id === value);
|
|
if (ds) {
|
|
let defaultName = ds.name || "";
|
|
if (defaultName.length < 3) {
|
|
defaultName = `${defaultName}-标注`;
|
|
}
|
|
manualForm.setFieldsValue({ name: defaultName });
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="标注工程名称"
|
|
name="name"
|
|
rules={[
|
|
{
|
|
validator: (_rule, value) => {
|
|
const trimmed = (value || "").trim();
|
|
if (!trimmed) {
|
|
return Promise.reject(new Error("请输入任务名称"));
|
|
}
|
|
if (trimmed.length < 3) {
|
|
return Promise.reject(
|
|
new Error("任务名称至少需要 3 个字符(不含首尾空格,Label Studio 限制)")
|
|
);
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Input
|
|
placeholder="输入标注工程名称"
|
|
onChange={() => setNameManuallyEdited(true)}
|
|
/>
|
|
</Form.Item>
|
|
</div>
|
|
{/* 描述变为可选 */}
|
|
<Form.Item label="描述" name="description">
|
|
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={2} />
|
|
</Form.Item>
|
|
|
|
{selectedDatasetId && isTextDataset && (
|
|
<Form.Item
|
|
label="段落切片"
|
|
name="segmentationEnabled"
|
|
initialValue={DEFAULT_SEGMENTATION_ENABLED}
|
|
extra={isEditMode ? "编辑模式暂不支持修改" : "仅文本数据集可配置该项"}
|
|
>
|
|
<Radio.Group
|
|
options={SEGMENTATION_OPTIONS}
|
|
optionType="button"
|
|
buttonStyle="solid"
|
|
disabled={isEditMode}
|
|
/>
|
|
</Form.Item>
|
|
)}
|
|
|
|
{/* 标注模板选择 */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-gray-700 after:content-['*'] after:text-red-500 after:ml-1">标注配置</span>
|
|
|
|
<div className="flex gap-2">
|
|
{!isEditMode && (
|
|
<Radio.Group value={configMode} onChange={handleConfigModeChange} size="small" buttonStyle="solid">
|
|
<Radio.Button value="template">现有模板</Radio.Button>
|
|
{/*<Radio.Button value="custom">自定义配置</Radio.Button>*/}
|
|
</Radio.Group>
|
|
)}
|
|
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
onClick={() => {
|
|
if (!labelConfig.trim()) {
|
|
message.warning("请先配置标注模板");
|
|
return;
|
|
}
|
|
|
|
const exampleData = generatePreviewTaskDataFromLabelConfig(labelConfig);
|
|
setPreviewTaskData(exampleData);
|
|
setShowPreview(true);
|
|
}}
|
|
>
|
|
预览配置效果
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isEditMode ? (
|
|
// 编辑模式:只允许修改标签取值
|
|
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
|
<div className="text-sm text-gray-500 mb-3 bg-blue-50 p-2 rounded border border-blue-200">
|
|
编辑模式下,模板结构(数据对象、控件类型等)不可修改,仅可修改属性值(来源名称、标签/选项取值等)。
|
|
</div>
|
|
<TemplateConfigurationTreeEditor
|
|
value={labelConfig}
|
|
onChange={setLabelConfig}
|
|
readOnlyStructure={true}
|
|
height={360}
|
|
/>
|
|
</div>
|
|
) : configMode === 'template' ? (
|
|
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
|
<Form.Item
|
|
label="加载现有模板"
|
|
name="templateId"
|
|
style={{ marginBottom: 12 }}
|
|
help="选择模板后,配置将自动填充到可视化编辑器中,您可以继续修改。"
|
|
rules={[{ required: true, message: "请选择标注模板" }]}
|
|
>
|
|
<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>
|
|
)}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<TemplateConfigurationTreeEditor
|
|
value={labelConfig}
|
|
onChange={setLabelConfig}
|
|
height={360}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
|
<TemplateConfigurationTreeEditor
|
|
value={labelConfig}
|
|
onChange={setLabelConfig}
|
|
height={360}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Preview Modal */}
|
|
<Modal
|
|
open={showPreview}
|
|
onCancel={() => setShowPreview(false)}
|
|
title="标注界面预览"
|
|
width={1000}
|
|
footer={[
|
|
<Button key="close" onClick={() => setShowPreview(false)}>
|
|
关闭
|
|
</Button>
|
|
]}
|
|
>
|
|
<div style={{ height: '600px', overflow: 'hidden' }}>
|
|
{showPreview && (
|
|
<LabelStudioEmbed
|
|
config={labelConfig}
|
|
task={{
|
|
id: 1,
|
|
data: previewTaskData,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* 数据集预览弹窗 */}
|
|
<Modal
|
|
open={datasetPreviewVisible}
|
|
onCancel={() => setDatasetPreviewVisible(false)}
|
|
title="数据集预览(前10条文件)"
|
|
width={700}
|
|
footer={[
|
|
<Button key="close" onClick={() => setDatasetPreviewVisible(false)}>
|
|
关闭
|
|
</Button>
|
|
]}
|
|
>
|
|
<div className="mb-2 text-xs text-gray-500">点击文件名可预览文件内容(支持文本、图片、音频、视频)</div>
|
|
<Table
|
|
dataSource={datasetPreviewData}
|
|
columns={[
|
|
{
|
|
title: "文件名",
|
|
dataIndex: "fileName",
|
|
key: "fileName",
|
|
ellipsis: true,
|
|
render: (text: string, record: DatasetPreviewFile) => (
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
className="p-0 h-auto text-left"
|
|
loading={fileContentLoading && previewFileName === text}
|
|
onClick={() => handlePreviewFileContent(record)}
|
|
>
|
|
{text}
|
|
</Button>
|
|
),
|
|
},
|
|
{
|
|
title: "大小",
|
|
dataIndex: "fileSize",
|
|
key: "fileSize",
|
|
width: 120,
|
|
render: (value: number) => {
|
|
if (!value) return "-";
|
|
if (value < 1024) return `${value} B`;
|
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
|
},
|
|
},
|
|
]}
|
|
rowKey="id"
|
|
pagination={false}
|
|
scroll={{ y: 300 }}
|
|
size="small"
|
|
/>
|
|
</Modal>
|
|
|
|
{/* 文件内容预览弹窗 */}
|
|
<Modal
|
|
open={fileContentVisible}
|
|
onCancel={() => {
|
|
setFileContentVisible(false);
|
|
setPreviewMediaUrl("");
|
|
setFileContent("");
|
|
}}
|
|
title={`文件预览:${previewFileName}`}
|
|
width={previewFileType === "text" ? 800 : 700}
|
|
footer={[
|
|
<Button key="close" onClick={() => {
|
|
setFileContentVisible(false);
|
|
setPreviewMediaUrl("");
|
|
setFileContent("");
|
|
}}>
|
|
关闭
|
|
</Button>
|
|
]}
|
|
>
|
|
{previewFileType === "text" && (
|
|
<pre
|
|
style={{
|
|
maxHeight: '500px',
|
|
overflow: 'auto',
|
|
backgroundColor: '#f5f5f5',
|
|
padding: '12px',
|
|
borderRadius: '4px',
|
|
fontSize: '12px',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-all',
|
|
}}
|
|
>
|
|
{fileContent}
|
|
</pre>
|
|
)}
|
|
{previewFileType === "image" && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<img
|
|
src={previewMediaUrl}
|
|
alt={previewFileName}
|
|
style={{ maxWidth: '100%', maxHeight: '500px', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{previewFileType === "video" && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<video
|
|
src={previewMediaUrl}
|
|
controls
|
|
style={{ maxWidth: '100%', maxHeight: '500px' }}
|
|
>
|
|
您的浏览器不支持视频播放
|
|
</video>
|
|
</div>
|
|
)}
|
|
{previewFileType === "audio" && (
|
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
|
<audio src={previewMediaUrl} controls style={{ width: '100%' }}>
|
|
您的浏览器不支持音频播放
|
|
</audio>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|