You've already forked DataMate
feat(annotation): 替换模板配置表单为树形编辑器组件
- 移除 TemplateConfigurationForm 组件并引入 TemplateConfigurationTreeEditor - 使用 useTagConfig Hook 获取标签配置 - 将自定义XML状态 customXml 替换为 labelConfig - 删除模板编辑标签页和选择模板状态管理 - 更新XML解析逻辑支持更多对象和标注控件类型 - 添加配置验证功能确保至少包含数据对象和标注控件 - 在模板详情页面使用树形编辑器显示配置详情 - 更新任务创建页面集成新的树形配置编辑器 - 调整预览数据生成功能适配新的XML解析方式
This commit is contained in:
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
const escapeTextValue = (value: string) =>
|
||||
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user