Commit ba3d23a8 authored by drigle's avatar drigle

feat: 支持拓扑节点删除恢复并修正右键菜单定位

parent e7a7bf9d
import assert from 'node:assert/strict';
import test from 'node:test';
import { getGraphBranchIds, isGraphDeleteShortcut, setGraphBranchRemoved } from '../src/pages/internal_regulation_manager/graphRemoval.js';
import { getNextGraphRecordNode } from '../src/pages/internal_regulation_manager/graphSelection.js';
import { serializeGraph } from '../src/services/regulationGraphData.js';
function exampleGraph() {
const nodes = [
{ id: 'root', type: 'rootNode', parent: null },
{ id: 'branch', type: 'groupNode', parent: 'root', content: '流程备注' },
{ id: 'child', type: 'regulationNode', parent: 'branch', relatedRegulation: 'same-regulation' },
{ id: 'grandchild', type: 'regulationNode', parent: 'child', relatedReviseRegulation: 'new-plan' },
{ id: 'sibling', type: 'regulationNode', parent: 'root', relatedRegulation: 'same-regulation' },
].map((node) => ({ ...node, removed: false, data: { removed: false } }));
return { nodes, edges: nodes.filter((node) => node.parent).map((node) => ({ source: node.parent, target: node.id })) };
}
test('deleting a branch includes descendants but preserves parent and sibling occurrences of the same regulation', () => {
const original = exampleGraph();
const removed = setGraphBranchRemoved(original, 'branch', true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), ['branch', 'child', 'grandchild']);
assert.equal(removed.nodes[0], original.nodes[0]);
assert.equal(removed.nodes.at(-1), original.nodes.at(-1));
assert.equal(removed.edges, original.edges);
assert.equal(removed.nodes[1].content, '流程备注');
assert.equal(removed.nodes[2].relatedRegulation, 'same-regulation');
assert.equal(removed.nodes[3].relatedReviseRegulation, 'new-plan');
assert.equal(original.nodes.some((node) => node.removed || node.data.removed), false);
removed.nodes.forEach((node) => assert.equal(node.data.removed, node.removed));
});
test('deleting a leaf does not remove the entire regulation or its other occurrence', () => {
const original = exampleGraph();
const removed = setGraphBranchRemoved(original, 'grandchild', true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), ['grandchild']);
const record = { _id: 'same-regulation' };
assert.equal(getNextGraphRecordNode(record, removed.nodes, 'child', record._id).id, 'sibling');
});
test('record navigation skips removed occurrences while other occurrences remain', () => {
const removed = setGraphBranchRemoved(exampleGraph(), 'child', true);
const record = { _id: 'same-regulation' };
assert.equal(getNextGraphRecordNode(record, removed.nodes, 'child', record._id).id, 'sibling');
assert.equal(getNextGraphRecordNode(record, removed.nodes, 'sibling', record._id).id, 'sibling');
});
test('saving a deleted branch preserves its removal state, hierarchy and links for reload and recovery', () => {
const original = exampleGraph();
const saved = serializeGraph(setGraphBranchRemoved(original, 'branch', true));
const reloaded = JSON.parse(JSON.stringify(saved));
assert.deepEqual(reloaded.nodes.filter((node) => node.removed).map((node) => node.id), ['branch', 'child', 'grandchild']);
assert.equal(reloaded.nodes[3].parent, 'child');
assert.equal(reloaded.nodes[2].data.related_regulation, 'same-regulation');
assert.equal(reloaded.edges.length, original.edges.length);
const restored = setGraphBranchRemoved(reloaded, 'branch', false);
assert.equal(restored.nodes.some((node) => node.removed || node.data.removed), false);
assert.deepEqual(restored.edges, reloaded.edges);
});
test('restoring a descendant restores its parent path without restoring deleted sibling branches', () => {
const removed = setGraphBranchRemoved(exampleGraph(), 'root', true);
const restored = setGraphBranchRemoved(removed, 'grandchild', false);
assert.deepEqual(restored.nodes.filter((node) => !node.removed).map((node) => node.id), ['root', 'branch', 'child', 'grandchild']);
assert.equal(restored.nodes.at(-1).removed, true);
assert.equal(removed.nodes.every((node) => node.removed), true);
});
test('numeric IDs and legacy data.parent still identify the correct branch', () => {
const graph = { nodes: [{ id: 1 }, { id: 2, data: { parent: 1 } }, { id: 3 }], edges: [] };
assert.deepEqual([...getGraphBranchIds(graph.nodes, '1')], ['1', '2']);
const removed = setGraphBranchRemoved(graph, 1, true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), [1, 2]);
});
test('missing selections and missing nodes leave the graph intact', () => {
const graph = exampleGraph();
for (const id of [null, undefined, 'missing']) {
assert.equal(getGraphBranchIds(graph.nodes, id).size, 0);
assert.equal(setGraphBranchRemoved(graph, id, true), graph);
}
assert.equal(getGraphBranchIds([{ id: 'null' }], null).size, 0);
});
test('branch traversal and restoring an ancestor path terminate with cyclic parents', () => {
const graph = { nodes: [{ id: 'a', parent: 'b' }, { id: 'b', parent: 'a' }, { id: 'other' }], edges: [] };
const removed = setGraphBranchRemoved(graph, 'a', true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), ['a', 'b']);
const restored = setGraphBranchRemoved(removed, 'b', false);
assert.equal(restored.nodes.some((node) => node.removed), false);
});
test('only unmodified Delete or Mac backward-delete presses trigger deletion', () => {
const event = { key: 'Delete' };
assert.equal(isGraphDeleteShortcut(event), true);
assert.equal(isGraphDeleteShortcut({ key: 'Backspace' }), true);
for (const key of ['Enter', 'Escape']) assert.equal(isGraphDeleteShortcut({ key }), false);
for (const flag of ['defaultPrevented', 'repeat', 'isComposing', 'ctrlKey', 'metaKey', 'altKey', 'shiftKey']) {
assert.equal(isGraphDeleteShortcut({ ...event, [flag]: true }), false, flag);
}
});
test('editable fields or overlay targets block the shortcut while canvas targets permit it', () => {
assert.equal(isGraphDeleteShortcut({ key: 'Delete', target: { closest: () => ({}) } }), false);
assert.equal(isGraphDeleteShortcut({ key: 'Delete', target: { closest: () => null } }), true);
});
import { Button, Checkbox, Chip, Dropdown, Input, Label, Spinner, Tabs, TextArea, TextField, Tooltip, toast } from '@heroui/react'; import { Button, Checkbox, Chip, Dropdown, Input, Label, Spinner, Tabs, TextArea, TextField, Tooltip, toast } from '@heroui/react';
import dagre from 'dagre'; import dagre from 'dagre';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import ReactFlow, { Background, Controls, MiniMap, ReactFlowProvider } from 'reactflow'; import ReactFlow, { Background, Controls, MiniMap, ReactFlowProvider } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { history } from 'umi'; import { history } from 'umi';
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
ArrowPathIcon, ArrowPathIcon,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Delete,
Download, Download,
Edit, Edit,
Expand, Expand,
...@@ -28,6 +29,7 @@ import { createGraphXmindArchive } from '@/services/regulationGraphXmind'; ...@@ -28,6 +29,7 @@ import { createGraphXmindArchive } from '@/services/regulationGraphXmind';
import GraphNode from './GraphNode'; import GraphNode from './GraphNode';
import { getGraphRecordId, getNextGraphRecordNode, getSelectedGraphRecordId } from './graphSelection'; import { getGraphRecordId, getNextGraphRecordNode, getSelectedGraphRecordId } from './graphSelection';
import { findGraphDropTarget, REGULATION_DRAG_TYPE } from './graphDrop'; import { findGraphDropTarget, REGULATION_DRAG_TYPE } from './graphDrop';
import { getGraphBranchIds, isGraphDeleteShortcut, setGraphBranchRemoved } from './graphRemoval';
import { import {
loadGraph, loadGraph,
loadGraphArchive, loadGraphArchive,
...@@ -259,6 +261,8 @@ export default function RegulationGraph() { ...@@ -259,6 +261,8 @@ export default function RegulationGraph() {
const flowRef = useRef(null); const flowRef = useRef(null);
const focusRequestRef = useRef(0); const focusRequestRef = useRef(0);
const canvasRef = useRef(null); const canvasRef = useRef(null);
const graphCanvasRef = useRef(null);
const contextMenuRef = useRef(null);
const importInputRef = useRef(null); const importInputRef = useRef(null);
const applyGraph = useCallback((nextGraph) => { const applyGraph = useCallback((nextGraph) => {
...@@ -298,6 +302,37 @@ export default function RegulationGraph() { ...@@ -298,6 +302,37 @@ export default function RegulationGraph() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [notice]); }, [notice]);
const openContextMenu = useCallback((event, nodeId = null) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ clientX: event.clientX, clientY: event.clientY, nodeId });
}, []);
useLayoutEffect(() => {
const canvas = graphCanvasRef.current;
const menu = contextMenuRef.current;
if (!contextMenu || !canvas || !menu) return;
// Use the menu's containing block, not the wrapper that also contains the
// sidebars. Flow zoom/pan does not affect these viewport coordinates.
const rect = canvas.getBoundingClientRect();
const maxX = Math.max(0, canvas.clientWidth - menu.offsetWidth);
const maxY = Math.max(0, canvas.clientHeight - menu.offsetHeight);
menu.style.left = `${Math.min(maxX, Math.max(0, contextMenu.clientX - rect.left - canvas.clientLeft))}px`;
menu.style.top = `${Math.min(maxY, Math.max(0, contextMenu.clientY - rect.top - canvas.clientTop))}px`;
}, [contextMenu, leftOpen, rightOpen]);
useEffect(() => {
if (!contextMenu) return undefined;
const closeMenu = () => setContextMenu(null);
const handleKeyDown = (event) => { if (event.key === 'Escape') closeMenu(); };
window.addEventListener('resize', closeMenu);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('resize', closeMenu);
window.removeEventListener('keydown', handleKeyDown);
};
}, [contextMenu]);
const nodeById = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node])), [graph.nodes]); const nodeById = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node])), [graph.nodes]);
const filteredCandidates = useMemo(() => { const filteredCandidates = useMemo(() => {
let candidates = graph.nodes.filter((node) => showRemoved || !node.removed); let candidates = graph.nodes.filter((node) => showRemoved || !node.removed);
...@@ -341,9 +376,13 @@ export default function RegulationGraph() { ...@@ -341,9 +376,13 @@ export default function RegulationGraph() {
}), [collapsedIds, filteredCandidates, nodeById]); }), [collapsedIds, filteredCandidates, nodeById]);
const visibleIds = useMemo(() => new Set(visibleNodes.map((node) => node.id)), [visibleNodes]); const visibleIds = useMemo(() => new Set(visibleNodes.map((node) => node.id)), [visibleNodes]);
const filteredNodes = useMemo(() => { const query = searchText.trim().toLocaleLowerCase(); return filteredCandidates.filter((node) => !query || node.title.toLocaleLowerCase().includes(query)); }, [filteredCandidates, searchText]); const filteredNodes = useMemo(() => { const query = searchText.trim().toLocaleLowerCase(); return filteredCandidates.filter((node) => !query || node.title.toLocaleLowerCase().includes(query)); }, [filteredCandidates, searchText]);
const removedNodes = useMemo(() => graph.nodes.filter((node) => node.removed), [graph.nodes]); const removedNodes = useMemo(() => {
const linkedIds = useMemo(() => new Set(graph.nodes.map((node) => String(node.relatedRegulation || node.relatedReviseRegulation)).filter(Boolean)), [graph.nodes]); const query = searchText.trim().toLocaleLowerCase();
const linkedTitles = useMemo(() => new Set(graph.nodes.map((node) => node.title).filter(Boolean)), [graph.nodes]); return graph.nodes.filter((node) => node.removed && (!query || node.title.toLocaleLowerCase().includes(query)));
}, [graph.nodes, searchText]);
const linkedIds = useMemo(() => new Set(graph.nodes.filter((node) => !node.removed)
.flatMap((node) => [node.relatedRegulation, node.relatedReviseRegulation]).filter(Boolean).map(String)), [graph.nodes]);
const linkedTitles = useMemo(() => new Set(graph.nodes.filter((node) => !node.removed).map((node) => node.title).filter(Boolean)), [graph.nodes]);
const pendingNodes = useMemo(() => { const pendingNodes = useMemo(() => {
const unlinkedRegulations = regulations const unlinkedRegulations = regulations
.filter((record) => !linkedIds.has(String(record._id || record.name)) && !linkedTitles.has(metadataOf(record).regulation_name || record.title)) .filter((record) => !linkedIds.has(String(record._id || record.name)) && !linkedTitles.has(metadataOf(record).regulation_name || record.title))
...@@ -368,13 +407,14 @@ export default function RegulationGraph() { ...@@ -368,13 +407,14 @@ export default function RegulationGraph() {
return [...regulationItems, ...additionItems].filter((record) => !query || String(metadataOf(record).regulation_name || record.title || '').toLocaleLowerCase().includes(query)); return [...regulationItems, ...additionItems].filter((record) => !query || String(metadataOf(record).regulation_name || record.title || '').toLocaleLowerCase().includes(query));
}, [regulations, revisions, searchText]); }, [regulations, revisions, searchText]);
const selectedNode = selectedId ? nodeById.get(String(selectedId)) : null; const selectedNode = selectedId ? nodeById.get(String(selectedId)) : null;
const selectedBranchIds = useMemo(() => getGraphBranchIds(graph.nodes, selectedId), [graph.nodes, selectedId]);
const highlightedRecordId = useMemo(() => getSelectedGraphRecordId(selectedNode, [...regulations, ...revisions], selectedRecordId), [regulations, revisions, selectedNode, selectedRecordId]); const highlightedRecordId = useMemo(() => getSelectedGraphRecordId(selectedNode, [...regulations, ...revisions], selectedRecordId), [regulations, revisions, selectedNode, selectedRecordId]);
const processTree = useMemo(() => buildProcessTree(graph.nodes), [graph.nodes]); const processTree = useMemo(() => buildProcessTree(graph.nodes), [graph.nodes]);
const toggleCollapsed = useCallback((nodeId) => setCollapsedIds((current) => { const next = new Set(current); const id = String(nodeId); if (next.has(id)) next.delete(id); else next.add(id); return next; }), []); const toggleCollapsed = useCallback((nodeId) => setCollapsedIds((current) => { const next = new Set(current); const id = String(nodeId); if (next.has(id)) next.delete(id); else next.add(id); return next; }), []);
const visibleEdges = useMemo(() => graph.edges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)), [graph.edges, visibleIds]); const visibleEdges = useMemo(() => graph.edges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)), [graph.edges, visibleIds]);
const flowNodes = useMemo(() => { const flowNodes = useMemo(() => {
const childrenByParent = new Map(); const childrenByParent = new Map();
graph.nodes.forEach((node) => { if (node.parent) childrenByParent.set(node.parent, [...(childrenByParent.get(node.parent) || []), node]); }); graph.nodes.forEach((node) => { if (node.parent && (showRemoved || !node.removed)) childrenByParent.set(node.parent, [...(childrenByParent.get(node.parent) || []), node]); });
const positioned = layoutFlow(visibleNodes, visibleEdges); const positioned = layoutFlow(visibleNodes, visibleEdges);
return positioned.map((node) => ({ return positioned.map((node) => ({
...node, ...node,
...@@ -398,7 +438,7 @@ export default function RegulationGraph() { ...@@ -398,7 +438,7 @@ export default function RegulationGraph() {
onToggle: toggleCollapsed, onToggle: toggleCollapsed,
}, },
})); }));
}, [collapsedIds, draggedPositions, dropPreview?.nodeId, graph.nodes, selectedId, visibleEdges, visibleNodes]); }, [collapsedIds, draggedPositions, dropPreview?.nodeId, graph.nodes, selectedId, showRemoved, visibleEdges, visibleNodes]);
const selectNode = useCallback((node, recordId = null) => { const selectNode = useCallback((node, recordId = null) => {
if (!node) return; if (!node) return;
focusRequestRef.current += 1; focusRequestRef.current += 1;
...@@ -456,6 +496,41 @@ export default function RegulationGraph() { ...@@ -456,6 +496,41 @@ export default function RegulationGraph() {
focusNode(node, getGraphRecordId(record)); focusNode(node, getGraphRecordId(record));
}, [focusNode, graph.nodes, selectedId, selectedRecordId]); }, [focusNode, graph.nodes, selectedId, selectedRecordId]);
const clearNodeSelection = useCallback(() => {
focusRequestRef.current += 1;
setSelectedId(null);
setSelectedRecordId(null);
}, []);
const deleteSelectedNode = useCallback(() => {
if (archive !== '当前版本' || loading || saving || nodeDialog || !selectedNode || selectedNode.removed) return;
setGraph((current) => setGraphBranchRemoved(current, selectedNode.id, true));
clearNodeSelection();
setSelectedGroupIds((current) => current.filter((id) => !selectedBranchIds.has(String(id))));
setContextMenu(null);
setDropPreview(null);
setShowRemoved(false);
setNotice(`已删除「${selectedNode.title}」${selectedBranchIds.size > 1 ? '及其子节点' : ''},可在“删除节点”列表恢复;请保存拓扑图`);
}, [archive, clearNodeSelection, loading, nodeDialog, saving, selectedBranchIds, selectedNode]);
useEffect(() => {
const handleKeyDown = (event) => {
if (!isGraphDeleteShortcut(event) || archive !== '当前版本' || loading || saving || nodeDialog || !selectedNode || selectedNode.removed) return;
if (event.target !== document.body && !canvasRef.current?.contains(event.target)) return;
event.preventDefault();
deleteSelectedNode();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [archive, deleteSelectedNode, loading, nodeDialog, saving, selectedNode]);
const restoreSelectedNode = () => {
if (archive !== '当前版本' || loading || saving || nodeDialog || !selectedNode?.removed) return;
setGraph((current) => setGraphBranchRemoved(current, selectedNode.id, false));
focusNode({ ...selectedNode, removed: false }, selectedRecordId);
setNotice(`「${selectedNode.title}」${selectedBranchIds.size > 1 ? '及其子节点' : ''}已恢复,请保存拓扑图`);
};
const setNodeField = (field, value) => setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === String(selectedId) ? { ...node, [field]: value, data: { ...node.data, [field === 'title' ? 'title' : 'content']: value } } : node) })); const setNodeField = (field, value) => setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === String(selectedId) ? { ...node, [field]: value, data: { ...node.data, [field === 'title' ? 'title' : 'content']: value } } : node) }));
const restoreView = () => flowRef.current?.fitView?.({ padding: 0.22, duration: 240 }); const restoreView = () => flowRef.current?.fitView?.({ padding: 0.22, duration: 240 });
const toggleFullscreen = async () => { if (!canvasRef.current) return; if (document.fullscreenElement) await document.exitFullscreen?.(); else await canvasRef.current.requestFullscreen?.(); }; const toggleFullscreen = async () => { if (!canvasRef.current) return; if (document.fullscreenElement) await document.exitFullscreen?.(); else await canvasRef.current.requestFullscreen?.(); };
...@@ -494,7 +569,7 @@ export default function RegulationGraph() { ...@@ -494,7 +569,7 @@ export default function RegulationGraph() {
if (!nodeDialog?.parentId || !title) return; if (!nodeDialog?.parentId || !title) return;
if (nodeDialog.mode === 'description') setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === String(nodeDialog.parentId) ? { ...node, content: nodeForm.title.trim(), data: { ...node.data, content: nodeForm.title.trim() } } : node) })); if (nodeDialog.mode === 'description') setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === String(nodeDialog.parentId) ? { ...node, content: nodeForm.title.trim(), data: { ...node.data, content: nodeForm.title.trim() } } : node) }));
else { const id = makeId('group'); const node = normalizeNode({ id, type: 'groupNode', title, parent: nodeDialog.parentId, content: nodeForm.content, relatedRegulationType: 'active', position: { x: 0, y: 0 } }); setGraph((current) => ({ nodes: [...current.nodes, node], edges: [...current.edges, { id: `${nodeDialog.parentId}->${id}`, source: String(nodeDialog.parentId), target: id }] })); } else { const id = makeId('group'); const node = normalizeNode({ id, type: 'groupNode', title, parent: nodeDialog.parentId, content: nodeForm.content, relatedRegulationType: 'active', position: { x: 0, y: 0 } }); setGraph((current) => ({ nodes: [...current.nodes, node], edges: [...current.edges, { id: `${nodeDialog.parentId}->${id}`, source: String(nodeDialog.parentId), target: id }] })); }
setNodeDialog(null); setNotice(nodeDialog.mode === 'description' ? '节点描述已更新,请保存拓扑图' : '节点已添加,请保存拓扑图'); setNodeDialog(null); setNotice(nodeDialog.mode === 'description' ? '节点备注已更新,请保存拓扑图' : '节点已添加,请保存拓扑图');
}; };
const importGraph = async (event) => { const importGraph = async (event) => {
const file = event.target.files?.[0]; if (!file) return; const file = event.target.files?.[0]; if (!file) return;
...@@ -594,9 +669,9 @@ export default function RegulationGraph() { ...@@ -594,9 +669,9 @@ export default function RegulationGraph() {
{notice ? <div role="status" className="rounded-xl border border-success/30 bg-success/5 p-3 text-sm text-success">{notice}</div> : null} {notice ? <div role="status" className="rounded-xl border border-success/30 bg-success/5 p-3 text-sm text-success">{notice}</div> : null}
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded-2xl border border-separator bg-background shadow-sm" ref={canvasRef}> <div className="relative flex min-h-0 flex-1 overflow-hidden rounded-2xl border border-separator bg-background shadow-sm" ref={canvasRef}>
<aside className={`shrink-0 overflow-hidden border-e border-separator transition-[width] ${leftOpen ? 'w-72' : 'w-0'}`}><div className="flex h-full w-72 flex-col"><div className="border-b border-separator p-3"><TextField fullWidth value={searchText} onChange={setSearchText}><Label className="sr-only">搜索节点</Label><div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={16} /><Input className="pl-9" fullWidth placeholder="搜索制度或节点" variant="secondary" /></div></TextField></div><Tabs className="min-w-0" selectedKey={activePanel} variant="primary" onSelectionChange={(key) => setActivePanel(String(key))}><Tabs.ListContainer className="w-full min-w-0 overflow-hidden border-b border-separator px-2"><Tabs.List className="grid w-full min-w-0 grid-cols-4" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', width: '100%' }} aria-label="拓扑图节点列表"><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="all">全部<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="pending">待分类<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="nodes">节点<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="removed">删除节点<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer></Tabs><div className="min-h-0 flex-1 overflow-y-auto p-2">{listNodes.map((node) => { const isRecordList = activePanel === 'all' || activePanel === 'pending'; const isSelected = isRecordList ? highlightedRecordId !== null && getGraphRecordId(node) === highlightedRecordId : selectedId === node.id; const title = isRecordList ? metadataOf(node).regulation_name || node.title : node.title; const type = isRecordList ? node.__pendingStatus || (metadataOf(node).type === '新增' ? 'addition' : metadataOf(node).type === '废止' ? 'forbidden' : 'revise') : nodeStatus(node); return <button draggable={isRecordList && archive === '当前版本' && !saving} onDragStart={(event) => { event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData(REGULATION_DRAG_TYPE, JSON.stringify(node)); }} onDragEnd={clearDropPreview} className={`flex min-h-11 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-surface-secondary ${isSelected ? 'bg-accent/10 text-accent' : ''}`} aria-current={isSelected ? 'true' : undefined} key={node._id || node.id || node.name} onClick={() => isRecordList ? focusRecordNode(node) : focusNode(node)}><span className="min-w-0 flex-1 truncate">{title}</span><Chip color={STATUS_OPTIONS.find((item) => item.value === type)?.color || 'default'} size="sm" variant="soft"><Chip.Label>{isRecordList ? (type === 'active' ? '现行有效' : `待${metadataOf(node).type || '处理'}`) : nodeTypeLabel(node.type)}</Chip.Label></Chip></button>; })}{listNodes.length === 0 ? <p className="p-4 text-center text-sm text-muted">暂无节点</p> : null}</div></div></aside> <aside className={`shrink-0 overflow-hidden border-e border-separator transition-[width] ${leftOpen ? 'w-72' : 'w-0'}`}><div className="flex h-full w-72 flex-col"><div className="border-b border-separator p-3"><TextField fullWidth value={searchText} onChange={setSearchText}><Label className="sr-only">搜索节点</Label><div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={16} /><Input className="pl-9" fullWidth placeholder="搜索制度或节点" variant="secondary" /></div></TextField></div><Tabs className="min-w-0" selectedKey={activePanel} variant="primary" onSelectionChange={(key) => setActivePanel(String(key))}><Tabs.ListContainer className="w-full min-w-0 overflow-hidden border-b border-separator px-2"><Tabs.List className="grid w-full min-w-0 grid-cols-4" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', width: '100%' }} aria-label="拓扑图节点列表"><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="all">全部<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="pending">待分类<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="nodes">节点<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="removed">删除节点<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer></Tabs><div className="min-h-0 flex-1 overflow-y-auto p-2">{listNodes.map((node) => { const isRecordList = activePanel === 'all' || activePanel === 'pending'; const isSelected = isRecordList ? highlightedRecordId !== null && getGraphRecordId(node) === highlightedRecordId : selectedId === node.id; const title = isRecordList ? metadataOf(node).regulation_name || node.title : node.title; const type = isRecordList ? node.__pendingStatus || (metadataOf(node).type === '新增' ? 'addition' : metadataOf(node).type === '废止' ? 'forbidden' : 'revise') : nodeStatus(node); return <button draggable={isRecordList && archive === '当前版本' && !saving} onDragStart={(event) => { event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData(REGULATION_DRAG_TYPE, JSON.stringify(node)); }} onDragEnd={clearDropPreview} className={`flex min-h-11 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-surface-secondary ${isSelected ? 'bg-accent/10 text-accent' : ''}`} aria-current={isSelected ? 'true' : undefined} key={node._id || node.id || node.name} onClick={() => isRecordList ? focusRecordNode(node) : focusNode(node)}><span className="min-w-0 flex-1 truncate">{title}</span><Chip color={STATUS_OPTIONS.find((item) => item.value === type)?.color || 'default'} size="sm" variant="soft"><Chip.Label>{isRecordList ? (type === 'active' ? '现行有效' : `待${metadataOf(node).type || '处理'}`) : nodeTypeLabel(node.type)}</Chip.Label></Chip></button>; })}{listNodes.length === 0 ? <p className="p-4 text-center text-sm text-muted">暂无节点</p> : null}</div></div></aside>
<main className="relative min-h-0 min-w-0 flex-1 bg-surface-secondary/30" onDragOver={previewRecordDrop} onDragLeave={leaveRecordDrop} onDrop={dropPendingRevision}><div className="absolute inset-x-3 top-3 z-10 flex flex-wrap items-center justify-between gap-2"><div className="flex flex-wrap items-center gap-2 rounded-xl border border-separator bg-background/95 p-2 shadow-sm backdrop-blur"><XinyuanAutocomplete className="w-40" aria-label="归档版本" options={archives.map((value) => ({ value, label: value }))} selectedKey={archive} onSelectionChange={(value) => loadArchive(String(value))} /><ProcessCascade tree={processTree} value={selectedGroupIds} onChange={setSelectedGroupIds} /><Checkbox isSelected={showCurrent} variant="secondary" onChange={setShowCurrent}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示现行</Label></Checkbox><Checkbox isSelected={showRemoved} variant="secondary" onChange={setShowRemoved}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示已移除</Label></Checkbox>{showCurrent ? <div className="flex items-center gap-1">{STATUS_OPTIONS.map((option) => <button className={`rounded-md px-2 py-1 text-xs ${selectedStatuses.includes(option.value) ? 'bg-accent/10 text-accent' : 'text-muted hover:bg-surface-secondary'}`} key={option.value} onClick={() => setSelectedStatuses((current) => current.includes(option.value) ? current.filter((value) => value !== option.value) : [...current, option.value])}>{option.label}</button>)}</div> : null}</div><div className="flex items-center gap-1 rounded-xl border border-separator bg-background/95 p-1 shadow-sm backdrop-blur"><Tooltip delay={0}><Button isIconOnly aria-label="恢复视图" size="sm" variant="tertiary" onPress={restoreView}><Reload size={16} /></Button><Tooltip.Content>恢复视图</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="展开全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(new Set())}><Expand size={16} /></Button><Tooltip.Content>展开全部</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="折叠全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(defaultCollapsedIds(graph.nodes))}><ArrowPathIcon size={16} /></Button><Tooltip.Content>折叠到第一层</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="全屏查看" size="sm" variant="tertiary" onPress={toggleFullscreen}><Fullscreen size={16} /></Button><Tooltip.Content>全屏查看</Tooltip.Content></Tooltip></div> <main ref={graphCanvasRef} className="relative min-h-0 min-w-0 flex-1 bg-surface-secondary/30" onDragOver={previewRecordDrop} onDragLeave={leaveRecordDrop} onDrop={dropPendingRevision}><div className="absolute inset-x-3 top-3 z-10 flex flex-wrap items-center justify-between gap-2"><div className="flex flex-wrap items-center gap-2 rounded-xl border border-separator bg-background/95 p-2 shadow-sm backdrop-blur"><XinyuanAutocomplete className="w-40" aria-label="归档版本" options={archives.map((value) => ({ value, label: value }))} selectedKey={archive} onSelectionChange={(value) => loadArchive(String(value))} /><ProcessCascade tree={processTree} value={selectedGroupIds} onChange={setSelectedGroupIds} /><Checkbox isSelected={showCurrent} variant="secondary" onChange={setShowCurrent}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示现行</Label></Checkbox><Checkbox isSelected={showRemoved} variant="secondary" onChange={setShowRemoved}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示已移除</Label></Checkbox>{showCurrent ? <div className="flex items-center gap-1">{STATUS_OPTIONS.map((option) => <button className={`rounded-md px-2 py-1 text-xs ${selectedStatuses.includes(option.value) ? 'bg-accent/10 text-accent' : 'text-muted hover:bg-surface-secondary'}`} key={option.value} onClick={() => setSelectedStatuses((current) => current.includes(option.value) ? current.filter((value) => value !== option.value) : [...current, option.value])}>{option.label}</button>)}</div> : null}</div><div className="flex items-center gap-1 rounded-xl border border-separator bg-background/95 p-1 shadow-sm backdrop-blur"><Tooltip delay={0}><Button isIconOnly aria-label="恢复视图" size="sm" variant="tertiary" onPress={restoreView}><Reload size={16} /></Button><Tooltip.Content>恢复视图</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="展开全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(new Set())}><Expand size={16} /></Button><Tooltip.Content>展开全部</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="折叠全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(defaultCollapsedIds(graph.nodes))}><ArrowPathIcon size={16} /></Button><Tooltip.Content>折叠到第一层</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="全屏查看" size="sm" variant="tertiary" onPress={toggleFullscreen}><Fullscreen size={16} /></Button><Tooltip.Content>全屏查看</Tooltip.Content></Tooltip></div>
</div><div className="relative h-full min-h-0 w-full" onContextMenu={(event) => event.preventDefault()}>{visibleNodes.length ? <ReactFlowProvider><ReactFlow nodes={flowNodes} edges={visibleEdges} nodeTypes={NODE_TYPES} fitView onInit={(instance) => { flowRef.current = instance; instance.fitView({ padding: 0.22 }); }} onNodeClick={(_, node) => selectNode(nodeById.get(String(node.id)))} onNodeDoubleClick={(_, node) => { const current = nodeById.get(String(node.id)); const relatedId = current?.relatedRegulation || current?.relatedReviseRegulation; if (relatedId) history.push(`/internal_regulation/${relatedId}`); }} onNodeDragStop={(_, node) => { setDraggedPositions((current) => ({ ...current, [node.id]: node.position })); setGraph((current) => ({ ...current, nodes: current.nodes.map((item) => item.id === node.id ? { ...item, position: node.position } : item) })); }} onNodeContextMenu={(event, node) => { event.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (rect) setContextMenu({ x: event.clientX - rect.left, y: event.clientY - rect.top, nodeId: String(node.id) }); }} onPaneContextMenu={(event) => { event.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (rect) setContextMenu({ x: event.clientX - rect.left, y: event.clientY - rect.top, nodeId: null }); }} onPaneClick={() => setContextMenu(null)} proOptions={{ hideAttribution: true }}><MiniMap pannable zoomable /><Controls showInteractive={false} /><Background color="#e2e8f0" gap={24} /></ReactFlow></ReactFlowProvider> : <div className="flex h-full min-h-0 items-center justify-center text-sm text-muted">暂无可展示的拓扑节点</div>} </div><div className="relative h-full min-h-0 w-full" onContextMenu={(event) => event.preventDefault()}>{visibleNodes.length ? <ReactFlowProvider><ReactFlow nodes={flowNodes} edges={visibleEdges} deleteKeyCode={null} nodeTypes={NODE_TYPES} fitView onInit={(instance) => { flowRef.current = instance; instance.fitView({ padding: 0.22 }); }} onNodeClick={(_, node) => selectNode(nodeById.get(String(node.id)))} onNodeDoubleClick={(_, node) => { const current = nodeById.get(String(node.id)); const relatedId = current?.relatedRegulation || current?.relatedReviseRegulation; if (relatedId) history.push(`/internal_regulation/${relatedId}`); }} onNodeDragStop={(_, node) => { setDraggedPositions((current) => ({ ...current, [node.id]: node.position })); setGraph((current) => ({ ...current, nodes: current.nodes.map((item) => item.id === node.id ? { ...item, position: node.position } : item) })); }} onNodeContextMenu={(event, node) => openContextMenu(event, String(node.id))} onPaneContextMenu={openContextMenu} onMoveStart={() => setContextMenu(null)} onNodeDragStart={() => setContextMenu(null)} onPaneClick={() => { setContextMenu(null); clearNodeSelection(); }} proOptions={{ hideAttribution: true }}><MiniMap pannable zoomable /><Controls showInteractive={false} /><Background color="#e2e8f0" gap={24} /></ReactFlow></ReactFlowProvider> : <div className="flex h-full min-h-0 items-center justify-center text-sm text-muted">暂无可展示的拓扑节点</div>}
{dropPreview ? <> {dropPreview ? <>
<svg aria-hidden="true" className="pointer-events-none absolute inset-0 z-[5] size-full overflow-hidden text-accent" data-graph-drop-preview> <svg aria-hidden="true" className="pointer-events-none absolute inset-0 z-[5] size-full overflow-hidden text-accent" data-graph-drop-preview>
<g transform={`translate(${dropPreview.viewport.x}, ${dropPreview.viewport.y}) scale(${dropPreview.viewport.zoom})`}> <g transform={`translate(${dropPreview.viewport.x}, ${dropPreview.viewport.y}) scale(${dropPreview.viewport.zoom})`}>
...@@ -609,7 +684,20 @@ export default function RegulationGraph() { ...@@ -609,7 +684,20 @@ export default function RegulationGraph() {
松开连接到「{nodeById.get(dropPreview.nodeId)?.title}」 松开连接到「{nodeById.get(dropPreview.nodeId)?.title}」
</div> </div>
</> : null} </> : null}
</div>{contextMenu ? <div className="absolute z-30 w-40 rounded-lg border border-separator bg-background p-1 shadow-lg" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(event) => event.stopPropagation()}><button className="flex min-h-10 w-full items-center gap-2 rounded-md px-3 text-left text-sm hover:bg-surface-secondary" onClick={() => openAddNode(contextMenu.nodeId, 'group')}><PlusIcon size={15} />添加流程节点</button>{contextMenu.nodeId ? <button className="flex min-h-10 w-full items-center gap-2 rounded-md px-3 text-left text-sm hover:bg-surface-secondary" onClick={() => openAddNode(contextMenu.nodeId, 'description')}><Edit size={15} />添加描述</button> : null}</div> : null}<button aria-label={leftOpen ? '收起左侧节点列表' : '展开左侧节点列表'} className="absolute left-1 top-1/2 z-20 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setLeftOpen((value) => !value)}><ChevronRight className={leftOpen ? 'rotate-180' : ''} size={15} /></button></main> </div>
{contextMenu ? (
<div ref={contextMenuRef} role="menu" aria-label="节点操作" className="absolute left-0 top-0 z-30 w-40 rounded-lg border border-separator bg-background p-1 shadow-lg" onClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.preventDefault()}>
<Button role="menuitem" className="min-h-10 w-full justify-start gap-2 rounded-md px-3 text-sm" variant="tertiary" onPress={() => openAddNode(contextMenu.nodeId, 'group')}>
<PlusIcon size={15} />添加流程节点
</Button>
{contextMenu.nodeId ? (
<Button role="menuitem" className="min-h-10 w-full justify-start gap-2 rounded-md px-3 text-sm" variant="tertiary" onPress={() => openAddNode(contextMenu.nodeId, 'description')}>
<Edit size={15} />添加备注
</Button>
) : null}
</div>
) : null}
<button aria-label={leftOpen ? '收起左侧节点列表' : '展开左侧节点列表'} className="absolute left-1 top-1/2 z-20 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setLeftOpen((value) => !value)}><ChevronRight className={leftOpen ? 'rotate-180' : ''} size={15} /></button></main>
<aside aria-label="拓扑图操作与节点详情" className={`relative shrink-0 transition-[width] ${rightOpen ? 'w-80 border-s border-separator' : 'w-0'}`}> <aside aria-label="拓扑图操作与节点详情" className={`relative shrink-0 transition-[width] ${rightOpen ? 'w-80 border-s border-separator' : 'w-0'}`}>
<div className={`absolute right-0 top-0 z-10 flex w-80 flex-col bg-background ${rightOpen ? 'bottom-0' : 'rounded-bl-xl border-s border-separator shadow-sm'}`}> <div className={`absolute right-0 top-0 z-10 flex w-80 flex-col bg-background ${rightOpen ? 'bottom-0' : 'rounded-bl-xl border-s border-separator shadow-sm'}`}>
<div aria-label="拓扑图操作" role="group" className="grid shrink-0 grid-cols-3 items-center gap-2 border-b border-separator p-3"> <div aria-label="拓扑图操作" role="group" className="grid shrink-0 grid-cols-3 items-center gap-2 border-b border-separator p-3">
...@@ -626,12 +714,37 @@ export default function RegulationGraph() { ...@@ -626,12 +714,37 @@ export default function RegulationGraph() {
<Button className="h-9 min-w-0 gap-1.5 whitespace-nowrap px-2" fullWidth isDisabled={saving || archive !== '当前版本'} size="sm" variant="primary" onPress={save}><Save className="shrink-0" size={15} /><span>保存</span></Button> <Button className="h-9 min-w-0 gap-1.5 whitespace-nowrap px-2" fullWidth isDisabled={saving || archive !== '当前版本'} size="sm" variant="primary" onPress={save}><Save className="shrink-0" size={15} /><span>保存</span></Button>
</div> </div>
<input accept=".xmind,.json" aria-label="导入 XMind 文件" disabled={saving || archive !== '当前版本'} hidden ref={importInputRef} type="file" onChange={importGraph} /> <input accept=".xmind,.json" aria-label="导入 XMind 文件" disabled={saving || archive !== '当前版本'} hidden ref={importInputRef} type="file" onChange={importGraph} />
<div className={`min-h-0 flex-1 flex-col ${rightOpen ? 'flex' : 'hidden'}`}>{selectedNode ? <><div className="flex shrink-0 items-center justify-between border-b border-separator p-4"><div><p className="text-xs text-muted">{nodeTypeLabel(selectedNode.type)}</p><h2 className="mt-1 max-w-56 truncate font-semibold">{selectedNode.title}</h2></div><Button isIconOnly aria-label="清除选择" size="sm" variant="ghost" onPress={() => setSelectedId(null)}>×</Button></div><div className="min-h-0 flex-1 overflow-y-auto p-4"><div className="space-y-4"><TextField fullWidth value={selectedNode.title} onChange={(value) => setNodeField('title', value)}><Label>标题</Label><Input fullWidth variant="secondary" /></TextField><TextField fullWidth value={selectedNode.content || ''} onChange={(value) => setNodeField('content', value)}><Label>备注</Label><TextArea fullWidth rows={5} variant="secondary" /></TextField><div><Label>节点状态</Label><div className="mt-2 flex flex-wrap gap-2"><Chip color={STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode))?.color || 'default'} size="sm" variant="soft"><Chip.Label>{STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode))?.label || nodeTypeLabel(selectedNode.type)}</Chip.Label></Chip></div></div><div><Label>节点操作</Label><div className="mt-2 grid gap-2"><Button variant="secondary" onPress={() => toggleCollapsed(selectedNode.id)}>{collapsedIds.has(selectedNode.id) ? '展开子节点' : '折叠子节点'}</Button><Button variant="tertiary" onPress={() => openAddNode(selectedNode.id, 'group')}><PlusIcon size={16} />添加流程节点</Button>{selectedNode.relatedRegulation || selectedNode.relatedReviseRegulation ? <Button variant="tertiary" onPress={() => history.push(`/internal_regulation/${selectedNode.relatedRegulation || selectedNode.relatedReviseRegulation}`)}><Edit size={16} />查看关联制度</Button> : null}</div></div><Tabs defaultSelectedKey="process" variant="primary"><Tabs.ListContainer className="border-b border-separator"><Tabs.List aria-label="节点信息"><Tabs.Tab id="process">流程<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="regulation">制度<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="history">历史<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer><Tabs.Panel className="pt-4" id="process"><div className="space-y-2 text-sm text-muted">{selectedNode.ancestors.filter((item) => item.type === 'groupNode').map((item) => <div className="flex items-center gap-1" key={item.id}><ChevronRight size={14} />{item.title}</div>)}{!selectedNode.ancestors.some((item) => item.type === 'groupNode') ? '暂无流程路径' : null}</div></Tabs.Panel><Tabs.Panel className="pt-4" id="regulation"><div className="space-y-2 text-sm text-muted">{selectedNode.ancestors.filter((item) => item.type === 'regulationNode').map((item) => <div className="flex items-center gap-1" key={item.id}><ChevronRight size={14} />{item.title}</div>)}{!selectedNode.ancestors.some((item) => item.type === 'regulationNode') ? '暂无制度路径' : null}</div></Tabs.Panel><Tabs.Panel className="pt-4" id="history"><div className="space-y-2 text-sm text-muted">{selectedNode.historyRegulation.map((item) => <button className="block text-left text-accent hover:underline" key={item._id || item.id || item.name} onClick={() => history.push(`/internal_regulation/${item._id || item.name}`)}>{metadataOf(item).regulation_name || item.title}</button>)}{!selectedNode.historyRegulation.length ? '暂无历史版本' : null}</div></Tabs.Panel></Tabs></div></div></> : <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted">点击中间节点查看编辑和流程信息</div>}</div> <div className={`min-h-0 flex-1 flex-col ${rightOpen ? 'flex' : 'hidden'}`}>{selectedNode ? <><div className="flex shrink-0 items-center justify-between border-b border-separator p-4"><div><p className="text-xs text-muted">{nodeTypeLabel(selectedNode.type)}</p><h2 className="mt-1 max-w-56 truncate font-semibold">{selectedNode.title}</h2></div><Button isIconOnly aria-label="清除选择" size="sm" variant="ghost" onPress={clearNodeSelection}>×</Button></div><div className="min-h-0 flex-1 overflow-y-auto p-4"><div className="space-y-4"><TextField fullWidth value={selectedNode.title} onChange={(value) => setNodeField('title', value)}><Label>标题</Label><Input fullWidth variant="secondary" /></TextField><TextField fullWidth value={selectedNode.content || ''} onChange={(value) => setNodeField('content', value)}><Label>备注</Label><TextArea fullWidth rows={5} variant="secondary" /></TextField><div><Label>节点状态</Label><div className="mt-2 flex flex-wrap gap-2">{selectedNode.removed ? <Chip color="danger" size="sm" variant="soft"><Chip.Label>已删除</Chip.Label></Chip> : null}<Chip color={STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode))?.color || 'default'} size="sm" variant="soft"><Chip.Label>{STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode))?.label || nodeTypeLabel(selectedNode.type)}</Chip.Label></Chip></div></div>
<div>
<Label>节点操作</Label>
<div className="mt-2 grid gap-2">
<Button variant="secondary" onPress={() => toggleCollapsed(selectedNode.id)}>{collapsedIds.has(selectedNode.id) ? '展开子节点' : '折叠子节点'}</Button>
<Button isDisabled={archive !== '当前版本' || saving || selectedNode.removed} variant="tertiary" onPress={() => openAddNode(selectedNode.id, 'group')}><PlusIcon size={16} />添加流程节点</Button>
{selectedNode.removed ? (
<Button isDisabled={archive !== '当前版本' || saving} variant="secondary" onPress={restoreSelectedNode}>
<Reload size={16} />{selectedBranchIds.size > 1 ? '恢复节点及子节点' : '恢复节点'}
</Button>
) : (
<>
<Button aria-keyshortcuts="Delete Backspace" isDisabled={archive !== '当前版本' || saving} variant="danger-soft" onPress={deleteSelectedNode}>
<Delete size={16} />删除节点
</Button>
<p className="text-xs text-muted">
{selectedBranchIds.size > 1 ? '删除会同时移除所有子节点,可在“删除节点”列表恢复。' : '选中后可按 Delete(Mac ⌫)删除,可在“删除节点”列表恢复。'}
</p>
</>
)}
{selectedNode.relatedRegulation || selectedNode.relatedReviseRegulation ? (
<Button variant="tertiary" onPress={() => history.push(`/internal_regulation/${selectedNode.relatedRegulation || selectedNode.relatedReviseRegulation}`)}><Edit size={16} />查看关联制度</Button>
) : null}
</div>
</div>
<Tabs defaultSelectedKey="process" variant="primary"><Tabs.ListContainer className="border-b border-separator"><Tabs.List aria-label="节点信息"><Tabs.Tab id="process">流程<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="regulation">制度<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="history">历史<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer><Tabs.Panel className="pt-4" id="process"><div className="space-y-2 text-sm text-muted">{selectedNode.ancestors.filter((item) => item.type === 'groupNode').map((item) => <div className="flex items-center gap-1" key={item.id}><ChevronRight size={14} />{item.title}</div>)}{!selectedNode.ancestors.some((item) => item.type === 'groupNode') ? '暂无流程路径' : null}</div></Tabs.Panel><Tabs.Panel className="pt-4" id="regulation"><div className="space-y-2 text-sm text-muted">{selectedNode.ancestors.filter((item) => item.type === 'regulationNode').map((item) => <div className="flex items-center gap-1" key={item.id}><ChevronRight size={14} />{item.title}</div>)}{!selectedNode.ancestors.some((item) => item.type === 'regulationNode') ? '暂无制度路径' : null}</div></Tabs.Panel><Tabs.Panel className="pt-4" id="history"><div className="space-y-2 text-sm text-muted">{selectedNode.historyRegulation.map((item) => <button className="block text-left text-accent hover:underline" key={item._id || item.id || item.name} onClick={() => history.push(`/internal_regulation/${item._id || item.name}`)}>{metadataOf(item).regulation_name || item.title}</button>)}{!selectedNode.historyRegulation.length ? '暂无历史版本' : null}</div></Tabs.Panel></Tabs></div></div></> : <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted">点击中间节点查看编辑和流程信息</div>}</div>
</div> </div>
</aside> </aside>
<button aria-label={rightOpen ? '收起右侧节点详情' : '展开右侧节点详情'} className="absolute right-1 top-1/2 z-20 grid size-8 translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setRightOpen((value) => !value)}><ChevronRight className={rightOpen ? '' : 'rotate-180'} size={15} /></button> <button aria-label={rightOpen ? '收起右侧节点详情' : '展开右侧节点详情'} className="absolute right-1 top-1/2 z-20 grid size-8 translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setRightOpen((value) => !value)}><ChevronRight className={rightOpen ? '' : 'rotate-180'} size={15} /></button>
</div> </div>
<XinyuanModal isOpen={Boolean(nodeDialog)} onOpenChange={(open) => { if (!open) setNodeDialog(null); }} title={nodeDialog?.mode === 'description' ? '添加节点描述' : '添加流程节点'} size="md" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setNodeDialog(null)}>取消</Button><Button isDisabled={!nodeForm.title.trim() || !nodeDialog?.parentId} variant="primary" onPress={submitNodeDialog}>确认</Button></div>}><div className="space-y-4"><TextField fullWidth isRequired value={nodeForm.title} onChange={(value) => setNodeForm((current) => ({ ...current, title: value }))}><Label>{nodeDialog?.mode === 'description' ? '节点描述' : '节点名称'}</Label><Input fullWidth variant="secondary" /></TextField>{nodeDialog?.mode !== 'description' ? <TextField fullWidth value={nodeForm.content} onChange={(value) => setNodeForm((current) => ({ ...current, content: value }))}><Label>备注</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField> : null}</div></XinyuanModal> <XinyuanModal isOpen={Boolean(nodeDialog)} onOpenChange={(open) => { if (!open) setNodeDialog(null); }} title={nodeDialog?.mode === 'description' ? '添加备注' : '添加流程节点'} size="md" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setNodeDialog(null)}>取消</Button><Button isDisabled={!nodeForm.title.trim() || !nodeDialog?.parentId} variant="primary" onPress={submitNodeDialog}>确认</Button></div>}><div className="space-y-4"><TextField fullWidth isRequired value={nodeForm.title} onChange={(value) => setNodeForm((current) => ({ ...current, title: value }))}><Label>{nodeDialog?.mode === 'description' ? '节点备注' : '节点名称'}</Label><Input fullWidth variant="secondary" /></TextField>{nodeDialog?.mode !== 'description' ? <TextField fullWidth value={nodeForm.content} onChange={(value) => setNodeForm((current) => ({ ...current, content: value }))}><Label>备注</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField> : null}</div></XinyuanModal>
</div> </div>
); );
} }
export function getGraphBranchIds(nodes, nodeId) {
if (nodeId === null || nodeId === undefined) return new Set();
const id = String(nodeId);
if (!nodes.some((node) => String(node.id) === id)) return new Set();
const childrenByParent = new Map();
nodes.forEach((node) => {
const parent = node.parent ?? node.data?.parent;
if (parent === null || parent === undefined) return;
const parentId = String(parent);
const children = childrenByParent.get(parentId) || [];
children.push(String(node.id));
childrenByParent.set(parentId, children);
});
const branchIds = new Set([id]);
const queue = [id];
for (let index = 0; index < queue.length; index += 1) {
(childrenByParent.get(queue[index]) || []).forEach((childId) => {
if (branchIds.has(childId)) return;
branchIds.add(childId);
queue.push(childId);
});
}
return branchIds;
}
export function setGraphBranchRemoved(graph, nodeId, removed) {
const affectedIds = getGraphBranchIds(graph.nodes, nodeId);
if (!affectedIds.size) return graph;
if (!removed) {
// A restored descendant must have a visible parent path, including when
// the user restores it directly from the removed-node list.
const nodesById = new Map(graph.nodes.map((node) => [String(node.id), node]));
let node = nodesById.get(String(nodeId));
while (node) {
const parentId = node.parent ?? node.data?.parent;
if (parentId === null || parentId === undefined || affectedIds.has(String(parentId))) break;
affectedIds.add(String(parentId));
node = nodesById.get(String(parentId));
}
}
return {
...graph,
nodes: graph.nodes.map((node) => affectedIds.has(String(node.id))
? { ...node, removed, data: { ...node.data, removed } }
: node),
};
}
export function isGraphDeleteShortcut(event) {
if (!['Delete', 'Backspace'].includes(event.key) || event.defaultPrevented || event.repeat || event.isComposing
|| event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
return !event.target?.closest?.('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"], [role="spinbutton"], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]');
}
...@@ -40,13 +40,17 @@ ...@@ -40,13 +40,17 @@
- “全部 / 待分类”列表首次点击制度记录时定位第一个关联节点,连续点击同一条记录时按图谱节点顺序循环定位(A → B → C → A);只有一个关联节点时保持定位该节点。记录 `_id/name` 的直接关联节点合并后参与循环,只有没有直接关联时才按 `metadata.origin_regulation` 查找。优先在未移除节点中循环;全部关联节点已移除时才在已移除节点中循环,并自动开启“显示已移除”。点击其他制度、选中画布或“节点 / 删除节点”列表、重新加载或切换拓扑版本后,再点击该制度从第一个关联节点重新开始。循环过程仍只高亮点击的制度记录,自动展开目标祖先、保持缩放并打开详情;快速连续点击时取消尚未执行的旧定位回调,画布最终定位到最新选中的节点。 - “全部 / 待分类”列表首次点击制度记录时定位第一个关联节点,连续点击同一条记录时按图谱节点顺序循环定位(A → B → C → A);只有一个关联节点时保持定位该节点。记录 `_id/name` 的直接关联节点合并后参与循环,只有没有直接关联时才按 `metadata.origin_regulation` 查找。优先在未移除节点中循环;全部关联节点已移除时才在已移除节点中循环,并自动开启“显示已移除”。点击其他制度、选中画布或“节点 / 删除节点”列表、重新加载或切换拓扑版本后,再点击该制度从第一个关联节点重新开始。循环过程仍只高亮点击的制度记录,自动展开目标祖先、保持缩放并打开详情;快速连续点击时取消尚未执行的旧定位回调,画布最终定位到最新选中的节点。
- 当前所查看的拓扑版本没有关联节点时,点击制度记录显示 HeroUI 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。 - 当前所查看的拓扑版本没有关联节点时,点击制度记录显示 HeroUI 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。
- 画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。 - 画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。
- 当前版本选中节点后,可按 `Delete`(兼容 Mac 的 `⌫ / Backspace`)或点击右侧详情“节点操作 → 删除节点”。删除按节点 ID 将该节点及全部下级节点的 `removed`、`data.removed` 设为 `true`,保留节点引用、父子层级及连线;同一制度在其他分支的节点不受影响。删除后清除节点和记录选择、取消旧定位回调与拖入预览、移除被删除的流程筛选路径,并关闭“显示已移除”,让删除分支立即从画布隐藏;节点保留在支持搜索的“删除节点”列表。只有已删除关联的制度重新进入“待分类”,仍有未移除关联节点的制度保持已分类。
- 在“删除节点”列表选择节点后,右侧详情显示“已删除”,提供“恢复节点”或“恢复节点及子节点”。恢复将所选分支及其祖先的 `removed`、`data.removed` 设为 `false`,保留其他分支的删除状态;自动展开目标祖先、保持缩放定位并打开详情。删除和恢复均只修改当前内存图谱,点击“保存”后通过 `POST /record/import` 写入 `internal_regulation_graph.metadata.graph_info`;不删除制度或修订计划 Record,保存失败保留修改。
- 删除快捷键仅对当前页面已选中且未删除的节点生效;输入框、备注、搜索框、可编辑区域、选择器、菜单和弹窗内的 `Delete / Backspace` 保持原有编辑行为,带修饰键、输入法组合中或长按重复事件不触发节点删除。归档版本、加载/导入/保存期间、节点编辑弹窗打开时禁用删除和恢复;已删除节点不重复删除。点击画布空白处或详情“清除选择”会同时清除节点和记录选择,后续删除快捷键不操作之前的节点。React Flow 默认删除快捷键关闭,统一由上述逻辑处理。
- 拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。 - 拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
- 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。 - 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
- 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。 - 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
- “导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开 `.xmind/.json` 文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。 - “导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开 `.xmind/.json` 文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。
- XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按 `parent` 保留层级;非父子连线转为 XMind 关联线。制度主题使用 `《制度名称》`,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;XMind 使用向右的逻辑图布局,不承诺复制画布的手工坐标、缩放或筛选状态。 - XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按 `parent` 保留层级;非父子连线转为 XMind 关联线。制度主题使用 `《制度名称》`,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;XMind 使用向右的逻辑图布局,不承诺复制画布的手工坐标、缩放或筛选状态。
- XMind 文件在浏览器内生成,使用现有 JSZip 打包 `content.json/metadata.json/manifest.json`,下载名称为 `制度拓扑图-版本.xmind`。生成期间显示“正在导出”并禁用重复导出;无节点、重复节点 ID 或循环父子关系时显示明确错误,失败保留当前图谱。CSV 继续按原规则导出制度列表。 - XMind 文件在浏览器内生成,使用现有 JSZip 打包 `content.json/metadata.json/manifest.json`,下载名称为 `制度拓扑图-版本.xmind`。生成期间显示“正在导出”并禁用重复导出;无节点、重复节点 ID 或循环父子关系时显示明确错误,失败保留当前图谱。CSV 继续按原规则导出制度列表。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;“添加流程节点”位于详情的节点操作区,右键也可添加流程节点或描述。 - 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;“添加流程节点”位于详情的节点操作区,右键也可添加流程节点或备注。
- 右键节点时,在鼠标点击位置显示“添加流程节点 / 添加备注”菜单;右键画布空白处只显示“添加流程节点”。菜单坐标以中间画布的实际定位容器为基准,不计入左侧列表宽度,不使用图谱缩放或平移后的节点坐标;菜单渲染后按实际宽高限制在画布内,靠近右侧或底部时自动向内调整,避免覆盖详情或被裁切。菜单项使用 HeroUI Button;点击外部、按 Esc、开始平移/缩放或拖动节点、窗口尺寸变化时关闭菜单,选择菜单项后按原流程打开节点编辑弹窗。
- 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类。 - 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类。
- “全部 / 待分类”的制度记录可拖到画布中任意未移除可见节点的右侧连接区域:区域向右延伸 220 个画布单位,上下各扩展 32 个单位;相邻区域重叠时优先选择离鼠标最近的节点。命中后高亮父节点、显示连接区域与横向连线预览,并提示“松开连接到「节点名称」”;缩放、平移及手动移动节点后仍按当前画布坐标判断。 - “全部 / 待分类”的制度记录可拖到画布中任意未移除可见节点的右侧连接区域:区域向右延伸 220 个画布单位,上下各扩展 32 个单位;相邻区域重叠时优先选择离鼠标最近的节点。命中后高亮父节点、显示连接区域与横向连线预览,并提示“松开连接到「节点名称」”;缩放、平移及手动移动节点后仍按当前画布坐标判断。
- 松手后按落点目标建立父子节点及连线,展开祖先路径和父节点、选中新制度并打开详情;同时清除流程/状态筛选并开启现行显示,让新节点可见。待新增关联修订计划 ID,现行/待修订/待废止制度保留制度 ID,修订计划通过 `origin_regulation` 关联现行制度。 - 松手后按落点目标建立父子节点及连线,展开祖先路径和父节点、选中新制度并打开详情;同时清除流程/状态筛选并开启现行显示,让新节点可见。待新增关联修订计划 ID,现行/待修订/待废止制度保留制度 ID,修订计划通过 `origin_regulation` 关联现行制度。
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment