You've already forked DataMate
- 引入 DataType 枚举类型定义 - 根据数据类型动态过滤对象标签选项 - 在模板表单中添加数据类型监听 - 改进错误处理逻辑以提高类型安全性 - 集成数据类型参数到配置树编辑器组件
925 lines
28 KiB
TypeScript
925 lines
28 KiB
TypeScript
import { useCallback, 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";
|
|
import { DataType } from "../annotation.model";
|
|
|
|
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;
|
|
dataType?: DataType;
|
|
}
|
|
|
|
const DEFAULT_ROOT_TAG = "View";
|
|
const CHILD_TAGS = ["Label", "Choice", "Relation", "Item", "Path", "Channel"];
|
|
const OBJECT_TAGS_BY_DATA_TYPE: Record<DataType, string[]> = {
|
|
[DataType.TEXT]: ["Text", "Paragraphs", "Markdown"],
|
|
[DataType.IMAGE]: ["Image", "Bitmask"],
|
|
[DataType.AUDIO]: ["Audio", "AudioPlus"],
|
|
[DataType.VIDEO]: ["Video"],
|
|
[DataType.PDF]: ["PDF"],
|
|
[DataType.TIMESERIES]: ["Timeseries", "TimeSeries", "Vector"],
|
|
[DataType.CHAT]: ["Chat"],
|
|
[DataType.HTML]: ["HyperText", "Markdown"],
|
|
[DataType.TABLE]: ["Table", "Vector"],
|
|
};
|
|
|
|
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 COMMON_TAG_DISPLAY_NAMES: Record<string, string> = {
|
|
View: "容器",
|
|
Header: "标题",
|
|
Style: "样式",
|
|
Label: "标签项",
|
|
Choice: "选项",
|
|
Relation: "关系",
|
|
Relations: "关系组",
|
|
Item: "列表项",
|
|
Path: "路径",
|
|
Channel: "通道",
|
|
Collapse: "折叠面板",
|
|
Filter: "过滤器",
|
|
Shortcut: "快捷键",
|
|
};
|
|
|
|
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) {
|
|
const name = getDefaultName(tag);
|
|
if (!attrs.name) {
|
|
attrs.name = name;
|
|
}
|
|
if (!attrs.value) {
|
|
attrs.value = `$${attrs.name}`;
|
|
}
|
|
}
|
|
|
|
if (controlConfig) {
|
|
const isLabeling = controlConfig.category === "labeling";
|
|
|
|
if (isLabeling) {
|
|
if (!attrs.name) {
|
|
attrs.name = getDefaultName(tag);
|
|
}
|
|
if (!attrs.toName) {
|
|
attrs.toName = objectNames[0] || "";
|
|
}
|
|
} else {
|
|
// For layout controls, only fill if required
|
|
if (attrs.name !== undefined && !attrs.name) {
|
|
attrs.name = getDefaultName(tag);
|
|
}
|
|
if (attrs.toName !== undefined && !attrs.toName) {
|
|
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,
|
|
dataType,
|
|
}: 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>("");
|
|
|
|
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: COMMON_TAG_DISPLAY_NAMES[tag] || tag,
|
|
}));
|
|
return { labeling, layout };
|
|
}, [config]);
|
|
|
|
const objectOptions = useMemo(() => {
|
|
if (!config?.objects) return [];
|
|
const options = Object.keys(config.objects).map((tag) => ({
|
|
value: tag,
|
|
label: getObjectDisplayName(tag),
|
|
}));
|
|
if (!dataType) return options;
|
|
const allowedTags = OBJECT_TAGS_BY_DATA_TYPE[dataType];
|
|
if (!allowedTags) return options;
|
|
const allowedSet = new Set(allowedTags);
|
|
const filtered = options.filter((option) => allowedSet.has(option.value));
|
|
return filtered.length > 0 ? filtered : options;
|
|
}, [config, dataType]);
|
|
|
|
const tagOptions = useMemo(() => {
|
|
const options = [] as {
|
|
label: string;
|
|
options: { value: string; label: string }[];
|
|
}[];
|
|
options.push({
|
|
label: "容器",
|
|
options: [{ value: "View", label: COMMON_TAG_DISPLAY_NAMES.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: COMMON_TAG_DISPLAY_NAMES[tag] || tag,
|
|
})),
|
|
});
|
|
return options;
|
|
}, [objectOptions, controlOptions]);
|
|
|
|
const getTagDisplayName = useCallback(
|
|
(tag: string) => {
|
|
if (config?.objects?.[tag]) return getObjectDisplayName(tag);
|
|
if (config?.controls?.[tag]) return getControlDisplayName(tag);
|
|
return COMMON_TAG_DISPLAY_NAMES[tag] || tag;
|
|
},
|
|
[config]
|
|
);
|
|
|
|
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;
|
|
const name = node.attrs.name || node.attrs.value;
|
|
const title = name
|
|
? `${getTagDisplayName(node.tag)} (${name})`
|
|
: getTagDisplayName(node.tag);
|
|
return {
|
|
key: node.id,
|
|
title: (
|
|
<Space size={6}>
|
|
<span className={hasError ? "text-red-600" : undefined}>
|
|
{title}
|
|
</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, getTagDisplayName]);
|
|
|
|
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
|
|
key={tree.id}
|
|
treeData={treeData}
|
|
selectedKeys={[selectedId]}
|
|
onSelect={(keys) => {
|
|
if (keys.length > 0) setSelectedId(String(keys[0]));
|
|
}}
|
|
defaultExpandAll
|
|
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={null}
|
|
onChange={(value) => {
|
|
handleAddNode(value, "child");
|
|
}}
|
|
disabled={isStructureLocked}
|
|
/>
|
|
<Select
|
|
placeholder="添加同级节点"
|
|
options={tagOptions}
|
|
value={null}
|
|
onChange={(value) => {
|
|
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;
|