diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84c979b..32c31f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@xyflow/react": "^12.11.2", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, @@ -1287,6 +1288,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4593595..3ac9f93 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@xyflow/react": "^12.11.2", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a919ef7..93d4158 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,21 @@ -import { DragEvent, useCallback, useMemo, useState } from "react"; +import { ChangeEvent, DragEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ClipboardPaste, + Copy, + ListChecks, + Magnet, + Maximize2, + Redo2, + Trash2, + Undo2, + type LucideIcon, +} from "lucide-react"; import { Background, Controls, Handle, MiniMap, + Panel, Position, ReactFlow, ReactFlowProvider, @@ -11,7 +23,9 @@ import { addEdge, type Connection, type Edge, + type EdgeChange, type Node, + type NodeChange, type NodeProps, type NodeTypes, useEdgesState, @@ -20,11 +34,23 @@ import { } from "@xyflow/react"; type ParameterValue = number | string; +type UnitQuantity = "pressure" | "volume" | "temperature" | "length"; + +type UnitOption = { + value: string; + label: string; + toBase: (value: number) => number; + fromBase: (value: number) => number; +}; type ParameterDefinition = { label: string; unit?: string; + quantity?: UnitQuantity; default: ParameterValue; + min?: number; + max?: number; + minExclusive?: boolean; }; type ComponentDefinition = { @@ -41,6 +67,7 @@ type SimulationNodeData = { modelType: string; ports: string[]; parameters: Record; + parameterUnits: Record; }; type SimulationNode = Node; @@ -61,7 +88,9 @@ type ProjectNodePayload = { x: number; y: number; }; - data: SimulationNodeData; + data: Omit & { + parameterUnits?: Record; + }; }; type ProjectEdgePayload = { @@ -79,12 +108,6 @@ type ReactFlowProjectPayload = { simulation: SimulationConfig; }; -type SaveProjectResult = { - id: string; - componentCount: number; - connectionCount: number; -}; - type SimulationResult = { success: boolean; message: string; @@ -100,6 +123,65 @@ type SimulationResult = { networkSummary: string; }; +type ValidationIssue = { + id: string; + severity: "error" | "warning"; + message: string; + nodeId?: string; + edgeId?: string; +}; + +type EditorSnapshot = { + nodes: SimulationNode[]; + edges: SimulationEdge[]; + projectName: string; + simulationConfig: SimulationConfig; +}; + +type EditorClipboard = { + nodes: SimulationNode[]; + edges: SimulationEdge[]; +}; + +const AUTOSAVE_KEY = "system-simulation-flow:autosave"; +const PROJECT_KEY_PREFIX = "system-simulation-flow:project:"; +const GRID_SIZE = 18; +const HISTORY_LIMIT = 50; + +const identityUnit = (value: number) => value; +const scaleUnit = (factor: number) => ({ + toBase: (value: number) => value * factor, + fromBase: (value: number) => value / factor, +}); + +const unitOptions: Record = { + pressure: [ + { value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit }, + { value: "kPa", label: "kPa", ...scaleUnit(1_000) }, + { value: "MPa", label: "MPa", ...scaleUnit(1_000_000) }, + { value: "bar", label: "bar", ...scaleUnit(100_000) }, + ], + volume: [ + { value: "m3", label: "m³", toBase: identityUnit, fromBase: identityUnit }, + { value: "L", label: "L", ...scaleUnit(0.001) }, + { value: "mL", label: "mL", ...scaleUnit(0.000001) }, + ], + temperature: [ + { value: "K", label: "K", toBase: identityUnit, fromBase: identityUnit }, + { + value: "degC", + label: "°C", + toBase: (value) => value + 273.15, + fromBase: (value) => value - 273.15, + }, + ], + length: [ + { value: "m", label: "m", toBase: identityUnit, fromBase: identityUnit }, + { value: "cm", label: "cm", ...scaleUnit(0.01) }, + { value: "mm", label: "mm", ...scaleUnit(0.001) }, + ], +}; + const defaultSimulationConfig: SimulationConfig = { t_start: 0, t_stop: 2, @@ -115,9 +197,23 @@ const componentDefinitions: ComponentDefinition[] = [ modelType: "cylinder", ports: ["port_b"], parameters: { - volume: { label: "容积", unit: "m3", default: 0.01 }, - p0: { label: "初始压力", unit: "Pa", default: 35000000 }, - T0: { label: "初始温度", unit: "K", default: 300 }, + volume: { + label: "容积", + unit: "m3", + quantity: "volume", + default: 0.01, + min: 0, + minExclusive: true, + }, + p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 35000000, min: 0 }, + T0: { + label: "初始温度", + unit: "K", + quantity: "temperature", + default: 300, + min: 0, + minExclusive: true, + }, }, }, { @@ -126,9 +222,23 @@ const componentDefinitions: ComponentDefinition[] = [ modelType: "tank", ports: ["port_a"], parameters: { - volume: { label: "容积", unit: "m3", default: 0.1 }, - p0: { label: "初始压力", unit: "Pa", default: 100000 }, - T0: { label: "初始温度", unit: "K", default: 300 }, + volume: { + label: "容积", + unit: "m3", + quantity: "volume", + default: 0.1, + min: 0, + minExclusive: true, + }, + p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 }, + T0: { + label: "初始温度", + unit: "K", + quantity: "temperature", + default: 300, + min: 0, + minExclusive: true, + }, }, }, { @@ -137,11 +247,32 @@ const componentDefinitions: ComponentDefinition[] = [ modelType: "pipe", ports: ["port_a", "port_b"], parameters: { - length: { label: "长度", unit: "m", default: 5 }, - diameter: { label: "直径", unit: "m", default: 0.02 }, - lambda_darcy: { label: "摩阻系数", default: 0.02 }, - p0: { label: "初始压力", unit: "Pa", default: 100000 }, - T0: { label: "初始温度", unit: "K", default: 300 }, + length: { + label: "长度", + unit: "m", + quantity: "length", + default: 5, + min: 0, + minExclusive: true, + }, + diameter: { + label: "直径", + unit: "m", + quantity: "length", + default: 0.02, + min: 0, + minExclusive: true, + }, + lambda_darcy: { label: "摩阻系数", default: 0.02, min: 0 }, + p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 }, + T0: { + label: "初始温度", + unit: "K", + quantity: "temperature", + default: 300, + min: 0, + minExclusive: true, + }, }, }, { @@ -150,8 +281,8 @@ const componentDefinitions: ComponentDefinition[] = [ modelType: "orifice", ports: ["port_a", "port_b"], parameters: { - K: { label: "流量系数", default: 0.00001 }, - opening: { label: "开度", default: 1 }, + K: { label: "流量系数", default: 0.00001, min: 0 }, + opening: { label: "开度", default: 1, min: 0, max: 1 }, }, }, { @@ -174,6 +305,14 @@ function defaultParameters(definition: ComponentDefinition) { ); } +function defaultParameterUnits(definition: ComponentDefinition) { + return Object.fromEntries( + Object.entries(definition.parameters) + .filter(([, parameter]) => parameter.unit) + .map(([key, parameter]) => [key, parameter.unit as string]), + ); +} + function buildNodeData(definition: ComponentDefinition): SimulationNodeData { return { label: definition.label, @@ -181,13 +320,13 @@ function buildNodeData(definition: ComponentDefinition): SimulationNodeData { modelType: definition.modelType, ports: definition.ports, parameters: defaultParameters(definition), + parameterUnits: defaultParameterUnits(definition), }; } function SimulationComponentNode({ data, selected }: NodeProps) { const inputPorts = data.ports.filter((port) => port.includes("in") || port === "port_a"); const outputPorts = data.ports.filter((port) => !inputPorts.includes(port)); - const singlePorts = data.ports.length === 1 ? data.ports : []; return (
@@ -215,15 +354,6 @@ function SimulationComponentNode({ data, selected }: NodeProps) type="source" /> ))} - {singlePorts.map((port) => ( - - ))}
); } @@ -244,10 +374,44 @@ function symbolForComponent(type: string) { return
; } +type ToolbarButtonProps = { + label: string; + icon: LucideIcon; + onClick: () => void; + disabled?: boolean; + pressed?: boolean; + accent?: boolean; +}; + +function ToolbarButton({ + label, + icon: Icon, + onClick, + disabled = false, + pressed, + accent = false, +}: ToolbarButtonProps) { + return ( + + + + ); +} + function FlowWorkbench() { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [projectName, setProjectName] = useState("demo-system"); const [simulationConfig, setSimulationConfig] = useState( defaultSimulationConfig, @@ -256,7 +420,31 @@ function FlowWorkbench() { const [statusText, setStatusText] = useState("后端待调用"); const [simulationResult, setSimulationResult] = useState(null); const [busy, setBusy] = useState(false); + const [snapEnabled, setSnapEnabled] = useState(true); + const [validationIssues, setValidationIssues] = useState([]); + const [validatedSignature, setValidatedSignature] = useState(""); + const [saveState, setSaveState] = useState("正在检查本地草稿..."); + const [autosaveReady, setAutosaveReady] = useState(false); + const [recoveryProject, setRecoveryProject] = useState(null); + const [historyAvailability, setHistoryAvailability] = useState({ + canUndo: false, + canRedo: false, + }); const reactFlow = useReactFlow(); + const fileInputRef = useRef(null); + const nodesRef = useRef(nodes); + const edgesRef = useRef(edges); + const projectNameRef = useRef(projectName); + const simulationConfigRef = useRef(simulationConfig); + const historyRef = useRef([]); + const futureRef = useRef([]); + const clipboardRef = useRef(null); + const pasteCountRef = useRef(0); + + nodesRef.current = nodes; + edgesRef.current = edges; + projectNameRef.current = projectName; + simulationConfigRef.current = simulationConfig; const nodeTypes = useMemo( () => ({ simulationComponent: SimulationComponentNode }), @@ -273,8 +461,143 @@ function FlowWorkbench() { [edges, nodes, projectName, simulationConfig], ); + const validationSignature = useMemo( + () => JSON.stringify(buildCurrentProject()), + [buildCurrentProject], + ); + const validationCurrent = validatedSignature === validationSignature; + const duplicateNames = useMemo(() => findDuplicateNames(nodes), [nodes]); + const validationSeverityByNode = useMemo( + () => { + const severities = new Map(); + if (!validationCurrent) { + return severities; + } + validationIssues.forEach((issue) => { + if ( + issue.nodeId && + (issue.severity === "error" || !severities.has(issue.nodeId)) + ) { + severities.set(issue.nodeId, issue.severity); + } + }); + return severities; + }, + [validationCurrent, validationIssues], + ); + const renderedNodes = useMemo( + () => + nodes.map((node) => ({ + ...node, + className: validationSeverityByNode.has(node.id) + ? `validation-${validationSeverityByNode.get(node.id)}` + : node.className, + })), + [nodes, validationSeverityByNode], + ); + + const updateHistoryAvailability = useCallback(() => { + setHistoryAvailability({ + canUndo: historyRef.current.length > 0, + canRedo: futureRef.current.length > 0, + }); + }, []); + + const captureSnapshot = useCallback( + (): EditorSnapshot => ({ + nodes: cloneValue(nodesRef.current), + edges: cloneValue(edgesRef.current), + projectName: projectNameRef.current, + simulationConfig: { ...simulationConfigRef.current }, + }), + [], + ); + + const recordHistory = useCallback(() => { + const snapshot = captureSnapshot(); + const previous = historyRef.current.at(-1); + if (!previous || snapshotSignature(previous) !== snapshotSignature(snapshot)) { + historyRef.current.push(snapshot); + if (historyRef.current.length > HISTORY_LIMIT) { + historyRef.current.shift(); + } + } + futureRef.current = []; + updateHistoryAvailability(); + }, [captureSnapshot, updateHistoryAvailability]); + + const finishHistoryEdit = useCallback(() => { + const latest = historyRef.current.at(-1); + if (latest && snapshotSignature(latest) === snapshotSignature(captureSnapshot())) { + historyRef.current.pop(); + updateHistoryAvailability(); + } + }, [captureSnapshot, updateHistoryAvailability]); + + const restoreSnapshot = useCallback( + (snapshot: EditorSnapshot) => { + const restoredNodes = cloneValue(snapshot.nodes).map((node) => ({ + ...node, + selected: false, + })); + setNodes(restoredNodes); + setEdges(cloneValue(snapshot.edges).map((edge) => ({ ...edge, selected: false }))); + setProjectName(snapshot.projectName); + setSimulationConfig({ ...snapshot.simulationConfig }); + setSelectedNodeId(null); + setSelectedEdgeId(null); + setSimulationResult(null); + setXmlText(""); + syncNextNodeNumbers(restoredNodes); + }, + [setEdges, setNodes], + ); + + const undo = useCallback(() => { + const previous = historyRef.current.pop(); + if (!previous) { + return; + } + futureRef.current.push(captureSnapshot()); + restoreSnapshot(previous); + updateHistoryAvailability(); + setStatusText("已撤销上一步操作"); + }, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]); + + const redo = useCallback(() => { + const next = futureRef.current.pop(); + if (!next) { + return; + } + historyRef.current.push(captureSnapshot()); + restoreSnapshot(next); + updateHistoryAvailability(); + setStatusText("已重做上一步操作"); + }, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]); + + const handleNodesChange = useCallback( + (changes: NodeChange[]) => { + if (changes.some((change) => change.type === "remove")) { + recordHistory(); + } + onNodesChange(changes); + }, + [onNodesChange, recordHistory], + ); + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + if (changes.some((change) => change.type === "remove")) { + recordHistory(); + } + onEdgesChange(changes); + }, + [onEdgesChange, recordHistory], + ); + const onConnect = useCallback( (connection: Connection) => { + recordHistory(); setEdges((currentEdges) => addEdge( { @@ -285,12 +608,30 @@ function FlowWorkbench() { ), ); }, - [setEdges], + [recordHistory, setEdges], ); const onDragStart = (event: DragEvent, type: string) => { event.dataTransfer.setData("application/system-component", type); event.dataTransfer.effectAllowed = "move"; + const icon = event.currentTarget.querySelector(".palette-icon"); + if (icon) { + const preview = document.createElement("div"); + const previewIcon = icon.cloneNode(true) as HTMLElement; + const previewLabel = document.createElement("span"); + preview.className = "component-drag-preview"; + preview.setAttribute("aria-hidden", "true"); + previewLabel.className = "component-drag-preview-label"; + previewLabel.textContent = peekNextNodeId(type); + preview.append(previewIcon, previewLabel); + document.body.appendChild(preview); + event.dataTransfer.setDragImage( + preview, + Math.round(preview.offsetWidth / 2), + Math.round(preview.offsetHeight / 2), + ); + window.setTimeout(() => preview.remove(), 0); + } }; const onDrop = useCallback( @@ -311,12 +652,13 @@ function FlowWorkbench() { id, type: "simulationComponent", position, - data: buildNodeData(definition), + data: { ...buildNodeData(definition), label: id }, }; + recordHistory(); setNodes((currentNodes) => [...currentNodes, node]); setSelectedNodeId(id); }, - [reactFlow, setNodes], + [reactFlow, recordHistory, setNodes], ); const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => { @@ -328,6 +670,118 @@ function FlowWorkbench() { ); }; + const updateSelectedParameter = ( + key: string, + definition: ParameterDefinition, + displayValue: string, + ) => { + const selectedUnit = + selectedNode?.data.parameterUnits[key] ?? definition.unit ?? ""; + const unit = findUnitOption(definition, selectedUnit); + const numericValue = Number(displayValue); + const baseValue = + displayValue.trim() === "" || !Number.isFinite(numericValue) + ? displayValue + : unit.toBase(numericValue); + + updateSelectedNode((node) => ({ + ...node, + data: { + ...node.data, + parameters: { + ...node.data.parameters, + [key]: baseValue, + }, + }, + })); + }; + + const updateSelectedParameterUnit = (key: string, unit: string) => { + updateSelectedNode((node) => ({ + ...node, + data: { + ...node.data, + parameterUnits: { + ...node.data.parameterUnits, + [key]: unit, + }, + }, + })); + }; + + const copySelection = useCallback(() => { + const selectedNodes = nodesRef.current.filter( + (node) => node.selected || node.id === selectedNodeId, + ); + if (selectedNodes.length === 0) { + setStatusText("请先选择需要复制的组件"); + return; + } + const selectedIds = new Set(selectedNodes.map((node) => node.id)); + clipboardRef.current = { + nodes: cloneValue(selectedNodes), + edges: cloneValue( + edgesRef.current.filter( + (edge) => selectedIds.has(edge.source) && selectedIds.has(edge.target), + ), + ), + }; + pasteCountRef.current = 0; + setStatusText(`已复制 ${selectedNodes.length} 个组件`); + }, [selectedNodeId]); + + const pasteSelection = useCallback(() => { + const clipboard = clipboardRef.current; + if (!clipboard || clipboard.nodes.length === 0) { + setStatusText("剪贴板中没有可粘贴的组件"); + return; + } + + recordHistory(); + pasteCountRef.current += 1; + const offset = GRID_SIZE * 2 * pasteCountRef.current; + const idMap = new Map(); + const usedNames = new Set(nodesRef.current.map((node) => node.data.label.trim())); + const pastedNodes = clipboard.nodes.map((node) => { + const id = takeNextNodeId(node.data.componentType); + idMap.set(node.id, id); + const label = makeUniqueName(`${node.data.label}_副本`, usedNames); + usedNames.add(label); + return { + ...cloneValue(node), + id, + position: { + x: node.position.x + offset, + y: node.position.y + offset, + }, + selected: true, + data: { + ...cloneValue(node.data), + label, + }, + } satisfies SimulationNode; + }); + const pastedEdges = clipboard.edges.map((edge, index) => ({ + ...cloneValue(edge), + id: `edge-copy-${Date.now()}-${index}`, + source: idMap.get(edge.source) as string, + target: idMap.get(edge.target) as string, + selected: false, + })); + + setNodes((currentNodes) => [ + ...currentNodes.map((node) => ({ ...node, selected: false })), + ...pastedNodes, + ]); + setEdges((currentEdges) => [ + ...currentEdges.map((edge) => ({ ...edge, selected: false })), + ...pastedEdges, + ]); + setSelectedNodeId(pastedNodes.length === 1 ? pastedNodes[0].id : null); + setSelectedEdgeId(null); + setStatusText(`已粘贴 ${pastedNodes.length} 个组件`); + }, [recordHistory, setEdges, setNodes]); + const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => { setSimulationConfig((current) => ({ ...current, @@ -335,57 +789,160 @@ function FlowWorkbench() { })); }; - const generateXml = async () => { - await runBusyAction("正在调用后端生成 XML...", async () => { - const xml = await postJsonText("/api/reactflow/system-xml", buildCurrentProject()); + const generateXml = () => { + try { + const xml = buildSystemXml(buildCurrentProject()); setXmlText(xml); setStatusText(`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`); - }); + } catch (error) { + setStatusText(`XML 生成失败:${formatError(error)}`); + } }; - const downloadXml = async () => { - await runBusyAction("正在生成并下载 XML...", async () => { - const xml = await postJsonText("/api/reactflow/system-xml", buildCurrentProject()); + const downloadXml = () => { + try { + const xml = buildSystemXml(buildCurrentProject()); setXmlText(xml); downloadText(`${safeFilename(projectName)}.xml`, xml); setStatusText("XML 已下载"); - }); + } catch (error) { + setStatusText(`XML 下载失败:${formatError(error)}`); + } }; - const saveProject = async () => { - await runBusyAction("正在保存工程...", async () => { - const project = buildCurrentProject(); - const result = await postJson( - `/api/reactflow/projects/${encodeURIComponent(project.name)}`, - project, - ); - setStatusText( - `工程已保存:${result.id},${result.componentCount} 个组件,${result.connectionCount} 条连接`, - ); - }); - }; - - const loadProject = async () => { - await runBusyAction("正在加载工程...", async () => { - const projectId = projectName.trim() || "untitled"; - const project = await getJson( - `/api/reactflow/projects/${encodeURIComponent(projectId)}`, - ); + const applyProject = useCallback( + (project: ReactFlowProjectPayload, message: string, addToHistory = true) => { + if (addToHistory) { + recordHistory(); + } const loadedNodes = project.nodes.map(normalizeLoadedNode); setNodes(loadedNodes); setEdges(project.edges.map(normalizeLoadedEdge)); - setProjectName(project.name || projectId); + setProjectName(project.name || "untitled"); setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation }); setSelectedNodeId(null); + setSelectedEdgeId(null); setSimulationResult(null); + setValidationIssues([]); + setValidatedSignature(""); setXmlText(""); syncNextNodeNumbers(loadedNodes); - setStatusText(`工程已加载:${loadedNodes.length} 个组件,${project.edges.length} 条连接`); - window.requestAnimationFrame(() => reactFlow.fitView()); + setStatusText(message); + window.requestAnimationFrame(() => reactFlow.fitView({ padding: 0.18 })); + }, + [reactFlow, recordHistory, setEdges, setNodes], + ); + + const saveProject = () => { + try { + const project = buildCurrentProject(); + localStorage.setItem(localProjectKey(project.name), JSON.stringify(project)); + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project)); + setSaveState(`已保存 ${formatTime(new Date())}`); + setStatusText( + `工程已保存到浏览器:${project.nodes.length} 个组件,${project.edges.length} 条连接`, + ); + } catch (error) { + setStatusText(`保存失败:${formatError(error)}`); + } + }; + + const loadProject = () => { + const name = projectName.trim() || "untitled"; + try { + const project = parseProjectPayload(localStorage.getItem(localProjectKey(name))); + if (!project) { + setStatusText(`没有找到本地工程:${name}`); + return; + } + applyProject( + project, + `工程已加载:${project.nodes.length} 个组件,${project.edges.length} 条连接`, + ); + } catch (error) { + setStatusText(`加载失败:${formatError(error)}`); + } + }; + + const exportProject = () => { + const project = buildCurrentProject(); + downloadFile( + `${safeFilename(project.name)}.json`, + JSON.stringify(project, null, 2), + "application/json;charset=utf-8", + ); + setStatusText("工程 JSON 已导出"); + }; + + const importProject = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + try { + const project = parseProjectPayload(await file.text()); + if (!project) { + throw new Error("文件不是有效的 ReactFlow 工程 JSON"); + } + applyProject(project, `已导入工程:${file.name}`); + } catch (error) { + setStatusText(`导入失败:${formatError(error)}`); + } + }; + + const checkModel = useCallback(() => { + const issues = validateModel( + nodesRef.current, + edgesRef.current, + simulationConfigRef.current, + ); + const signature = JSON.stringify( + buildProjectPayload( + projectNameRef.current, + nodesRef.current, + edgesRef.current, + simulationConfigRef.current, + ), + ); + setValidationIssues(issues); + setValidatedSignature(signature); + const errorCount = issues.filter((issue) => issue.severity === "error").length; + const warningCount = issues.length - errorCount; + setStatusText( + errorCount === 0 + ? warningCount === 0 + ? "模型检查通过" + : `模型检查通过,存在 ${warningCount} 条警告` + : `模型检查发现 ${errorCount} 个错误、${warningCount} 条警告`, + ); + return issues; + }, []); + + const focusValidationIssue = (issue: ValidationIssue) => { + if (!issue.nodeId) { + return; + } + const node = nodesRef.current.find((item) => item.id === issue.nodeId); + if (!node) { + return; + } + setNodes((currentNodes) => + currentNodes.map((item) => ({ ...item, selected: item.id === issue.nodeId })), + ); + setSelectedNodeId(issue.nodeId); + setSelectedEdgeId(null); + reactFlow.setCenter(node.position.x, node.position.y, { + duration: 250, + zoom: Math.max(reactFlow.getZoom(), 1), }); }; const runSimulation = async () => { + const issues = checkModel(); + if (issues.some((issue) => issue.severity === "error")) { + return; + } await runBusyAction("正在调用 PythonModels 仿真...", async () => { const result = await postJson( "/api/reactflow/simulate-testmodel", @@ -408,6 +965,121 @@ function FlowWorkbench() { } } + const restoreRecoveryProject = () => { + if (!recoveryProject) { + return; + } + applyProject(recoveryProject, "已恢复上次自动保存的工程", false); + setRecoveryProject(null); + setAutosaveReady(true); + }; + + const discardRecoveryProject = () => { + localStorage.removeItem(AUTOSAVE_KEY); + setRecoveryProject(null); + setAutosaveReady(true); + setSaveState("尚未保存"); + setStatusText("已忽略上次的自动保存草稿"); + }; + + const selectAllElements = useCallback(() => { + setNodes((currentNodes) => currentNodes.map((node) => ({ ...node, selected: true }))); + setEdges((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: true }))); + setSelectedNodeId(null); + setSelectedEdgeId(null); + }, [setEdges, setNodes]); + + const deleteSelection = useCallback(() => { + const nodeIds = new Set( + nodesRef.current + .filter((node) => node.selected || node.id === selectedNodeId) + .map((node) => node.id), + ); + const edgeIds = new Set( + edgesRef.current + .filter((edge) => edge.selected || edge.id === selectedEdgeId) + .map((edge) => edge.id), + ); + if (nodeIds.size === 0 && edgeIds.size === 0) { + setStatusText("请先选择需要删除的组件或连接"); + return; + } + recordHistory(); + setNodes((currentNodes) => currentNodes.filter((node) => !nodeIds.has(node.id))); + setEdges((currentEdges) => + currentEdges.filter( + (edge) => + !edgeIds.has(edge.id) && + !nodeIds.has(edge.source) && + !nodeIds.has(edge.target), + ), + ); + setSelectedNodeId(null); + setSelectedEdgeId(null); + setStatusText(`已删除 ${nodeIds.size} 个组件和 ${edgeIds.size} 条连接`); + }, [recordHistory, selectedEdgeId, selectedNodeId, setEdges, setNodes]); + + useEffect(() => { + try { + const project = parseProjectPayload(localStorage.getItem(AUTOSAVE_KEY)); + if (project && (project.nodes.length > 0 || project.edges.length > 0)) { + setRecoveryProject(project); + setSaveState("发现可恢复的本地草稿"); + } else { + setAutosaveReady(true); + setSaveState("尚未保存"); + } + } catch { + localStorage.removeItem(AUTOSAVE_KEY); + setAutosaveReady(true); + setSaveState("尚未保存"); + } + }, []); + + useEffect(() => { + if (!autosaveReady || recoveryProject) { + return; + } + setSaveState("有未保存更改"); + const timer = window.setTimeout(() => { + try { + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(buildCurrentProject())); + setSaveState(`已自动保存 ${formatTime(new Date())}`); + } catch (error) { + setSaveState(`自动保存失败:${formatError(error)}`); + } + }, 600); + return () => window.clearTimeout(timer); + }, [autosaveReady, buildCurrentProject, recoveryProject]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isEditableTarget(event.target)) { + return; + } + const commandKey = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + if (commandKey && key === "z") { + event.preventDefault(); + event.shiftKey ? redo() : undo(); + } else if (commandKey && key === "y") { + event.preventDefault(); + redo(); + } else if (commandKey && key === "c") { + event.preventDefault(); + copySelection(); + } else if (commandKey && key === "v") { + event.preventDefault(); + pasteSelection(); + } else if (commandKey && key === "a") { + event.preventDefault(); + selectAllElements(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [copySelection, pasteSelection, redo, selectAllElements, undo]); + return (
@@ -418,17 +1090,37 @@ function FlowWorkbench() {
- + {saveState} + + + @@ -441,6 +1133,52 @@ function FlowWorkbench() {
+ +
@@ -649,6 +1487,7 @@ function buildProjectPayload( modelType: node.data.modelType, ports: [...node.data.ports], parameters: { ...node.data.parameters }, + parameterUnits: { ...node.data.parameterUnits }, }, })), edges: edges.map((edge) => ({ @@ -669,6 +1508,9 @@ function normalizeLoadedNode(node: ProjectNodePayload): SimulationNode { const parameters = definition ? { ...defaultParameters(definition), ...node.data.parameters } : { ...node.data.parameters }; + const parameterUnits = definition + ? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) } + : { ...(node.data.parameterUnits ?? {}) }; return { id: node.id, @@ -683,6 +1525,7 @@ function normalizeLoadedNode(node: ProjectNodePayload): SimulationNode { modelType: node.data.modelType || definition?.modelType || node.data.componentType, ports: node.data.ports?.length ? node.data.ports : definition?.ports ?? [], parameters, + parameterUnits, }, }; } @@ -703,6 +1546,10 @@ function takeNextNodeId(componentType: string) { return `${componentType}_${nextNumber}`; } +function peekNextNodeId(componentType: string) { + return `${componentType}_${nextNodeNumberByType[componentType] ?? 1}`; +} + function syncNextNodeNumbers(nodes: SimulationNode[]) { Object.keys(nextNodeNumberByType).forEach((type) => { delete nextNodeNumberByType[type]; @@ -727,6 +1574,305 @@ function syncNextNodeNumbers(nodes: SimulationNode[]) { }); } +function findUnitOption(definition: ParameterDefinition, unitValue: string) { + if (!definition.quantity) { + return { value: "", label: "", toBase: identityUnit, fromBase: identityUnit }; + } + return ( + unitOptions[definition.quantity].find((unit) => unit.value === unitValue) ?? + unitOptions[definition.quantity][0] + ); +} + +function displayParameterValue( + baseValue: ParameterValue, + definition: ParameterDefinition, + selectedUnit: string, +) { + if (typeof baseValue === "string" && baseValue.trim() === "") { + return ""; + } + const numericValue = Number(baseValue); + if (!Number.isFinite(numericValue)) { + return String(baseValue); + } + return formatEditableNumber(findUnitOption(definition, selectedUnit).fromBase(numericValue)); +} + +function formatEditableNumber(value: number) { + if (!Number.isFinite(value)) { + return ""; + } + return Number(value.toPrecision(12)).toString(); +} + +function parameterValidationMessage( + definition: ParameterDefinition, + value: ParameterValue, +) { + if (typeof value === "string" && value.trim() === "") { + return "参数不能为空"; + } + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return "请输入有效数字"; + } + if ( + definition.min !== undefined && + (definition.minExclusive ? numericValue <= definition.min : numericValue < definition.min) + ) { + return definition.minExclusive + ? `必须大于 ${definition.min}` + : `不能小于 ${definition.min}`; + } + if (definition.max !== undefined && numericValue > definition.max) { + return `不能大于 ${definition.max}`; + } + return ""; +} + +function findDuplicateNames(nodes: SimulationNode[]) { + const counts = new Map(); + nodes.forEach((node) => { + const name = node.data.label.trim(); + if (name) { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + }); + return new Set( + [...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name), + ); +} + +function validateModel( + nodes: SimulationNode[], + edges: SimulationEdge[], + simulation: SimulationConfig, +) { + const issues: ValidationIssue[] = []; + let issueNumber = 0; + const addIssue = ( + severity: ValidationIssue["severity"], + message: string, + location: Pick = {}, + ) => { + issueNumber += 1; + issues.push({ id: `validation-${issueNumber}`, severity, message, ...location }); + }; + + if (nodes.length === 0) { + addIssue("error", "画布中没有组件"); + } + + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + const duplicateNames = findDuplicateNames(nodes); + nodes.forEach((node) => { + const definition = componentDefinitions.find( + (item) => item.type === node.data.componentType, + ); + if (!node.data.label.trim()) { + addIssue("error", `${node.id} 的组件名称为空`, { nodeId: node.id }); + } else if (duplicateNames.has(node.data.label.trim())) { + addIssue("error", `组件名称“${node.data.label.trim()}”重复`, { nodeId: node.id }); + } + if (!definition) { + addIssue("error", `${node.id} 的组件类型不受支持`, { nodeId: node.id }); + return; + } + Object.entries(definition.parameters).forEach(([key, parameter]) => { + const message = parameterValidationMessage(parameter, node.data.parameters[key] ?? ""); + if (message) { + addIssue("error", `${node.data.label}:${parameter.label}${message}`, { + nodeId: node.id, + }); + } + }); + definition.ports.forEach((port) => { + const connectionCount = edges.filter( + (edge) => + (edge.source === node.id && edge.sourceHandle === port) || + (edge.target === node.id && edge.targetHandle === port), + ).length; + if (connectionCount === 0) { + addIssue("error", `${node.data.label} 的端口 ${port} 未连接`, { nodeId: node.id }); + } else if (connectionCount > 1) { + addIssue("warning", `${node.data.label} 的端口 ${port} 存在多条连接`, { + nodeId: node.id, + }); + } + }); + }); + + const connectionKeys = new Set(); + edges.forEach((edge) => { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) { + addIssue("error", `连接 ${edge.id} 引用了不存在的组件`, { edgeId: edge.id }); + return; + } + if (!edge.sourceHandle || !source.data.ports.includes(edge.sourceHandle)) { + addIssue("error", `连接 ${edge.id} 的源端口无效`, { + edgeId: edge.id, + nodeId: source.id, + }); + } + if (!edge.targetHandle || !target.data.ports.includes(edge.targetHandle)) { + addIssue("error", `连接 ${edge.id} 的目标端口无效`, { + edgeId: edge.id, + nodeId: target.id, + }); + } + const key = `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`; + if (connectionKeys.has(key)) { + addIssue("warning", "检测到重复连接", { edgeId: edge.id }); + } + connectionKeys.add(key); + }); + + if (!Number.isFinite(simulation.t_start) || !Number.isFinite(simulation.t_stop)) { + addIssue("error", "仿真起止时间必须是有效数字"); + } else if (simulation.t_stop <= simulation.t_start) { + addIssue("error", "仿真结束时间必须大于起始时间"); + } + if (!Number.isFinite(simulation.step) || simulation.step <= 0) { + addIssue("error", "采样步长必须大于 0"); + } + if (!Number.isFinite(simulation.max_step) || simulation.max_step <= 0) { + addIssue("error", "最大积分步长必须大于 0"); + } + if (!simulation.method.trim()) { + addIssue("error", "求解器不能为空"); + } + + return issues; +} + +function cloneValue(value: T): T { + return structuredClone(value); +} + +function snapshotSignature(snapshot: EditorSnapshot) { + return JSON.stringify( + buildProjectPayload( + snapshot.projectName, + snapshot.nodes, + snapshot.edges, + snapshot.simulationConfig, + ), + ); +} + +function makeUniqueName(baseName: string, usedNames: Set) { + if (!usedNames.has(baseName)) { + return baseName; + } + let suffix = 2; + while (usedNames.has(`${baseName}_${suffix}`)) { + suffix += 1; + } + return `${baseName}_${suffix}`; +} + +function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null { + if (!raw) { + return null; + } + const value = JSON.parse(raw) as unknown; + if (!value || typeof value !== "object") { + return null; + } + const project = value as Partial; + if ( + typeof project.name !== "string" || + !Array.isArray(project.nodes) || + !Array.isArray(project.edges) || + !project.simulation || + typeof project.simulation !== "object" + ) { + return null; + } + return project as ReactFlowProjectPayload; +} + +function localProjectKey(name: string) { + return `${PROJECT_KEY_PREFIX}${encodeURIComponent(name.trim() || "untitled")}`; +} + +function isEditableTarget(target: EventTarget | null) { + return ( + target instanceof HTMLElement && + (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) + ); +} + +function formatTime(date: Date) { + return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); +} + +function buildSystemXml(project: ReactFlowProjectPayload) { + const xmlDocument = document.implementation.createDocument("", "System", null); + const system = xmlDocument.documentElement; + system.setAttribute("name", project.name); + + const components = xmlDocument.createElement("Components"); + const connections = xmlDocument.createElement("Connections"); + system.append(components, connections); + + project.nodes.forEach((node) => { + const component = xmlDocument.createElement("Component"); + component.setAttribute("id", node.id); + component.setAttribute("name", node.data.label); + component.setAttribute("type", node.data.modelType); + component.setAttribute("componentType", node.data.componentType); + component.setAttribute("x", formatEditableNumber(node.position.x)); + component.setAttribute("y", formatEditableNumber(node.position.y)); + + node.data.ports.forEach((portName) => { + const port = xmlDocument.createElement("Port"); + port.setAttribute("name", portName); + component.appendChild(port); + }); + Object.entries(node.data.parameters).forEach(([name, value]) => { + const parameter = xmlDocument.createElement("Parameter"); + parameter.setAttribute("name", name); + parameter.setAttribute("value", String(value)); + component.appendChild(parameter); + }); + components.appendChild(component); + }); + + project.edges.forEach((edge) => { + const connection = xmlDocument.createElement("Connection"); + connection.setAttribute("id", edge.id); + connection.setAttribute("source", edge.source); + connection.setAttribute("sourcePort", edge.sourceHandle ?? ""); + connection.setAttribute("target", edge.target); + connection.setAttribute("targetPort", edge.targetHandle ?? ""); + connections.appendChild(connection); + }); + + indentXmlElement(system); + return `\n${new XMLSerializer().serializeToString(system)}`; +} + +function indentXmlElement(element: Element, depth = 0) { + const children = Array.from(element.children); + if (children.length === 0) { + return; + } + children.forEach((child) => { + element.insertBefore( + element.ownerDocument.createTextNode(`\n${" ".repeat(depth + 1)}`), + child, + ); + indentXmlElement(child, depth + 1); + }); + element.appendChild( + element.ownerDocument.createTextNode(`\n${" ".repeat(depth)}`), + ); +} + async function postJson(url: string, body: unknown): Promise { const response = await fetch(url, { method: "POST", @@ -739,26 +1885,6 @@ async function postJson(url: string, body: unknown): Promise { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return response.text(); -} - -async function getJson(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as TResponse; -} - async function readApiError(response: Response) { const text = await response.text(); if (!text) { @@ -776,7 +1902,11 @@ async function readApiError(response: Response) { } function downloadText(filename: string, content: string) { - const blob = new Blob([content], { type: "application/xml;charset=utf-8" }); + downloadFile(filename, content, "application/xml;charset=utf-8"); +} + +function downloadFile(filename: string, content: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b0f0a19..bb605a9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -20,13 +20,14 @@ body, button, input, +select, textarea { font: inherit; } .app-shell { display: grid; - grid-template-rows: 72px minmax(0, 1fr); + grid-template-rows: auto 42px minmax(0, 1fr); width: 100%; height: 100%; } @@ -36,7 +37,8 @@ textarea { align-items: center; justify-content: space-between; gap: 16px; - padding: 0 14px; + min-height: 72px; + padding: 8px 14px; border-bottom: 1px solid #cfd8e3; background: #ffffff; } @@ -60,6 +62,24 @@ textarea { gap: 8px; } +.save-indicator { + min-width: 112px; + color: #64748b; + font-size: 11px; + text-align: center; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .primary-button, .secondary-button { height: 32px; @@ -104,6 +124,106 @@ textarea { color: #1f2933; } +.editor-toolbar { + position: relative; + z-index: 20; + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + padding: 5px 12px; + overflow: visible; + border-bottom: 1px solid #cfd8e3; + background: #f5f7f9; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 3px; +} + +.toolbar-separator { + width: 1px; + height: 22px; + background: #cbd3dd; +} + +.toolbar-item { + position: relative; + display: inline-flex; +} + +.toolbar-item::after { + position: absolute; + top: calc(100% + 7px); + left: 50%; + z-index: 50; + padding: 5px 7px; + border: 1px solid #aeb8c5; + border-radius: 4px; + color: #ffffff; + background: #263442; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18); + content: attr(data-tooltip); + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + line-height: 1; + opacity: 0; + pointer-events: none; + transform: translate(-50%, -2px); + transition: opacity 120ms ease, transform 120ms ease; + white-space: nowrap; +} + +.toolbar-item:hover::after, +.toolbar-item:focus-within::after { + opacity: 1; + transform: translate(-50%, 0); +} + +.editor-tool-button { + display: grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + color: #344252; + background: transparent; + cursor: pointer; +} + +.editor-tool-button:hover:not(:disabled) { + border-color: #bcc7d4; + color: #17212b; + background: #ffffff; +} + +.editor-tool-button:focus-visible { + outline: 2px solid #1d6fb8; + outline-offset: 1px; +} + +.editor-tool-button:disabled { + color: #9ba6b2; + cursor: not-allowed; + opacity: 0.62; +} + +.editor-tool-button.active { + border-color: #1d6fb8; + color: #155a94; + background: #dceefa; + box-shadow: inset 0 0 0 1px rgba(29, 111, 184, 0.08); +} + +.editor-tool-button.accent:not(.active) { + color: #26724a; +} + .workbench { display: grid; grid-template-columns: 230px minmax(0, 1fr) 330px; @@ -191,6 +311,70 @@ textarea { background: #f8fafc; } +.component-drag-preview { + position: fixed; + top: -1000px; + left: -1000px; + z-index: -1; + display: grid; + grid-template-rows: 30px 12px; + place-items: center; + width: 72px; + height: 58px; + padding: 5px 6px 4px; + border: 1px solid #7c8998; + border-radius: 5px; + color: #1f2933; + background: #ffffff; + box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16); + pointer-events: none; +} + +.component-drag-preview .palette-icon { + width: 28px; + height: 28px; + border-width: 1.5px; +} + +.component-drag-preview .icon-pipe { + width: 34px; + height: 10px; +} + +.component-drag-preview .icon-orifice { + width: 22px; + height: 22px; +} + +.component-drag-preview .icon-tee { + width: 30px; + height: 28px; +} + +.component-drag-preview .icon-tee::before { + top: 10px; + width: 30px; + height: 8px; + border-width: 1.5px; +} + +.component-drag-preview .icon-tee::after { + left: 10px; + width: 8px; + height: 28px; + border-width: 1.5px; +} + +.component-drag-preview-label { + max-width: 60px; + overflow: hidden; + font-size: 9px; + font-weight: 650; + line-height: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + .icon-cylinder { border-radius: 13px; background: #dbeafe; @@ -241,11 +425,44 @@ textarea { } .flow-canvas { + position: relative; min-width: 0; min-height: 0; background: #f8fafc; } +.recovery-banner button { + min-width: 48px; + height: 28px; + padding: 0 8px; + border: 1px solid #c2ccda; + border-radius: 4px; + color: #263442; + background: #ffffff; + cursor: pointer; +} + +.recovery-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 9px; + border: 1px solid #d5a52e; + border-radius: 6px; + color: #713f12; + background: #fffbeb; + box-shadow: 0 2px 8px rgba(120, 53, 15, 0.12); + font-size: 12px; + font-weight: 650; +} + +.recovery-banner button { + min-width: 42px; + border-color: #d5a52e; + color: #713f12; + background: #ffffff; +} + .flow-canvas .react-flow__pane, .flow-canvas .react-flow__pane.selection, .flow-canvas .react-flow__node.selectable, @@ -279,6 +496,16 @@ textarea { box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.15); } +.react-flow__node.validation-error .sim-node { + border-color: #c63b32; + box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14); +} + +.react-flow__node.validation-warning .sim-node { + border-color: #c08a16; + box-shadow: 0 0 0 3px rgba(192, 138, 22, 0.14); +} + .node-symbol { height: 28px; } @@ -378,13 +605,40 @@ textarea { font-weight: 400; } -.field input { +.field input, +.field select { width: 100%; height: 34px; padding: 0 9px; border: 1px solid #c2ccda; border-radius: 6px; color: #1f2933; + background: #ffffff; +} + +.field select { + padding: 0 7px; +} + +.parameter-control { + display: grid; + grid-template-columns: minmax(0, 1fr) 76px; + gap: 6px; +} + +.parameter-control.unitless { + grid-template-columns: minmax(0, 1fr); +} + +.field.invalid input, +.field.invalid select { + border-color: #c63b32; +} + +.field-error { + color: #b42318; + font-size: 11px; + font-weight: 500; } .node-id { @@ -400,6 +654,68 @@ textarea { font-size: 13px; } +.validation-section { + padding: 10px 12px; + border-bottom: 1px solid #e3e8ef; + background: #fffdf9; +} + +.validation-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + font-size: 12px; +} + +.validation-heading span { + color: #9a6700; + font-size: 11px; + font-weight: 500; +} + +.validation-list { + display: grid; + gap: 6px; +} + +.validation-item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 7px; + align-items: start; + width: 100%; + padding: 7px; + border: 1px solid #e5c0bd; + border-radius: 5px; + color: #7a271a; + text-align: left; + background: #fff7f6; + cursor: pointer; +} + +.validation-item.warning { + border-color: #ead59a; + color: #713f12; + background: #fffbeb; +} + +.validation-item:disabled { + cursor: default; +} + +.validation-item span { + font-size: 10px; + font-weight: 700; +} + +.validation-item strong { + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.45; +} + .xml-output { width: calc(100% - 24px); min-height: 220px;