feat(annotation): 添加标注任务自定义配置功能

- 新增 LabelStudioEmbed 组件用于嵌入式标注界面预览
- 在创建标注任务对话框中添加 XML 配置编辑器
- 支持从现有模板加载配置并进行自定义修改
- 实现标注界面实时预览功能
- 后端支持直接传递 label_config 覆盖模板配置
- 更新 CreateAnnotationTaskRequest 模型添加 labelConfig 字段
This commit is contained in:
2026-01-18 14:12:12 +08:00
parent 87c2ef8a58
commit 01dcd16a98
4 changed files with 429 additions and 223 deletions

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef, useState, useMemo } from "react";
import { Spin, message } from "antd";
const LSF_IFRAME_SRC = "/lsf/lsf.html";
export interface LabelStudioEmbedProps {
config: string;
task?: any;
user?: any;
interfaces?: string[];
height?: string | number;
className?: string;
onSubmit?: (result: any) => void;
onUpdate?: (result: any) => void;
onError?: (error: any) => void;
}
export default function LabelStudioEmbed({
config,
task = { id: 1, data: { text: "Preview Text" } },
user = { id: "preview-user" },
interfaces = [
"panel",
"update",
"controls",
"side-column",
"annotations:tabs",
"annotations:menu",
"annotations:current",
"annotations:history",
],
height = "100%",
className = "",
onSubmit,
onUpdate,
onError,
}: LabelStudioEmbedProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [iframeReady, setIframeReady] = useState(false);
const [lsReady, setLsReady] = useState(false);
const origin = useMemo(() => window.location.origin, []);
const postToIframe = (type: string, payload?: any) => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type, payload }, origin);
};
useEffect(() => {
setIframeReady(false);
setLsReady(false);
}, [config]); // Reset when config changes
// Initialize LS when iframe is ready and config is available
useEffect(() => {
if (iframeReady && config) {
postToIframe("LS_INIT", {
labelConfig: config,
task,
user,
interfaces,
selectedAnnotationIndex: 0,
allowCreateEmptyAnnotation: true,
});
}
}, [iframeReady, config, task, user, interfaces]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== origin) return;
const msg = event.data || {};
if (!msg?.type) return;
if (msg.type === "LS_IFRAME_READY") {
setIframeReady(true);
return;
}
if (msg.type === "LS_READY") {
setLsReady(true);
return;
}
if (msg.type === "LS_EXPORT_RESULT" || msg.type === "LS_SUBMIT") {
if (onSubmit) onSubmit(msg.payload);
else if (onUpdate) onUpdate(msg.payload);
return;
}
if (msg.type === "LS_UPDATE_ANNOTATION") {
if (onUpdate) onUpdate(msg.payload);
return;
}
if (msg.type === "LS_ERROR") {
if (onError) onError(msg.payload);
else message.error(msg.payload?.message || "编辑器发生错误");
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [origin, onSubmit, onUpdate, onError]);
return (
<div className={`relative ${className}`} style={{ height }}>
{!lsReady && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<Spin tip={!iframeReady ? "加载资源..." : "初始化编辑器..."} />
</div>
)}
<iframe
ref={iframeRef}
title="Label Studio Frontend"
src={LSF_IFRAME_SRC}
className="w-full h-full border-0"
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api"; import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const"; import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select, message, Tabs, Slider, Checkbox } from "antd"; import { Button, Form, Input, Modal, Select, message, Tabs, Slider, Checkbox, Radio, Space, Typography } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -11,10 +11,12 @@ import {
import DatasetFileTransfer from "@/components/business/DatasetFileTransfer"; import DatasetFileTransfer from "@/components/business/DatasetFileTransfer";
import { DatasetType, type Dataset, type DatasetFile } from "@/pages/DataManagement/dataset.model"; import { DatasetType, type Dataset, type DatasetFile } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate } from "../../annotation.model"; import type { AnnotationTemplate } from "../../annotation.model";
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
const { Option } = Select; const { Option } = Select;
const COCO_CLASSES = [ const COCO_CLASSES = [
// ... (keep existing COCO_CLASSES)
{ id: 0, name: "person", label: "人" }, { id: 0, name: "person", label: "人" },
{ id: 1, name: "bicycle", label: "自行车" }, { id: 1, name: "bicycle", label: "自行车" },
{ id: 2, name: "car", label: "汽车" }, { id: 2, name: "car", label: "汽车" },
@@ -114,6 +116,10 @@ export default function CreateAnnotationTask({
const [nameManuallyEdited, setNameManuallyEdited] = useState(false); const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
const [activeMode, setActiveMode] = useState<"manual" | "auto">("manual"); const [activeMode, setActiveMode] = useState<"manual" | "auto">("manual");
// Custom template state
const [customXml, setCustomXml] = useState("");
const [showPreview, setShowPreview] = useState(false);
const [selectAllClasses, setSelectAllClasses] = useState(true); const [selectAllClasses, setSelectAllClasses] = useState(true);
const [selectedFilesMap, setSelectedFilesMap] = useState<Record<string, DatasetFile>>({}); const [selectedFilesMap, setSelectedFilesMap] = useState<Record<string, DatasetFile>>({});
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null); const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
@@ -126,17 +132,16 @@ export default function CreateAnnotationTask({
// Fetch datasets // Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({ const { data: datasetData } = await queryDatasetsUsingGet({
page: 0, page: 0,
pageSize: 1000, // Use camelCase for HTTP params pageSize: 1000,
}); });
setDatasets(datasetData.content.map(mapDataset) || []); setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates // Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({ const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1, page: 1,
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize') size: 100,
}); });
// The API returns: {code, message, data: {content, total, page, ...}}
if (templateResponse.code === 200 && templateResponse.data) { if (templateResponse.code === 200 && templateResponse.data) {
const fetchedTemplates = templateResponse.data.content || []; const fetchedTemplates = templateResponse.data.content || [];
console.log("Fetched templates:", fetchedTemplates); console.log("Fetched templates:", fetchedTemplates);
@@ -164,6 +169,8 @@ export default function CreateAnnotationTask({
setSelectedFilesMap({}); setSelectedFilesMap({});
setSelectedDataset(null); setSelectedDataset(null);
setImageFileCount(0); setImageFileCount(0);
setCustomXml("");
setShowPreview(false);
} }
}, [open, manualForm, autoForm]); }, [open, manualForm, autoForm]);
@@ -179,13 +186,19 @@ export default function CreateAnnotationTask({
const handleManualSubmit = async () => { const handleManualSubmit = async () => {
try { try {
const values = await manualForm.validateFields(); const values = await manualForm.validateFields();
if (!customXml.trim()) {
message.error("请配置标注模板或选择一个现有模板");
return;
}
setSubmitting(true); setSubmitting(true);
// Send templateId instead of labelingConfig
const requestData = { const requestData = {
name: values.name, name: values.name,
description: values.description, description: values.description,
datasetId: values.datasetId, datasetId: values.datasetId,
templateId: values.templateId, templateId: values.templateId, // Can be null/undefined if user just typed XML
labelConfig: customXml, // Pass the custom XML
}; };
await createAnnotationTaskUsingPost(requestData); await createAnnotationTaskUsingPost(requestData);
message?.success?.("创建标注任务成功"); message?.success?.("创建标注任务成功");
@@ -201,6 +214,7 @@ export default function CreateAnnotationTask({
}; };
const handleAutoSubmit = async () => { const handleAutoSubmit = async () => {
// ... (keep existing handleAutoSubmit)
try { try {
const values = await autoForm.validateFields(); const values = await autoForm.validateFields();
@@ -253,6 +267,7 @@ export default function CreateAnnotationTask({
}; };
return ( return (
<>
<Modal <Modal
open={open} open={open}
onCancel={onClose} onCancel={onClose}
@@ -349,26 +364,44 @@ export default function CreateAnnotationTask({
</div> </div>
{/* 描述变为可选 */} {/* 描述变为可选 */}
<Form.Item label="描述" name="description"> <Form.Item label="描述" name="description">
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} /> <TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={2} />
</Form.Item> </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>
<Button type="link" size="small" onClick={() => setShowPreview(true)} disabled={!customXml}>
</Button>
</div>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<Form.Item <Form.Item
label="标注模板" label="加载现有模板 (可选)"
name="templateId" name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]} style={{ marginBottom: 12 }}
help="选择模板后,配置代码将自动填充到下方编辑器中,您可以继续修改。"
> >
<Select <Select
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"} placeholder="选择一个模板作为基础(可选)"
showSearch showSearch
allowClear
optionFilterProp="label" optionFilterProp="label"
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
options={templates.map((template) => ({ options={templates.map((template) => ({
label: template.name, label: template.name,
value: template.id, value: template.id,
// Add description as subtitle
title: template.description, title: template.description,
config: template.labelConfig,
}))} }))}
onChange={(value, option: any) => {
if (option && option.config) {
setCustomXml(option.config);
} else if (!value) {
// If cleared, maybe clear XML? Or keep it?
// User might clear selection to say "I am customizing now".
// Let's keep it to be safe, or user can clear manually.
}
}}
optionRender={(option) => ( optionRender={(option) => (
<div> <div>
<div style={{ fontWeight: 500 }}>{option.label}</div> <div style={{ fontWeight: 500 }}>{option.label}</div>
@@ -381,6 +414,22 @@ export default function CreateAnnotationTask({
)} )}
/> />
</Form.Item> </Form.Item>
<Form.Item
label="XML 配置编辑器"
required
style={{ marginBottom: 0 }}
>
<TextArea
rows={8}
value={customXml}
onChange={(e) => setCustomXml(e.target.value)}
placeholder="<View>...</View>"
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
</div>
</Form> </Form>
), ),
}, },
@@ -485,5 +534,36 @@ export default function CreateAnnotationTask({
]} ]}
/> />
</Modal> </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: {
image: "https://labelstud.io/images/opa-header.png",
text: "这是示例文本,用于预览标注界面。",
audio: "https://labelstud.io/files/sample.wav"
}
}}
/>
)}
</div>
</Modal>
</>
); );
} }

View File

@@ -83,6 +83,11 @@ async def create_mapping(
label_config = template.label_config label_config = template.label_config
logger.debug(f"Template label config loaded for template: {template.name}") logger.debug(f"Template label config loaded for template: {template.name}")
# 如果直接提供了 label_config (自定义或修改后的),则覆盖模板配置
if request.label_config:
label_config = request.label_config
logger.debug("Using custom label config from request")
# DataMate-only:不再创建/依赖 Label Studio Server 项目。 # DataMate-only:不再创建/依赖 Label Studio Server 项目。
# 为兼容既有 schema 字段(labeling_project_id 长度 8),生成一个 8 位数字 ID。 # 为兼容既有 schema 字段(labeling_project_id 长度 8),生成一个 8 位数字 ID。
labeling_project_id = str(uuid.uuid4().int % 10**8).zfill(8) labeling_project_id = str(uuid.uuid4().int % 10**8).zfill(8)

View File

@@ -22,6 +22,7 @@ class DatasetMappingCreateRequest(BaseModel):
name: Optional[str] = Field(None, alias="name", description="标注项目名称") name: Optional[str] = Field(None, alias="name", description="标注项目名称")
description: Optional[str] = Field(None, alias="description", description="标注项目描述") description: Optional[str] = Field(None, alias="description", description="标注项目描述")
template_id: Optional[str] = Field(None, alias="templateId", description="标注模板ID") template_id: Optional[str] = Field(None, alias="templateId", description="标注模板ID")
label_config: Optional[str] = Field(None, alias="labelConfig", description="Label Studio XML配置")
class Config: class Config:
# allow population by field name when constructing model programmatically # allow population by field name when constructing model programmatically