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 = { code?: number; message?: string; data?: T; }; const isRecord = (value: unknown): value is Record => !!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((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([]); const [templates, setTemplates] = useState([]); const [submitting, setSubmitting] = useState(false); const [nameManuallyEdited, setNameManuallyEdited] = useState(false); const [labelConfig, setLabelConfig] = useState(""); const [showPreview, setShowPreview] = useState(false); const [previewTaskData, setPreviewTaskData] = useState>({}); const [configMode, setConfigMode] = useState<"template" | "custom">("template"); // 数据集预览相关状态 const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false); const [datasetPreviewData, setDatasetPreviewData] = useState([]); const [datasetPreviewLoading, setDatasetPreviewLoading] = useState(false); const [selectedDatasetId, setSelectedDatasetId] = useState(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) => { 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 = { 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: "

这是示例 HTML 内容

", 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 = {}; 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 ( <> } width={800} >
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
数据集
} name="datasetId" rules={[{ required: true, message: "请选择数据集" }]} help={isEditMode ? "数据集不可修改" : undefined} > setNameManuallyEdited(true)} /> {/* 描述变为可选 */}