feat(annotation): 替换模板配置表单为树形编辑器组件

- 移除 TemplateConfigurationForm 组件并引入 TemplateConfigurationTreeEditor
- 使用 useTagConfig Hook 获取标签配置
- 将自定义XML状态 customXml 替换为 labelConfig
- 删除模板编辑标签页和选择模板状态管理
- 更新XML解析逻辑支持更多对象和标注控件类型
- 添加配置验证功能确保至少包含数据对象和标注控件
- 在模板详情页面使用树形编辑器显示配置详情
- 更新任务创建页面集成新的树形配置编辑器
- 调整预览数据生成功能适配新的XML解析方式
This commit is contained in:
2026-01-23 16:11:59 +08:00
parent 76d06b9809
commit 3f566a0b08
14 changed files with 1383 additions and 900 deletions

View File

@@ -1,180 +1,108 @@
import type React from "react";
import { useEffect, useState } from "react";
import { Card, Button, Input, Select, Divider, Form, message, Radio } from "antd";
import { Button, Input, Select, Form, message, Radio } from "antd";
import TextArea from "antd/es/input/TextArea";
import {
DatabaseOutlined,
CheckOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { mockTemplates } from "@/mock/annotation";
import CustomTemplateDialog from "./components/CustomTemplateDialog";
import TemplateConfigurationForm from "../components/TemplateConfigurationForm";
import { DatabaseOutlined } from "@ant-design/icons";
import { Link, useNavigate } from "react-router";
import { ArrowLeft } from "lucide-react";
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import type { Dataset } from "@/pages/DataManagement/dataset.model";
import {
DatasetType,
type Dataset,
} from "@/pages/DataManagement/dataset.model";
interface Template {
id: string;
name: string;
category: string;
description: string;
type: "text" | "image";
preview?: string;
icon: React.ReactNode;
isCustom?: boolean;
}
const templateCategories = ["Computer Vision", "Natural Language Processing"];
createAnnotationTaskUsingPost,
queryAnnotationTemplatesUsingGet,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
export default function AnnotationTaskCreate() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
useState(false);
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
const [searchQuery, setSearchQuery] = useState("");
const [datasetFilter, setDatasetFilter] = useState("all");
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null
);
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
// 用于Form的受控数据
const [formValues, setFormValues] = useState({
name: "",
description: "",
datasetId: "",
templateId: "",
});
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [labelConfig, setLabelConfig] = useState("");
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
const [submitting, setSubmitting] = useState(false);
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet();
setDatasets(data.results || []);
try {
const { data } = await queryDatasetsUsingGet({ page: 0, pageSize: 1000 });
const list = data?.content || [];
setDatasets(list.map((item: any) => mapDataset(item)) || []);
} catch (error) {
console.error("加载数据集失败:", error);
message.error("加载数据集失败");
}
};
const fetchTemplates = async () => {
try {
const response = await queryAnnotationTemplatesUsingGet({ page: 1, size: 200 });
if (response.code === 200 && response.data) {
setTemplates(response.data.content || []);
} else {
message.error(response.message || "加载模板失败");
}
} catch (error) {
console.error("加载模板失败:", error);
message.error("加载模板失败");
}
};
useEffect(() => {
fetchDatasets();
fetchTemplates();
}, []);
const filteredTemplates = mockTemplates.filter(
(template) => template.category === selectedCategory
);
const handleDatasetSelect = (datasetId: string) => {
const dataset = datasets.find((ds) => ds.id === datasetId) || null;
setSelectedDataset(dataset);
setFormValues((prev) => ({ ...prev, datasetId }));
if (dataset?.type === DatasetType.PRETRAIN_IMAGE) {
setSelectedCategory("Computer Vision");
} else if (dataset?.type === DatasetType.PRETRAIN_TEXT) {
setSelectedCategory("Natural Language Processing");
const handleTemplateSelect = (value?: string) => {
if (!value) {
setLabelConfig("");
return;
}
setSelectedTemplate(null);
setFormValues((prev) => ({ ...prev, templateId: "" }));
const selectedTemplate = templates.find((template) => template.id === value);
setLabelConfig(selectedTemplate?.labelConfig || "");
};
const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template);
setFormValues((prev) => ({ ...prev, templateId: template.id }));
};
const handleValuesChange = (_, allValues) => {
setFormValues({ ...formValues, ...allValues });
};
const handleConfigModeChange = (e) => {
const handleConfigModeChange = (e: any) => {
const mode = e.target.value;
setConfigMode(mode);
if (mode === "custom") {
// Initialize default values for custom configuration
form.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
// Clear template selection
setSelectedTemplate(null);
setFormValues((prev) => ({ ...prev, templateId: "" }));
form.setFieldsValue({ templateId: undefined });
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const dataset = datasets.find((ds) => ds.id === values.datasetId);
let template;
if (configMode === "template") {
template = mockTemplates.find(
(tpl) => tpl.id === values.templateId
);
if (!template) {
message.error("请选择标注模板");
return;
}
} else {
// Custom configuration
const objects = values.objects;
const labels = values.labels;
if (!objects || objects.length === 0) {
message.error("请至少配置一个数据对象");
return;
}
if (!labels || labels.length === 0) {
message.error("请至少配置一个标签控件");
return;
}
// Construct a temporary custom template object
template = {
id: `custom-${Date.now()}`,
name: "自定义配置",
description: "任务特定的自定义配置",
type: selectedDataset?.type === DatasetType.PRETRAIN_TEXT ? "text" : "image",
isCustom: true,
configuration: {
objects,
labels
}
};
}
if (!dataset) {
message.error("请选择数据集");
if (!labelConfig.trim()) {
message.error("请配置标注模板");
return;
}
const taskData = {
setSubmitting(true);
await createAnnotationTaskUsingPost({
name: values.name,
description: values.description,
dataset,
template,
};
// onCreateTask(taskData); // 实际创建逻辑
console.log("Submitting task data:", taskData);
datasetId: values.datasetId,
templateId: configMode === "template" ? values.templateId : undefined,
labelConfig: labelConfig.trim(),
});
message.success("标注任务创建成功");
navigate("/data/annotation");
} catch (e) {
// 校验失败
console.error(e);
} catch (error: any) {
if (error?.errorFields) {
message.error("请完善必填信息");
} else {
const msg = error?.message || error?.data?.message || "创建失败,请稍后重试";
message.error(msg);
console.error(error);
}
} finally {
setSubmitting(false);
}
};
const handleSaveCustomTemplate = (templateData: any) => {
setSelectedTemplate(templateData);
setFormValues((prev) => ({ ...prev, templateId: templateData.id }));
message.success(`自定义模板 "${templateData.name}" 已创建`);
};
return (
<div className="flex-overflow-auto">
{/* Header */}
<div className="flex items-center mb-2">
<Link to="/data/annotation">
<Button type="text">
@@ -186,13 +114,7 @@ export default function AnnotationTaskCreate() {
<div className="flex-overflow-auto bg-white rounded-lg shadow-sm">
<div className="flex-1 overflow-y-auto p-6">
<Form
form={form}
initialValues={formValues}
onValuesChange={handleValuesChange}
layout="vertical"
>
{/* 基本信息 */}
<Form form={form} layout="vertical">
<h2 className="font-medium text-gray-900 text-lg mb-2"></h2>
<Form.Item
label="任务名称"
@@ -201,11 +123,7 @@ export default function AnnotationTaskCreate() {
>
<Input placeholder="输入任务名称" />
</Form.Item>
<Form.Item
label="任务描述"
name="description"
rules={[{ required: true, message: "请输入任务描述" }]}
>
<Form.Item label="任务描述" name="description">
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
</Form.Item>
<Form.Item
@@ -215,8 +133,6 @@ export default function AnnotationTaskCreate() {
>
<Select
optionFilterProp="children"
value={formValues.datasetId}
onChange={handleDatasetSelect}
placeholder="请选择数据集"
size="large"
options={datasets.map((dataset) => ({
@@ -236,10 +152,9 @@ export default function AnnotationTaskCreate() {
/>
</Form.Item>
{/* 模板配置 */}
<div className="flex items-center justify-between mt-6 mb-2">
<h2 className="font-medium text-gray-900 text-lg flex items-center gap-2">
</h2>
<Radio.Group value={configMode} onChange={handleConfigModeChange} buttonStyle="solid">
<Radio.Button value="template"></Radio.Button>
@@ -247,163 +162,56 @@ export default function AnnotationTaskCreate() {
</Radio.Group>
</div>
{configMode === "template" ? (
{configMode === "template" && (
<Form.Item
label="加载现有模板"
name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]}
>
<div className="flex">
{/* Category Sidebar */}
<div className="w-64 pr-6 border-r border-gray-200">
<div className="space-y-2">
{templateCategories.map((category) => {
const isAvailable =
selectedDataset?.type === "image"
? category === "Computer Vision"
: category === "Natural Language Processing";
return (
<Button
key={category}
type={
selectedCategory === category && isAvailable
? "primary"
: "default"
}
block
disabled={!isAvailable}
onClick={() =>
isAvailable && setSelectedCategory(category)
}
style={{ textAlign: "left", marginBottom: 8 }}
>
{category}
</Button>
);
})}
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => setShowCustomTemplateDialog(true)}
>
</Button>
</div>
</div>
{/* Template Grid */}
<div className="flex-1 pl-6">
<div className="max-h-96 overflow-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTemplates.map((template) => (
<div
key={template.id}
className={`border rounded-lg cursor-pointer transition-all hover:shadow-md ${
formValues.templateId === template.id
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
onClick={() => handleTemplateSelect(template)}
>
{template.preview && (
<div className="aspect-video bg-gray-100 rounded-t-lg overflow-hidden">
<img
src={template.preview || "/placeholder.svg"}
alt={template.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
{template.icon}
<span className="font-medium text-sm">
{template.name}
</span>
</div>
</div>
<p className="text-xs text-gray-600">
{template.description}
</p>
</div>
</div>
))}
{/* Custom Template Option */}
<div
className={`border-2 border-dashed rounded-lg cursor-pointer transition-all hover:border-gray-400 ${
selectedTemplate?.isCustom
? "border-blue-500 bg-blue-50"
: "border-gray-300"
}`}
onClick={() => setShowCustomTemplateDialog(true)}
>
<div className="aspect-video bg-gray-50 rounded-t-lg flex items-center justify-center">
<PlusOutlined
style={{ fontSize: 32, color: "#bbb" }}
/>
</div>
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<PlusOutlined />
<span className="font-medium text-sm">
</span>
</div>
{selectedTemplate?.isCustom && (
<CheckOutlined style={{ color: "#1677ff" }} />
)}
</div>
<p className="text-xs text-gray-600">
</p>
</div>
<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>
)}
</div>
</div>
</div>
{selectedTemplate && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<div className="flex items-center space-x-2">
<span
className="text-sm font-medium"
style={{ color: "#1677ff" }}
>
</span>
</div>
<p
className="text-sm"
style={{ color: "#1677ff", marginTop: 4 }}
>
{selectedTemplate.name} - {selectedTemplate.description}
</p>
</div>
)}
)}
/>
</Form.Item>
) : (
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50">
<TemplateConfigurationForm form={form} />
</div>
)}
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={420}
/>
</div>
</Form>
</div>
<div className="flex gap-2 justify-end border-t border-gray-200 p-6">
<Button onClick={() => navigate("/data/annotation")}></Button>
<Button type="primary" onClick={handleSubmit}>
<Button onClick={() => navigate("/data/annotation")} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</div>
</div>
{/* Custom Template Dialog */}
<CustomTemplateDialog
open={showCustomTemplateDialog}
onOpenChange={setShowCustomTemplateDialog}
onSaveTemplate={handleSaveCustomTemplate}
datasetType={selectedDataset?.type || "image"}
/>
</div>
);
}

View File

@@ -13,7 +13,8 @@ import {
import { type Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate, AnnotationTask } from "../../annotation.model";
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
import TemplateConfigurationForm from "../../components/TemplateConfigurationForm";
import TemplateConfigurationTreeEditor from "../../components/TemplateConfigurationTreeEditor";
import { useTagConfig } from "@/hooks/useTagConfig";
interface AnnotationTaskDialogProps {
open: boolean;
@@ -37,15 +38,10 @@ export default function CreateAnnotationTask({
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
// Custom template state
const [customXml, setCustomXml] = useState("");
const [labelConfig, setLabelConfig] = useState("");
const [showPreview, setShowPreview] = useState(false);
const [previewTaskData, setPreviewTaskData] = useState<Record<string, any>>({});
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
// 模板编辑模式切换(可视化 vs XML)
const [templateEditTab, setTemplateEditTab] = useState<"visual" | "xml">("visual");
// 是否已选择模板(用于启用受限编辑模式)
const [hasSelectedTemplate, setHasSelectedTemplate] = useState(false);
// 数据集预览相关状态
const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false);
@@ -63,6 +59,7 @@ export default function CreateAnnotationTask({
// 任务详情加载状态(编辑模式)
const [taskDetailLoading, setTaskDetailLoading] = useState(false);
const { config: tagConfig } = useTagConfig(false);
useEffect(() => {
if (!open) return;
@@ -101,7 +98,7 @@ export default function CreateAnnotationTask({
if (open) {
manualForm.resetFields();
setNameManuallyEdited(false);
setCustomXml("");
setLabelConfig("");
setShowPreview(false);
setPreviewTaskData({});
setDatasetPreviewData([]);
@@ -122,23 +119,14 @@ export default function CreateAnnotationTask({
setSelectedDatasetId(taskDetail.datasetId);
// 获取实际的 labelConfig(优先使用任务自身的配置,回退到模板配置)
const labelConfig = taskDetail.labelConfig || taskDetail.template?.labelConfig;
const configXml = taskDetail.labelConfig || taskDetail.template?.labelConfig;
// 设置 XML 配置用于预览
if (labelConfig) {
setCustomXml(labelConfig);
// 始终从 XML 解析配置,确保数据一致性
const parsed = parseXmlToConfig(labelConfig);
manualForm.setFieldsValue({
objects: parsed.objects,
labels: parsed.labels,
});
if (configXml) {
setLabelConfig(configXml);
}
// 编辑模式始终使用 custom 配置模式(不改变结构,只改标签
// 编辑模式始终使用 custom 配置模式(不改变结构,只改属性
setConfigMode("custom");
// 编辑模式下启用受限编辑
setHasSelectedTemplate(true);
}
})
.catch((err) => {
@@ -151,7 +139,6 @@ export default function CreateAnnotationTask({
} else {
// 创建模式:重置为默认状态
setConfigMode("template");
setHasSelectedTemplate(false);
setSelectedDatasetId(null);
}
}
@@ -243,229 +230,203 @@ export default function CreateAnnotationTask({
}
};
// 从 Label Studio XML 配置解析出 objects 和 labels
const parseXmlToConfig = (xml: string): { objects: any[], labels: any[] } => {
const objects: any[] = [];
const labels: any[] = [];
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");
// 数据对象类型列表
const objectTypes = ["Image", "Text", "Audio", "Video", "HyperText", "Header", "Paragraphs", "TimeSeries", "TimeSeriesChannel"];
// 标签控件类型列表
const controlTypes = ["Choices", "Labels", "RectangleLabels", "PolygonLabels", "EllipseLabels", "KeyPointLabels", "BrushLabels", "TextArea", "Number", "DateTime", "Rating", "Taxonomy"];
// 解析数据对象
objectTypes.forEach(type => {
const elements = doc.getElementsByTagName(type);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const name = el.getAttribute("name") || "";
const value = el.getAttribute("value") || "";
if (name) {
objects.push({ name, type, value });
}
}
});
// 解析标签控件
controlTypes.forEach(type => {
const elements = doc.getElementsByTagName(type);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const fromName = el.getAttribute("name") || "";
const toName = el.getAttribute("toName") || "";
const required = el.getAttribute("required") === "true";
if (fromName) {
const label: any = {
fromName,
toName,
type,
required,
};
// 解析选项/标签值
if (type === "Choices") {
const choices: string[] = [];
const choiceElements = el.getElementsByTagName("Choice");
for (let j = 0; j < choiceElements.length; j++) {
const value = choiceElements[j].getAttribute("value");
if (value) choices.push(value);
}
label.options = choices;
} else if (["Labels", "RectangleLabels", "PolygonLabels", "EllipseLabels", "KeyPointLabels", "BrushLabels"].includes(type)) {
const labelValues: string[] = [];
const labelElements = el.getElementsByTagName("Label");
for (let j = 0; j < labelElements.length; j++) {
const value = labelElements[j].getAttribute("value");
if (value) labelValues.push(value);
}
label.labels = labelValues;
}
labels.push(label);
}
}
});
} catch (e) {
console.error("Failed to parse XML config:", e);
if (doc.getElementsByTagName("parsererror").length > 0) {
return [];
}
return Array.from(doc.getElementsByTagName("*"));
} catch (error) {
console.error("解析 XML 失败", error);
return [];
}
return { objects, labels };
};
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;
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);
};
// 根据 objects 配置生成预览用的示例数据
const generateExampleData = (objects: any[]) => {
const exampleUrls: Record<string, string> = {
const generatePreviewTaskDataFromLabelConfig = (xml: string) => {
const exampleDataByType: Record<string, any> = {
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",
};
const exampleTexts: Record<string, string> = {
Text: "这是示例文本,用于预览标注界面。",
HyperText: "<p>这是示例 HTML 内容</p>",
Header: "示例标题",
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 data: Record<string, any> = {};
if (!objects || objects.length === 0) {
// 默认数据
const objects = extractObjectsFromLabelConfig(xml);
if (objects.length === 0) {
return {
image: exampleUrls.Image,
text: exampleTexts.Text,
audio: exampleUrls.Audio,
image: exampleDataByType.Image,
text: exampleDataByType.Text,
audio: exampleDataByType.Audio,
};
}
objects.forEach((obj: any) => {
if (!obj?.name || !obj?.value) return;
// 变量名从 $varName 中提取
const varName = obj.value.startsWith("$") ? obj.value.slice(1) : obj.name;
const data: Record<string, any> = {};
objects.forEach((obj) => {
const name = obj.name || "";
const value = obj.value || "";
const varName = value.startsWith("$") ? value.slice(1) : name || value;
if (!varName) return;
if (exampleUrls[obj.type]) {
data[varName] = exampleUrls[obj.type];
} else if (exampleTexts[obj.type]) {
data[varName] = exampleTexts[obj.type];
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 {
// 未知类型,尝试根据名称猜测
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;
}
data[varName] = exampleDataByType.Text;
}
});
return data;
};
// 当选择模板时,加载模板配置到表单
// 当选择模板时,加载 XML 配置到树编辑器(仅快速填充)
const handleTemplateSelect = (value: string, option: any) => {
// 处理清除选择的情况
if (!value) {
setHasSelectedTemplate(false);
setCustomXml("");
setLabelConfig("");
return;
}
setHasSelectedTemplate(true);
const selectedTemplate = templates.find((template) => template.id === value);
const configXml = selectedTemplate?.labelConfig || option?.config || "";
setLabelConfig(configXml);
};
if (option && option.config) {
setCustomXml(option.config);
const validateLabelConfigForSubmit = () => {
const xml = labelConfig.trim();
if (!xml) {
message.error("请配置标注模板");
return false;
}
// 从模板列表中找到完整的模板数据
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 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();
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);
if (!validateLabelConfigForSubmit()) {
return;
}
setSubmitting(true);
@@ -473,13 +434,8 @@ export default function CreateAnnotationTask({
name: values.name,
description: values.description,
datasetId: values.datasetId,
templateId: configMode === 'template' ? values.templateId : undefined,
labelConfig: finalLabelConfig,
// 编辑模式需要传递配置结构,用于后端保存
configuration: {
objects: objects || [],
labels: labels || [],
},
templateId: configMode === "template" ? values.templateId : undefined,
labelConfig: labelConfig.trim(),
};
if (isEditMode && editTask) {
@@ -505,17 +461,8 @@ export default function CreateAnnotationTask({
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");
if (mode === "custom") {
manualForm.setFieldsValue({ templateId: undefined });
}
};
@@ -647,19 +594,13 @@ export default function CreateAnnotationTask({
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);
if (!labelConfig.trim()) {
message.warning("请先配置标注模板");
return;
}
// 生成适配的示例数据
const exampleData = generateExampleData(objects);
const exampleData = generatePreviewTaskDataFromLabelConfig(labelConfig);
setPreviewTaskData(exampleData);
setShowPreview(true);
}}
>
@@ -672,14 +613,14 @@ export default function CreateAnnotationTask({
// 编辑模式:只允许修改标签取值
<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>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
<TemplateConfigurationForm
form={manualForm}
restrictedMode={true}
/>
/
</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">
@@ -688,6 +629,7 @@ export default function CreateAnnotationTask({
name="templateId"
style={{ marginBottom: 12 }}
help="选择模板后,配置将自动填充到可视化编辑器中,您可以继续修改。"
rules={[{ required: true, message: "请选择标注模板" }]}
>
<Select
placeholder="选择一个模板作为基础"
@@ -714,16 +656,19 @@ export default function CreateAnnotationTask({
/>
</Form.Item>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
<TemplateConfigurationForm
form={manualForm}
restrictedMode={hasSelectedTemplate}
/>
</div>
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={360}
/>
</div>
) : (
<div className="bg-gray-50 p-4 rounded-md border border-gray-200" style={{ maxHeight: '400px', overflowY: 'auto' }}>
<TemplateConfigurationForm form={manualForm} />
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={360}
/>
</div>
)}
@@ -745,7 +690,7 @@ export default function CreateAnnotationTask({
<div style={{ height: '600px', overflow: 'hidden' }}>
{showPreview && (
<LabelStudioEmbed
config={customXml}
config={labelConfig}
task={{
id: 1,
data: previewTaskData,

View File

@@ -1,8 +1,7 @@
import React from "react";
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
import { Modal, Descriptions, Tag, Divider, Card } from "antd";
import type { AnnotationTemplate } from "../annotation.model";
const { Text, Paragraph } = Typography;
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
interface TemplateDetailProps {
visible: boolean;
@@ -64,88 +63,26 @@ const TemplateDetail: React.FC<TemplateDetailProps> = ({
<Divider></Divider>
<Card title="数据对象" 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></Text>
<Tag>{obj.name}</Tag>
<Text strong></Text>
<Tag color="blue">{obj.type}</Tag>
<Text strong></Text>
<Tag color="green">{obj.value}</Tag>
</Space>
</Card>
))}
</Space>
</Card>
<Card title="标注控件" 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={`控件 ${index + 1}`}>
<Space direction="vertical" style={{ width: "100%" }}>
<div>
<Text strong></Text>
<Tag>{label.fromName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag>{label.toName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag color="purple">{label.type}</Tag>
{label.required && <Tag color="red"></Tag>}
</div>
{label.description && (
<div>
<Text strong></Text>
<Text type="secondary">{label.description}</Text>
</div>
)}
{label.options && label.options.length > 0 && (
<div>
<Text strong></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></Text>
<div style={{ marginTop: 4 }}>
{label.labels.map((lbl, i) => (
<Tag key={i} color="geekblue">{lbl}</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
))}
</Space>
<Card title="标注配置树" size="small" style={{ marginBottom: 16 }}>
<TemplateConfigurationTreeEditor
value={template.labelConfig || ""}
readOnly={true}
readOnlyStructure={true}
height={360}
/>
</Card>
{template.labelConfig && (
<Card title="Label Studio XML 配置" size="small">
<Paragraph>
<pre style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
maxHeight: 300
}}>
{template.labelConfig}
</pre>
</Paragraph>
<pre style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
maxHeight: 300
}}>
{template.labelConfig}
</pre>
</Card>
)}
</Modal>

View File

@@ -13,7 +13,7 @@ import {
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import { DataTypeMap, ClassificationMap, AnnotationTypeMap } from "../annotation.const";
import TemplateConfigurationForm from "../components/TemplateConfigurationForm";
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
const { TextArea } = Input;
const { Option } = Select;
@@ -35,6 +35,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [labelConfig, setLabelConfig] = useState("");
useEffect(() => {
if (visible && template && mode === "edit") {
@@ -45,24 +46,26 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
labelingType: template.labelingType,
style: template.style,
category: template.category,
labels: template.configuration.labels,
objects: template.configuration.objects,
});
setLabelConfig(template.labelConfig || "");
} else if (visible && mode === "create") {
form.resetFields();
// Set default values
form.setFieldsValue({
style: "horizontal",
category: "custom",
labels: [],
objects: [{ name: "image", type: "Image", value: "$image" }],
});
setLabelConfig("");
}
}, [visible, template, mode, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (!labelConfig.trim()) {
message.error("请配置标注模板");
return;
}
setLoading(true);
console.log("Form values:", values);
@@ -74,10 +77,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
labelingType: values.labelingType,
style: values.style,
category: values.category,
configuration: {
labels: values.labels,
objects: values.objects,
},
labelConfig: labelConfig.trim(),
};
console.log("Request data:", requestData);
@@ -190,7 +190,13 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
</Form.Item>
</Space>
<TemplateConfigurationForm form={form} />
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={420}
/>
</div>
</Form>
</Modal>
);

View File

@@ -61,7 +61,7 @@ export interface AnnotationTemplate {
description?: string;
dataType: string;
labelingType: string;
configuration: TemplateConfiguration;
configuration?: TemplateConfiguration;
labelConfig?: string;
style: string;
category: string;

View File

@@ -6,7 +6,7 @@
export interface TagAttributeConfig {
type?: "boolean" | "number" | "string";
values?: string[];
default?: any;
default?: unknown;
description?: string;
}
@@ -65,7 +65,7 @@ export function parseTagConfig(
);
const controlOptions: TagOption[] = Object.entries(config.controls)
.filter(([_, value]) => {
.filter(([, value]) => {
// If includeLabelingOnly is true, filter out layout controls
if (includeLabelingOnly) {
return value.category === "labeling";
@@ -103,6 +103,7 @@ export function getControlDisplayName(controlType: string): string {
BrushLabels: "画笔分割",
EllipseLabels: "椭圆",
KeyPointLabels: "关键点",
HyperTextLabels: "HTML实体标注",
Rectangle: "矩形",
Polygon: "多边形",
Ellipse: "椭圆",
@@ -151,7 +152,7 @@ export function getControlGroups(): Record<
return {
classification: {
label: "分类标注",
controls: ["Choices", "Taxonomy", "Labels", "Rating"],
controls: ["Choices", "Taxonomy", "Labels", "Rating", "Ranker", "List"],
},
detection: {
label: "目标检测",
@@ -172,7 +173,7 @@ export function getControlGroups(): Record<
},
text: {
label: "文本输入",
controls: ["TextArea", "Number", "DateTime"],
controls: ["TextArea", "Number", "DateTime", "HyperTextLabels"],
},
other: {
label: "其他",
@@ -181,6 +182,8 @@ export function getControlGroups(): Record<
"VectorLabels",
"ParagraphLabels",
"VideoRectangle",
"Relations",
"Pairwise",
],
},
};

View File

@@ -0,0 +1,860 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Button,
Card,
Input,
Select,
Space,
Tag,
Tree,
Typography,
} from "antd";
import type { DataNode, TreeProps } from "antd/es/tree";
import {
PlusOutlined,
DeleteOutlined,
BranchesOutlined,
} from "@ant-design/icons";
import { useTagConfig } from "@/hooks/useTagConfig";
import {
getControlDisplayName,
getObjectDisplayName,
type LabelStudioTagConfig,
} from "../annotation.tagconfig";
const { Text, Title } = Typography;
interface XmlNode {
id: string;
tag: string;
attrs: Record<string, string>;
children: XmlNode[];
text?: string;
}
interface ValidationIssue {
errors: string[];
warnings: string[];
}
interface TemplateConfigurationTreeEditorProps {
value?: string;
onChange?: (xml: string) => void;
readOnly?: boolean;
readOnlyStructure?: boolean;
height?: number | string;
}
const DEFAULT_ROOT_TAG = "View";
const CHILD_TAGS = ["Label", "Choice", "Relation", "Item", "Path", "Channel"];
const createId = () =>
`node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const createEmptyTree = (): XmlNode => ({
id: createId(),
tag: DEFAULT_ROOT_TAG,
attrs: {},
children: [],
});
const escapeAttributeValue = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapeTextValue = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const parseXmlToTree = (xml: string): { tree: XmlNode | null; error?: string } => {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
const parserError = doc.getElementsByTagName("parsererror");
if (parserError.length > 0) {
return { tree: null, error: "XML 格式错误,请检查标签闭合与层级。" };
}
const root = doc.documentElement;
if (!root || root.tagName !== DEFAULT_ROOT_TAG) {
return { tree: null, error: "根节点必须是 <View>。" };
}
const buildNode = (element: Element): XmlNode => {
const attrs: Record<string, string> = {};
Array.from(element.attributes).forEach((attr) => {
attrs[attr.name] = attr.value;
});
const elementChildren = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE
) as Element[];
const textNodes = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
);
const children = elementChildren.map(buildNode);
const text =
children.length === 0
? textNodes.map((node) => node.textContent ?? "").join("").trim()
: undefined;
return {
id: createId(),
tag: element.tagName,
attrs,
children,
text: text || undefined,
};
};
return { tree: buildNode(root) };
} catch (error) {
console.error("解析 XML 失败", error);
return { tree: null, error: "解析 XML 失败,请检查配置内容。" };
}
};
const serializeTreeToXml = (node: XmlNode, indent = 0): string => {
const pad = " ".repeat(indent);
const attrs = Object.entries(node.attrs)
.filter(([key]) => key.trim())
.map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`)
.join("");
if (node.children.length === 0 && !node.text) {
return `${pad}<${node.tag}${attrs} />`;
}
const lines: string[] = [`${pad}<${node.tag}${attrs}>`];
if (node.text) {
lines.push(`${pad} ${escapeTextValue(node.text)}`);
}
node.children.forEach((child) => {
lines.push(serializeTreeToXml(child, indent + 1));
});
lines.push(`${pad}</${node.tag}>`);
return lines.join("\n");
};
const updateNode = (
node: XmlNode,
targetId: string,
updater: (current: XmlNode) => XmlNode
): XmlNode => {
if (node.id === targetId) {
return updater(node);
}
let changed = false;
const children = node.children.map((child) => {
const updated = updateNode(child, targetId, updater);
if (updated !== child) changed = true;
return updated;
});
return changed ? { ...node, children } : node;
};
const removeNodeById = (
node: XmlNode,
targetId: string
): { node: XmlNode; removed?: XmlNode } => {
const index = node.children.findIndex((child) => child.id === targetId);
if (index >= 0) {
const removed = node.children[index];
const children = node.children.filter((child) => child.id !== targetId);
return { node: { ...node, children }, removed };
}
let removed: XmlNode | undefined;
const children = node.children.map((child) => {
const result = removeNodeById(child, targetId);
if (result.removed) removed = result.removed;
return result.node;
});
if (removed) {
return { node: { ...node, children }, removed };
}
return { node };
};
const findNodeWithParent = (
node: XmlNode,
targetId: string,
parent?: XmlNode
): { node?: XmlNode; parent?: XmlNode; index?: number } => {
if (node.id === targetId) {
return { node, parent, index: parent?.children.findIndex((c) => c.id === targetId) };
}
for (const child of node.children) {
const result = findNodeWithParent(child, targetId, node);
if (result.node) return result;
}
return {};
};
const isDescendant = (node: XmlNode, targetId: string): boolean => {
if (node.id === targetId) return true;
return node.children.some((child) => isDescendant(child, targetId));
};
const getNodeLabel = (node: XmlNode) => {
const name = node.attrs.name || node.attrs.value;
return name ? `${node.tag} (${name})` : node.tag;
};
const getDefaultName = (tag: string) => {
const lower = tag.toLowerCase();
if (lower.includes("text")) return "text";
if (lower.includes("image")) return "image";
if (lower.includes("audio")) return "audio";
if (lower.includes("video")) return "video";
if (lower.includes("pdf")) return "pdf";
if (lower.includes("chat")) return "chat";
if (lower.includes("table")) return "table";
if (lower.includes("timeseries") || lower.includes("time")) return "ts";
return lower;
};
const createNode = (
tag: string,
config: LabelStudioTagConfig | null,
objectNames: string[]
): XmlNode => {
const attrs: Record<string, string> = {};
const controlConfig = config?.controls?.[tag];
const objectConfig = config?.objects?.[tag];
const requiredAttrs = controlConfig?.required_attrs || objectConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
attrs[attr] = "";
});
if (objectConfig && attrs.name !== undefined) {
const name = getDefaultName(tag);
attrs.name = name;
if (attrs.value !== undefined) {
attrs.value = `$${name}`;
}
}
if (controlConfig && attrs.name !== undefined) {
attrs.name = getDefaultName(tag);
if (attrs.toName !== undefined) {
attrs.toName = objectNames[0] || "";
}
}
if (CHILD_TAGS.includes(tag)) {
attrs.value = attrs.value || "";
}
const node: XmlNode = {
id: createId(),
tag,
attrs,
children: [],
};
if (controlConfig?.requires_children && controlConfig.child_tag) {
node.children.push({
id: createId(),
tag: controlConfig.child_tag,
attrs: { value: "" },
children: [],
});
}
if (tag === "Style") {
node.text = "";
}
return node;
};
const collectObjectNames = (node: XmlNode, config: LabelStudioTagConfig | null): string[] => {
const names: string[] = [];
const objectTags = new Set(Object.keys(config?.objects || {}));
const traverse = (current: XmlNode) => {
if (objectTags.has(current.tag) && current.attrs.name) {
names.push(current.attrs.name);
}
current.children.forEach(traverse);
};
traverse(node);
return names;
};
const validateTree = (
node: XmlNode,
config: LabelStudioTagConfig | null
): Record<string, ValidationIssue> => {
const issues: Record<string, ValidationIssue> = {};
const objectTags = new Set(Object.keys(config?.objects || {}));
const controlTags = new Set(Object.keys(config?.controls || {}));
const objectNames = new Set(collectObjectNames(node, config));
const ensureIssue = (id: string) => {
if (!issues[id]) {
issues[id] = { errors: [], warnings: [] };
}
return issues[id];
};
let labelingControlCount = 0;
let objectCount = 0;
const validateNode = (current: XmlNode) => {
const issue = ensureIssue(current.id);
const controlConfig = config?.controls?.[current.tag];
const objectConfig = config?.objects?.[current.tag];
const isObject = objectTags.has(current.tag);
const isControl = controlTags.has(current.tag);
const isLabelingControl = isControl && controlConfig?.category === "labeling";
if (isObject) {
objectCount += 1;
const requiredAttrs = objectConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.errors.push(`对象缺少必填属性: ${attr}`);
}
});
if (current.attrs.value && !current.attrs.value.startsWith("$")) {
issue.errors.push("对象 value 应以 $ 开头");
}
}
if (isControl) {
if (isLabelingControl) {
labelingControlCount += 1;
const requiredAttrs = controlConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.errors.push(`控件缺少必填属性: ${attr}`);
}
});
if (current.attrs.toName) {
const toNames = current.attrs.toName
.split(",")
.map((name) => name.trim())
.filter(Boolean);
const invalid = toNames.filter((name) => !objectNames.has(name));
if (invalid.length > 0) {
issue.errors.push(`toName 未找到对象: ${invalid.join(", ")}`);
}
}
} else {
const requiredAttrs = controlConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.warnings.push(`布局标签缺少建议属性: ${attr}`);
}
});
}
if (controlConfig?.requires_children && controlConfig.child_tag) {
const children = current.children.filter(
(child) => child.tag === controlConfig.child_tag
);
if (children.length === 0) {
const message = `${current.tag} 需要至少一个 <${controlConfig.child_tag}> 子节点`;
if (isLabelingControl) {
issue.errors.push(message);
} else {
issue.warnings.push(message);
}
}
children.forEach((child) => {
if (!child.attrs.value) {
const message = `<${child.tag}> 缺少 value 属性`;
const childIssue = ensureIssue(child.id);
if (isLabelingControl) {
childIssue.errors.push(message);
} else {
childIssue.warnings.push(message);
}
}
});
}
}
current.children.forEach(validateNode);
};
validateNode(node);
if (node.tag === DEFAULT_ROOT_TAG) {
const rootIssue = ensureIssue(node.id);
if (objectCount === 0) {
rootIssue.errors.push("至少需要一个数据对象标签");
}
if (labelingControlCount === 0) {
rootIssue.errors.push("至少需要一个标注控件标签");
}
}
return issues;
};
const TemplateConfigurationTreeEditor = ({
value,
onChange,
readOnly = false,
readOnlyStructure = false,
height = 420,
}: TemplateConfigurationTreeEditorProps) => {
const { config } = useTagConfig(false);
const [tree, setTree] = useState<XmlNode>(() => createEmptyTree());
const [selectedId, setSelectedId] = useState<string>(tree.id);
const [parseError, setParseError] = useState<string | null>(null);
const lastSerialized = useRef<string>("");
const [addChildTag, setAddChildTag] = useState<string | undefined>();
const [addSiblingTag, setAddSiblingTag] = useState<string | undefined>();
useEffect(() => {
if (!value) {
const empty = createEmptyTree();
setTree(empty);
setSelectedId(empty.id);
setParseError(null);
lastSerialized.current = "";
return;
}
if (value === lastSerialized.current) return;
const result = parseXmlToTree(value);
if (result.tree) {
setTree(result.tree);
setSelectedId(result.tree.id);
setParseError(null);
lastSerialized.current = value;
} else if (result.error) {
setParseError(result.error);
}
}, [value]);
useEffect(() => {
const xml = serializeTreeToXml(tree);
lastSerialized.current = xml;
onChange?.(xml);
}, [tree, onChange]);
const objectNames = useMemo(
() => collectObjectNames(tree, config || null),
[tree, config]
);
const validationIssues = useMemo(
() => validateTree(tree, config || null),
[tree, config]
);
const selectedNode = useMemo(() => {
const result = findNodeWithParent(tree, selectedId);
return result.node || tree;
}, [tree, selectedId]);
const selectedIssue = validationIssues[selectedNode.id] || {
errors: [],
warnings: [],
};
const isStructureLocked = readOnly || readOnlyStructure;
const canEditAttributeValues = !readOnly && selectedNode.id !== tree.id;
const canEditAttributeKeys = canEditAttributeValues && !readOnlyStructure;
const canEditTagName = canEditAttributeKeys;
const canEditText = canEditAttributeValues && !readOnlyStructure;
const controlOptions = useMemo(() => {
if (!config?.controls) return { labeling: [], layout: [] };
const labeling = Object.entries(config.controls)
.filter(([, item]) => item.category === "labeling")
.map(([tag]) => ({ value: tag, label: getControlDisplayName(tag) }));
const layout = Object.entries(config.controls)
.filter(([, item]) => item.category === "layout")
.map(([tag]) => ({ value: tag, label: tag }));
return { labeling, layout };
}, [config]);
const objectOptions = useMemo(() => {
if (!config?.objects) return [];
return Object.keys(config.objects).map((tag) => ({
value: tag,
label: getObjectDisplayName(tag),
}));
}, [config]);
const tagOptions = useMemo(() => {
const options = [] as {
label: string;
options: { value: string; label: string }[];
}[];
options.push({
label: "容器",
options: [{ value: "View", label: "View" }],
});
if (objectOptions.length > 0) {
options.push({ label: "数据对象", options: objectOptions });
}
if (controlOptions.labeling.length > 0) {
options.push({ label: "标注控件", options: controlOptions.labeling });
}
if (controlOptions.layout.length > 0) {
options.push({ label: "布局标签", options: controlOptions.layout });
}
options.push({
label: "子标签",
options: CHILD_TAGS.map((tag) => ({ value: tag, label: tag })),
});
return options;
}, [objectOptions, controlOptions]);
const handleAddNode = (tag: string, mode: "child" | "sibling") => {
if (isStructureLocked) return;
const newNode = createNode(tag, config || null, objectNames);
if (mode === "child") {
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
children: [...current.children, newNode],
}))
);
setSelectedId(newNode.id);
return;
}
const { parent } = findNodeWithParent(tree, selectedNode.id);
if (!parent) return;
setTree((prev) =>
updateNode(prev, parent.id, (current) => {
const index = current.children.findIndex(
(child) => child.id === selectedNode.id
);
const children = [...current.children];
children.splice(index + 1, 0, newNode);
return { ...current, children };
})
);
setSelectedId(newNode.id);
};
const handleDeleteNode = () => {
if (isStructureLocked || selectedNode.id === tree.id) return;
const { node: nextTree } = removeNodeById(tree, selectedNode.id);
setTree(nextTree);
setSelectedId(nextTree.id);
};
const handleAttrKeyChange = (oldKey: string, newKey: string) => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
const value = attrs[oldKey] ?? "";
delete attrs[oldKey];
if (newKey) {
attrs[newKey] = value;
}
return { ...current, attrs };
})
);
};
const handleAttrValueChange = (key: string, value: string) => {
if (!canEditAttributeValues) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
attrs: { ...current.attrs, [key]: value },
}))
);
};
const handleAddAttribute = () => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
let index = 1;
let key = "attr";
while (attrs[key]) {
index += 1;
key = `attr${index}`;
}
attrs[key] = "";
return { ...current, attrs };
})
);
};
const handleRemoveAttribute = (key: string) => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
delete attrs[key];
return { ...current, attrs };
})
);
};
const handleTagNameChange = (value: string) => {
if (!canEditTagName) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
tag: value || current.tag,
}))
);
};
const handleTextChange = (value: string) => {
if (!canEditText) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
text: value,
}))
);
};
const treeData: DataNode[] = useMemo(() => {
const build = (node: XmlNode): DataNode => {
const issue = validationIssues[node.id];
const hasError = issue?.errors?.length > 0;
const hasWarning = issue?.warnings?.length > 0;
return {
key: node.id,
title: (
<Space size={6}>
<span className={hasError ? "text-red-600" : undefined}>
{getNodeLabel(node)}
</span>
{hasError && <Tag color="red"></Tag>}
{!hasError && hasWarning && <Tag color="gold"></Tag>}
</Space>
),
children: node.children.map(build),
draggable: !isStructureLocked && node.id !== tree.id,
};
};
return [build(tree)];
}, [tree, validationIssues, isStructureLocked]);
const onDrop: TreeProps["onDrop"] = (info) => {
if (isStructureLocked) return;
const dragId = String(info.dragNode.key);
const dropId = String(info.node.key);
if (dragId === tree.id) return;
if (dragId === dropId) return;
const dragInfo = findNodeWithParent(tree, dragId);
const dropInfo = findNodeWithParent(tree, dropId);
if (!dragInfo.node || !dropInfo.node) return;
if (isDescendant(dragInfo.node, dropId)) {
return;
}
const removedResult = removeNodeById(tree, dragId);
let nextTree = removedResult.node;
const movingNode = removedResult.removed;
if (!movingNode) return;
if (!info.dropToGap) {
nextTree = updateNode(nextTree, dropId, (current) => ({
...current,
children: [...current.children, movingNode],
}));
} else {
const { parent } = findNodeWithParent(nextTree, dropId);
if (!parent) return;
nextTree = updateNode(nextTree, parent.id, (current) => {
const index = current.children.findIndex((child) => child.id === dropId);
const children = [...current.children];
const insertIndex = info.dropPosition > 0 ? index + 1 : index;
children.splice(insertIndex, 0, movingNode);
return { ...current, children };
});
}
setTree(nextTree);
setSelectedId(movingNode.id);
};
return (
<div className="grid grid-cols-2 gap-4" style={{ minHeight: 360 }}>
<Card
title={
<Space>
<BranchesOutlined />
<span></span>
</Space>
}
size="small"
style={{ height }}
bodyStyle={{ height: "100%", overflow: "auto" }}
>
{parseError && (
<Alert message={parseError} type="error" showIcon style={{ marginBottom: 8 }} />
)}
<Tree
treeData={treeData}
selectedKeys={[selectedId]}
onSelect={(keys) => {
if (keys.length > 0) setSelectedId(String(keys[0]));
}}
draggable={!isStructureLocked}
onDrop={onDrop}
blockNode
/>
</Card>
<Card title="节点配置" size="small" style={{ height }} bodyStyle={{ height: "100%", overflow: "auto" }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div>
<Title level={5} style={{ margin: 0 }}>
{selectedNode.tag}
</Title>
<Text type="secondary">{selectedNode.id}</Text>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
placeholder="添加子节点"
options={tagOptions}
value={addChildTag}
onChange={(value) => {
setAddChildTag(undefined);
handleAddNode(value, "child");
}}
disabled={isStructureLocked}
/>
<Select
placeholder="添加同级节点"
options={tagOptions}
value={addSiblingTag}
onChange={(value) => {
setAddSiblingTag(undefined);
handleAddNode(value, "sibling");
}}
disabled={isStructureLocked || selectedNode.id === tree.id}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleDeleteNode}
disabled={isStructureLocked || selectedNode.id === tree.id}
>
</Button>
</div>
<div>
<Text></Text>
<Input
value={selectedNode.tag}
onChange={(e) => handleTagNameChange(e.target.value)}
disabled={!canEditTagName}
/>
</div>
{selectedNode.tag === "Style" || selectedNode.text !== undefined ? (
<div>
<Text></Text>
<Input.TextArea
value={selectedNode.text}
onChange={(e) => handleTextChange(e.target.value)}
rows={4}
disabled={!canEditText}
/>
</div>
) : null}
<div>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Space align="center" style={{ width: "100%", justifyContent: "space-between" }}>
<Text></Text>
<Button size="small" onClick={handleAddAttribute} disabled={!canEditAttributeKeys} icon={<PlusOutlined />}>
</Button>
</Space>
{Object.entries(selectedNode.attrs).length === 0 && (
<Text type="secondary"></Text>
)}
{Object.entries(selectedNode.attrs).map(([key, value]) => (
<Space key={key} align="baseline" style={{ width: "100%" }}>
<Input
value={key}
onChange={(e) => handleAttrKeyChange(key, e.target.value)}
placeholder="属性名"
style={{ width: 120 }}
disabled={!canEditAttributeKeys}
/>
{key === "toName" ? (
<Select
mode="multiple"
style={{ flex: 1 }}
placeholder="选择对象"
value={value
.split(",")
.map((item) => item.trim())
.filter(Boolean)}
onChange={(values) => handleAttrValueChange(key, values.join(","))}
options={objectNames.map((name) => ({ value: name, label: name }))}
disabled={!canEditAttributeValues}
/>
) : (
<Input
value={value}
onChange={(e) => handleAttrValueChange(key, e.target.value)}
placeholder="属性值"
style={{ flex: 1 }}
disabled={!canEditAttributeValues}
/>
)}
<Button
size="small"
danger
onClick={() => handleRemoveAttribute(key)}
disabled={!canEditAttributeKeys}
>
</Button>
</Space>
))}
</Space>
</div>
{(selectedIssue.errors.length > 0 || selectedIssue.warnings.length > 0) && (
<div>
<Text strong></Text>
<div className="mt-2 space-y-1">
{selectedIssue.errors.map((err, index) => (
<Tag key={`err-${index}`} color="red">
{err}
</Tag>
))}
{selectedIssue.warnings.map((warn, index) => (
<Tag key={`warn-${index}`} color="gold">
{warn}
</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
</div>
);
};
export default TemplateConfigurationTreeEditor;