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; 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.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, ">"); const escapeTextValue = (value: string) => value.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: "根节点必须是 。" }; } const buildNode = (element: Element): XmlNode => { const attrs: Record = {}; 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}`); 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 = { 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 = {}; 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 => { const issues: Record = {}; 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(() => createEmptyTree()); const [selectedId, setSelectedId] = useState(tree.id); const [parseError, setParseError] = useState(null); const lastSerialized = useRef(""); 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: ( {title} {hasError && 错误} {!hasError && hasWarning && 提示} ), 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 (
结构树 } size="small" style={{ height }} bodyStyle={{ height: "100%", overflow: "auto" }} > {parseError && ( )} { if (keys.length > 0) setSelectedId(String(keys[0])); }} defaultExpandAll draggable={!isStructureLocked} onDrop={onDrop} blockNode />
{selectedNode.tag} {selectedNode.id}
{ handleAddNode(value, "sibling"); }} disabled={isStructureLocked || selectedNode.id === tree.id} />
标签名称 handleTagNameChange(e.target.value)} disabled={!canEditTagName} />
{selectedNode.tag === "Style" || selectedNode.text !== undefined ? (
文本内容 handleTextChange(e.target.value)} rows={4} disabled={!canEditText} />
) : null}
属性配置 {Object.entries(selectedNode.attrs).length === 0 && ( 暂无属性 )} {Object.entries(selectedNode.attrs).map(([key, value]) => ( handleAttrKeyChange(key, e.target.value)} placeholder="属性名" style={{ width: 120 }} disabled={!canEditAttributeKeys} /> {key === "toName" ? ( handleAttrValueChange(key, e.target.value)} placeholder="属性值" style={{ flex: 1 }} disabled={!canEditAttributeValues} /> )} ))}
{(selectedIssue.errors.length > 0 || selectedIssue.warnings.length > 0) && (
校验提示
{selectedIssue.errors.map((err, index) => ( {err} ))} {selectedIssue.warnings.map((warn, index) => ( {warn} ))}
)}
); }; export default TemplateConfigurationTreeEditor;