Files
DataMate/frontend/src/pages/DataAnnotation/Create/components/CreateAnnotationTaskDialog.tsx
Jerry Yan 7a73322858 feat(annotation): 添加数据集文件内容预览功能
- 添加文件内容预览相关状态管理
- 实现支持多种文本格式文件的预览功能(JSON、JSONL、TXT、CSV等)
- 添加文件内容长度限制以避免页面卡顿
- 在数据集预览表格中添加文件名点击预览功能
- 创建文件内容预览弹窗界面
- 添加文件预览加载状态和错误处理
2026-01-19 19:24:42 +08:00

648 lines
22 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 TextArea from "antd/es/input/TextArea";
import { useEffect, useState } from "react";
import { Eye } from "lucide-react";
import {
createAnnotationTaskUsingPost,
queryAnnotationTemplatesUsingGet,
} from "../../annotation.api";
import { type Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate } from "../../annotation.model";
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
import TemplateConfigurationForm from "../../components/TemplateConfigurationForm";
export default function CreateAnnotationTask({
open,
onClose,
onRefresh,
}: {
open: boolean;
onClose: () => void;
onRefresh: () => void;
}) {
const { message } = App.useApp();
const [manualForm] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
// Custom template state
const [customXml, setCustomXml] = useState("");
const [showPreview, setShowPreview] = useState(false);
const [previewTaskData, setPreviewTaskData] = useState<Record<string, any>>({});
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
// 是否已选择模板(用于启用受限编辑模式)
const [hasSelectedTemplate, setHasSelectedTemplate] = useState(false);
// 数据集预览相关状态
const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false);
const [datasetPreviewData, setDatasetPreviewData] = useState<any[]>([]);
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("");
useEffect(() => {
if (!open) return;
const fetchData = async () => {
try {
// Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({
page: 0,
pageSize: 1000,
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100,
});
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 data:", error);
setTemplates([]);
}
};
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
useEffect(() => {
if (open) {
manualForm.resetFields();
setNameManuallyEdited(false);
setCustomXml("");
setShowPreview(false);
setPreviewTaskData({});
setConfigMode("template");
setHasSelectedTemplate(false);
setSelectedDatasetId(null);
setDatasetPreviewData([]);
}
}, [open, manualForm]);
// 预览数据集
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 || []);
setDatasetPreviewVisible(true);
} else {
message.error("获取数据集预览失败");
}
} catch (error) {
console.error("Preview dataset error:", error);
message.error("获取数据集预览失败");
} finally {
setDatasetPreviewLoading(false);
}
};
// 预览文件内容
const handlePreviewFileContent = async (file: any) => {
// 支持预览的文本文件类型
const textExtensions = ['.json', '.jsonl', '.txt', '.csv', '.tsv', '.xml', '.md', '.yaml', '.yml'];
const fileName = file.fileName?.toLowerCase() || '';
const isTextFile = textExtensions.some(ext => fileName.endsWith(ext));
if (!isTextFile) {
message.warning("仅支持预览文本类文件(JSON、JSONL、TXT、CSV 等)");
return;
}
setFileContentLoading(true);
setPreviewFileName(file.fileName);
try {
const response = await fetch(`/api/data-management/datasets/${selectedDatasetId}/files/${file.id}/download`);
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);
}
setFileContentVisible(true);
} catch (error) {
console.error("Preview file content error:", error);
message.error("获取文件内容失败");
} finally {
setFileContentLoading(false);
}
};
const generateXmlFromConfig = (objects: any[], labels: any[]) => {
let xml = '<View>\n';
// Objects
if (objects) {
objects.forEach((obj: any) => {
xml += ` <${obj.type} name="${obj.name}" value="${obj.value}" />\n`;
});
}
// Controls
if (labels) {
labels.forEach((lbl: any) => {
let attrs = `name="${lbl.fromName}" toName="${lbl.toName}"`;
if (lbl.required) attrs += ' required="true"';
xml += ` <${lbl.type} ${attrs}>\n`;
const options = lbl.type === 'Choices' ? lbl.options : lbl.labels;
if (options && options.length) {
options.forEach((opt: string) => {
if (lbl.type === 'Choices') {
xml += ` <Choice value="${opt}" />\n`;
} else {
xml += ` <Label value="${opt}" />\n`;
}
});
}
xml += ` </${lbl.type}>\n`;
});
}
xml += '</View>';
return xml;
};
// 根据 objects 配置生成预览用的示例数据
const generateExampleData = (objects: any[]) => {
const exampleUrls: Record<string, string> = {
Image: "https://labelstud.io/images/opa-header.png",
Audio: "https://labelstud.io/files/sample.wav",
Video: "https://labelstud.io/files/sample.mp4",
};
const exampleTexts: Record<string, string> = {
Text: "这是示例文本,用于预览标注界面。",
HyperText: "<p>这是示例 HTML 内容</p>",
Header: "示例标题",
Paragraphs: "段落一\n\n段落二\n\n段落三",
};
const data: Record<string, any> = {};
if (!objects || objects.length === 0) {
// 默认数据
return {
image: exampleUrls.Image,
text: exampleTexts.Text,
audio: exampleUrls.Audio,
};
}
objects.forEach((obj: any) => {
if (!obj?.name || !obj?.value) return;
// 变量名从 $varName 中提取
const varName = obj.value.startsWith("$") ? obj.value.slice(1) : obj.name;
if (exampleUrls[obj.type]) {
data[varName] = exampleUrls[obj.type];
} else if (exampleTexts[obj.type]) {
data[varName] = exampleTexts[obj.type];
} else {
// 未知类型,尝试根据名称猜测
const lowerName = varName.toLowerCase();
if (lowerName.includes("image") || lowerName.includes("img")) {
data[varName] = exampleUrls.Image;
} else if (lowerName.includes("audio") || lowerName.includes("sound")) {
data[varName] = exampleUrls.Audio;
} else if (lowerName.includes("video")) {
data[varName] = exampleUrls.Video;
} else {
data[varName] = exampleTexts.Text;
}
}
});
return data;
};
// 当选择模板时,加载模板配置到表单
const handleTemplateSelect = (value: string, option: any) => {
// 处理清除选择的情况
if (!value) {
setHasSelectedTemplate(false);
setCustomXml("");
return;
}
setHasSelectedTemplate(true);
if (option && option.config) {
setCustomXml(option.config);
}
// 从模板列表中找到完整的模板数据
const selectedTemplate = templates.find(t => t.id === value);
if (selectedTemplate?.configuration) {
const { objects, labels } = selectedTemplate.configuration;
manualForm.setFieldsValue({
objects: objects || [{ name: "image", type: "Image", value: "$image" }],
labels: labels || [],
});
} else if (option && option.config) {
// 如果没有结构化配置,设置默认值
manualForm.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
}
};
const handleManualSubmit = async () => {
try {
const values = await manualForm.validateFields();
let finalLabelConfig = "";
const objects = values.objects;
const labels = values.labels;
if (configMode === "template") {
// 模板模式:优先使用可视化配置生成 XML,回退到直接使用 XML 编辑器内容
if (templateEditTab === "visual" && objects && objects.length > 0) {
finalLabelConfig = generateXmlFromConfig(objects, labels || []);
} else if (customXml.trim()) {
finalLabelConfig = customXml;
} else {
message.error("请配置标注模板或选择一个现有模板");
return;
}
} else {
// 自定义模式
if (!objects || objects.length === 0) {
message.error("请至少配置一个数据对象");
return;
}
if (!labels || labels.length === 0) {
message.error("请至少配置一个标签控件");
return;
}
finalLabelConfig = generateXmlFromConfig(objects, labels);
}
setSubmitting(true);
const requestData = {
name: values.name,
description: values.description,
datasetId: values.datasetId,
templateId: configMode === 'template' ? values.templateId : undefined,
labelConfig: finalLabelConfig,
};
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.error(msg);
} finally {
setSubmitting(false);
}
};
const handleConfigModeChange = (e: any) => {
const mode = e.target.value;
setConfigMode(mode);
// 两种模式都需要初始化默认值
const currentObjects = manualForm.getFieldValue("objects");
if (!currentObjects || currentObjects.length === 0) {
manualForm.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
}
// 切换到模板模式时,重置 tab 到可视化
if (mode === "template") {
setTemplateEditTab("visual");
}
};
return (
<>
<Modal
open={open}
onCancel={onClose}
title="创建标注任务"
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: "请选择数据集" }]}
>
<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) => {
setSelectedDatasetId(value);
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
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>
{/* 标注模板选择 */}
<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">
<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={() => {
const objects = manualForm.getFieldValue("objects");
const labels = manualForm.getFieldValue("labels");
// 生成 XML
if (objects && objects.length > 0) {
const xml = generateXmlFromConfig(objects, labels || []);
setCustomXml(xml);
}
// 生成适配的示例数据
const exampleData = generateExampleData(objects);
setPreviewTaskData(exampleData);
setShowPreview(true);
}}
>
</Button>
</div>
</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="选择模板后,配置将自动填充到可视化编辑器中,您可以继续修改。"
>
<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>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
<TemplateConfigurationForm
form={manualForm}
restrictedMode={hasSelectedTemplate}
/>
</div>
</div>
) : (
<div className="bg-gray-50 p-4 rounded-md border border-gray-200" style={{ maxHeight: '400px', overflowY: 'auto' }}>
<TemplateConfigurationForm form={manualForm} />
</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={customXml}
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: any) => (
<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)}
title={`文件预览:${previewFileName}`}
width={800}
footer={[
<Button key="close" onClick={() => setFileContentVisible(false)}>
</Button>
]}
>
<pre
style={{
maxHeight: '500px',
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{fileContent}
</pre>
</Modal>
</>
);
}