Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
internal_regulation_manager_new
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
internal_regulation_manager_new
Commits
ba3d23a8
Commit
ba3d23a8
authored
Oct 07, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 支持拓扑节点删除恢复并修正右键菜单定位
parent
e7a7bf9d
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
288 additions
and
13 deletions
+288
-13
graph-removal.test.mjs
scripts/graph-removal.test.mjs
+105
-0
RegulationGraph.jsx
src/pages/internal_regulation_manager/RegulationGraph.jsx
+125
-12
graphRemoval.js
src/pages/internal_regulation_manager/graphRemoval.js
+53
-0
internal-regulation.business-flow.md
...l_regulation_manager/internal-regulation.business-flow.md
+5
-1
No files found.
scripts/graph-removal.test.mjs
0 → 100644
View file @
ba3d23a8
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
);
});
src/pages/internal_regulation_manager/RegulationGraph.jsx
View file @
ba3d23a8
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
,
use
LayoutEffect
,
use
Memo
,
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
>
);
);
}
}
src/pages/internal_regulation_manager/graphRemoval.js
0 → 100644
View file @
ba3d23a8
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"]'
);
}
src/pages/internal_regulation_manager/internal-regulation.business-flow.md
View file @
ba3d23a8
...
@@ -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`
关联现行制度。
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment