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
d0b00c39
Commit
d0b00c39
authored
Sep 30, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 完善制度拓扑交互、保存与 XMind 导出
parent
a6b49d2c
Show whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
902 additions
and
82 deletions
+902
-82
.umirc.ts
.umirc.ts
+2
-2
graph-drop.test.mjs
scripts/graph-drop.test.mjs
+52
-0
graph-save.test.mjs
scripts/graph-save.test.mjs
+112
-0
graph-selection.test.mjs
scripts/graph-selection.test.mjs
+139
-0
graph-xmind.test.mjs
scripts/graph-xmind.test.mjs
+121
-0
GraphNode.jsx
src/pages/internal_regulation_manager/GraphNode.jsx
+2
-2
InternalRegulationManager.jsx
...internal_regulation_manager/InternalRegulationManager.jsx
+0
-8
RegulationGraph.jsx
src/pages/internal_regulation_manager/RegulationGraph.jsx
+175
-35
RevisionList.jsx
src/pages/internal_regulation_manager/RevisionList.jsx
+3
-3
graphDrop.js
src/pages/internal_regulation_manager/graphDrop.js
+39
-0
graphSelection.js
src/pages/internal_regulation_manager/graphSelection.js
+45
-0
internal-regulation.business-flow.md
...l_regulation_manager/internal-regulation.business-flow.md
+18
-5
internalRegulationService.js
src/services/internalRegulationService.js
+4
-27
record-api.md
src/services/record-api.md
+8
-0
regulationGraphData.js
src/services/regulationGraphData.js
+79
-0
regulationGraphXmind.js
src/services/regulationGraphXmind.js
+103
-0
No files found.
.umirc.ts
View file @
d0b00c39
...
@@ -124,8 +124,8 @@ export default defineConfig({
...
@@ -124,8 +124,8 @@ export default defineConfig({
npmClient
:
'npm'
,
npmClient
:
'npm'
,
proxy
:
{
proxy
:
{
'/api'
:
{
'/api'
:
{
//
target: 'https://xy.streams7.com/api',
target
:
'https://xy.streams7.com/api'
,
target
:
'http://127.0.0.1:3000/'
,
//
target: 'http://127.0.0.1:3000/',
changeOrigin
:
true
,
changeOrigin
:
true
,
pathRewrite
:
{
'^/api'
:
''
},
pathRewrite
:
{
'^/api'
:
''
},
secure
:
false
,
secure
:
false
,
...
...
scripts/graph-drop.test.mjs
0 → 100644
View file @
d0b00c39
import
assert
from
'node:assert/strict'
;
import
test
from
'node:test'
;
import
{
findGraphDropTarget
}
from
'../src/pages/internal_regulation_manager/graphDrop.js'
;
const
root
=
{
id
:
'root'
,
type
:
'rootNode'
,
position
:
{
x
:
60
,
y
:
240
},
data
:
{}
};
const
upper
=
{
id
:
'upper'
,
type
:
'groupNode'
,
position
:
{
x
:
500
,
y
:
100
},
data
:
{}
};
const
lower
=
{
id
:
'lower'
,
type
:
'groupNode'
,
position
:
{
x
:
500
,
y
:
178
},
data
:
{}
};
test
(
'the drop position selects a flow node even when a root exists'
,
()
=>
{
const
target
=
findGraphDropTarget
([
root
,
upper
,
lower
],
{
x
:
830
,
y
:
122
});
assert
.
equal
(
target
.
nodeId
,
'upper'
);
assert
.
deepEqual
(
target
.
source
,
{
x
:
740
,
y
:
122
});
assert
.
deepEqual
(
target
.
end
,
{
x
:
830
,
y
:
122
});
});
test
(
'adjacent connection zones select the nearest row'
,
()
=>
{
assert
.
equal
(
findGraphDropTarget
([
upper
,
lower
],
{
x
:
800
,
y
:
152
}).
nodeId
,
'upper'
);
assert
.
equal
(
findGraphDropTarget
([
upper
,
lower
],
{
x
:
800
,
y
:
169
}).
nodeId
,
'lower'
);
});
test
(
'blank space and the left of a node never fall back to the root'
,
()
=>
{
assert
.
equal
(
findGraphDropTarget
([
root
,
upper
],
{
x
:
1100
,
y
:
122
}),
null
);
assert
.
equal
(
findGraphDropTarget
([
root
,
upper
],
{
x
:
470
,
y
:
122
}),
null
);
assert
.
equal
(
findGraphDropTarget
([
root
,
upper
],
{
x
:
800
,
y
:
20
}),
null
);
});
test
(
'a node body blocks a connection to a node behind it'
,
()
=>
{
const
child
=
{
id
:
'child'
,
type
:
'regulationNode'
,
position
:
{
x
:
450
,
y
:
240
},
data
:
{}
};
assert
.
equal
(
findGraphDropTarget
([
root
,
child
],
{
x
:
470
,
y
:
262
}),
null
);
assert
.
equal
(
findGraphDropTarget
([
root
,
child
],
{
x
:
700
,
y
:
262
}).
nodeId
,
'child'
);
});
test
(
'removed and hidden nodes cannot receive a record'
,
()
=>
{
assert
.
equal
(
findGraphDropTarget
([{
...
upper
,
data
:
{
removed
:
true
}
}],
{
x
:
800
,
y
:
122
}),
null
);
assert
.
equal
(
findGraphDropTarget
([{
...
upper
,
removed
:
true
}],
{
x
:
800
,
y
:
122
}),
null
);
assert
.
equal
(
findGraphDropTarget
([{
...
upper
,
hidden
:
true
}],
{
x
:
800
,
y
:
122
}),
null
);
});
test
(
'measured bounds and current absolute positions determine the preview'
,
()
=>
{
const
moved
=
{
...
upper
,
positionAbsolute
:
{
x
:
1000
,
y
:
500
},
width
:
300
,
height
:
60
};
const
target
=
findGraphDropTarget
([
moved
],
{
x
:
1360
,
y
:
530
});
assert
.
equal
(
target
.
nodeId
,
'upper'
);
assert
.
deepEqual
(
target
.
source
,
{
x
:
1300
,
y
:
530
});
assert
.
equal
(
findGraphDropTarget
([
moved
],
{
x
:
800
,
y
:
122
}),
null
);
});
test
(
'the right handle has a usable connection preview with a minimum line length'
,
()
=>
{
const
target
=
findGraphDropTarget
([
upper
],
{
x
:
735
,
y
:
122
});
assert
.
equal
(
target
.
nodeId
,
'upper'
);
assert
.
equal
(
target
.
end
.
x
,
812
);
assert
.
equal
(
target
.
source
.
y
,
target
.
end
.
y
);
});
scripts/graph-save.test.mjs
0 → 100644
View file @
d0b00c39
import
assert
from
'node:assert/strict'
;
import
test
from
'node:test'
;
import
{
attachGraphAncestors
,
serializeGraph
}
from
'../src/services/regulationGraphData.js'
;
function
chain
(
length
)
{
const
nodes
=
Array
.
from
({
length
},
(
_
,
index
)
=>
({
id
:
`node-
${
index
}
`
,
type
:
index
?
'groupNode'
:
'rootNode'
,
title
:
`流程
${
index
}
`
,
parent
:
index
?
`node-
${
index
-
1
}
`
:
null
,
position
:
{
x
:
index
*
390
,
y
:
80
},
data
:
{},
}));
const
edges
=
nodes
.
slice
(
1
).
map
((
node
)
=>
({
source
:
node
.
parent
,
target
:
node
.
id
}));
return
{
nodes
,
edges
};
}
function
legacyGraph
(
length
)
{
const
graph
=
chain
(
length
);
graph
.
nodes
.
forEach
((
node
,
index
)
=>
{
node
.
ancestors
=
graph
.
nodes
.
slice
(
0
,
index
);
node
.
data
.
ancestors
=
node
.
ancestors
;
});
return
graph
;
}
test
(
'ancestor paths contain summaries rather than recursively nested nodes'
,
()
=>
{
const
graph
=
chain
(
40
);
const
enriched
=
attachGraphAncestors
(
graph
.
nodes
);
assert
.
equal
(
enriched
.
at
(
-
1
).
ancestors
.
length
,
39
);
assert
.
deepEqual
(
enriched
.
at
(
-
1
).
ancestors
[
0
],
{
id
:
'node-0'
,
title
:
'流程 0'
,
type
:
'rootNode'
});
enriched
.
forEach
((
node
)
=>
node
.
ancestors
.
forEach
((
ancestor
)
=>
{
assert
.
equal
(
'data'
in
ancestor
,
false
);
assert
.
equal
(
'ancestors'
in
ancestor
,
false
);
assert
.
notEqual
(
ancestor
,
graph
.
nodes
.
find
((
original
)
=>
original
.
id
===
ancestor
.
id
));
}));
assert
.
equal
(
JSON
.
stringify
(
enriched
).
length
<
200000
,
true
);
assert
.
deepEqual
(
attachGraphAncestors
(
enriched
),
enriched
);
});
test
(
'legacy full-node ancestors at deep levels can be saved with bounded JSON'
,
()
=>
{
const
original
=
legacyGraph
(
40
);
const
saved
=
serializeGraph
(
original
);
const
text
=
JSON
.
stringify
(
saved
);
assert
.
equal
(
saved
.
nodes
.
length
,
40
);
assert
.
equal
(
saved
.
edges
.
length
,
39
);
assert
.
equal
(
text
.
length
<
40000
,
true
);
assert
.
equal
(
text
.
includes
(
'ancestors'
),
false
);
assert
.
equal
(
original
.
nodes
.
at
(
-
1
).
ancestors
[
0
],
original
.
nodes
[
0
]);
});
test
(
'saved JSON grows with node count rather than ancestor depth'
,
()
=>
{
const
first
=
JSON
.
stringify
(
serializeGraph
(
legacyGraph
(
40
))).
length
;
const
doubled
=
JSON
.
stringify
(
serializeGraph
(
legacyGraph
(
80
))).
length
;
assert
.
equal
(
doubled
<
first
*
2.1
,
true
);
});
test
(
'save preserves edited graph fields and stores only reference IDs'
,
()
=>
{
const
graph
=
{
nodes
:
[{
id
:
'edited'
,
type
:
'regulationNode'
,
title
:
'修订后的标题'
,
parent
:
'group'
,
content
:
'节点备注'
,
relatedRegulation
:
{
_id
:
'current-record'
,
metadata
:
{
content
:
'exclude'
}
},
relatedReviseRegulation
:
{
_id
:
'revision-record'
},
relatedRegulationType
:
'revise'
,
position
:
{
x
:
420
,
y
:
130
},
expanded
:
true
,
removed
:
true
,
data
:
{
selected
:
true
,
childCount
:
2
,
onToggle
()
{},
title
:
'旧标题'
},
}],
edges
:
[{
source
:
'group'
,
target
:
'edited'
,
data
:
{
callback
()
{}
}
}],
};
graph
.
nodes
[
0
].
data
.
owner
=
graph
.
nodes
[
0
];
const
saved
=
serializeGraph
(
graph
);
const
node
=
saved
.
nodes
[
0
];
assert
.
equal
(
node
.
title
,
'修订后的标题'
);
assert
.
equal
(
node
.
data
.
title
,
node
.
title
);
assert
.
equal
(
node
.
parent
,
'group'
);
assert
.
equal
(
node
.
content
,
'节点备注'
);
assert
.
deepEqual
(
node
.
position
,
{
x
:
420
,
y
:
130
});
assert
.
equal
(
node
.
relatedRegulation
,
'current-record'
);
assert
.
equal
(
node
.
data
.
related_revise_regulation
,
'revision-record'
);
assert
.
equal
(
node
.
relatedRegulationType
,
'revise'
);
assert
.
equal
(
node
.
expanded
,
true
);
assert
.
equal
(
node
.
removed
,
true
);
assert
.
deepEqual
(
saved
.
edges
[
0
],
{
id
:
'group->edited'
,
source
:
'group'
,
target
:
'edited'
,
type
:
'smoothstep'
});
assert
.
doesNotThrow
(()
=>
JSON
.
stringify
(
saved
));
assert
.
equal
(
'owner'
in
node
.
data
,
false
);
assert
.
equal
(
'onToggle'
in
node
.
data
,
false
);
});
test
(
'legacy data fields and historical record labels survive without full records'
,
()
=>
{
const
saved
=
serializeGraph
({
nodes
:
[{
id
:
'legacy'
,
data
:
{
title
:
'制度节点'
,
parent
:
'flow'
,
related_regulation
:
{
_id
:
'regulation'
},
related_revise_regulation
:
'revision'
,
related_regulation_type
:
'addition'
,
history_regulation
:
[{
_id
:
'history-record'
,
name
:
'history-name'
,
title
:
'历史标题'
,
metadata
:
{
regulation_name
:
'历史制度名称'
,
document
:
{
body
:
'x'
.
repeat
(
1000000
)
}
},
content
:
'x'
.
repeat
(
1000000
),
attachments
:
[{
data
:
'exclude'
}],
}],
}
}]
});
const
node
=
saved
.
nodes
[
0
];
assert
.
equal
(
node
.
title
,
'制度节点'
);
assert
.
equal
(
node
.
parent
,
'flow'
);
assert
.
equal
(
node
.
relatedRegulation
,
'regulation'
);
assert
.
equal
(
node
.
relatedReviseRegulation
,
'revision'
);
assert
.
deepEqual
(
node
.
data
.
history_regulation
,
[{
_id
:
'history-record'
,
name
:
'history-name'
,
title
:
'历史标题'
,
metadata
:
{
regulation_name
:
'历史制度名称'
}
}]);
assert
.
equal
(
JSON
.
stringify
(
saved
).
length
<
2000
,
true
);
});
test
(
'parent cycles do not create self references in ancestor summaries'
,
()
=>
{
const
nodes
=
[{
id
:
'a'
,
title
:
'A'
,
type
:
'groupNode'
,
parent
:
'b'
},
{
id
:
'b'
,
title
:
'B'
,
type
:
'groupNode'
,
parent
:
'a'
}];
const
enriched
=
attachGraphAncestors
(
nodes
);
assert
.
deepEqual
(
enriched
[
0
].
ancestors
.
map
((
ancestor
)
=>
ancestor
.
id
),
[
'b'
]);
assert
.
deepEqual
(
enriched
[
1
].
ancestors
.
map
((
ancestor
)
=>
ancestor
.
id
),
[
'a'
]);
assert
.
doesNotThrow
(()
=>
JSON
.
stringify
(
enriched
));
});
scripts/graph-selection.test.mjs
0 → 100644
View file @
d0b00c39
import
assert
from
'node:assert/strict'
;
import
test
from
'node:test'
;
import
{
getGraphRecordId
,
getNextGraphRecordNode
,
getSelectedGraphRecordId
}
from
'../src/pages/internal_regulation_manager/graphSelection.js'
;
const
current
=
{
_id
:
'current'
,
name
:
'cash-management'
,
metadata
:
{
regulation_name
:
'现金头寸管理办法(2026)'
}
};
const
history
=
[
2024
,
2022
,
2020
].
map
((
year
)
=>
({
_id
:
`history-
${
year
}
`
,
metadata
:
{
regulation_name
:
`现金头寸管理办法(
${
year
}
)`
,
origin_regulation
:
current
.
_id
},
}));
const
node
=
{
id
:
'cash-node'
,
relatedRegulation
:
current
.
_id
};
const
records
=
[...
history
,
current
];
function
highlightedIds
(
selectedNode
,
selectedRecordId
=
null
)
{
const
id
=
getSelectedGraphRecordId
(
selectedNode
,
records
,
selectedRecordId
);
return
records
.
filter
((
record
)
=>
id
!==
null
&&
getGraphRecordId
(
record
)
===
id
).
map
(
getGraphRecordId
);
}
test
(
'clicking the current record highlights one row despite several history versions sharing its node'
,
()
=>
{
assert
.
deepEqual
(
highlightedIds
(
node
,
current
.
_id
),
[
'current'
]);
});
test
(
'clicking different history versions of the same node moves the highlight to just the clicked record'
,
()
=>
{
history
.
forEach
((
record
)
=>
assert
.
deepEqual
(
highlightedIds
(
node
,
record
.
_id
),
[
record
.
_id
]));
});
test
(
'selecting a canvas node highlights its directly referenced record regardless of list ordering'
,
()
=>
{
assert
.
deepEqual
(
highlightedIds
(
node
),
[
'current'
]);
assert
.
equal
(
getSelectedGraphRecordId
(
node
,
history
),
null
);
});
test
(
'references saved as a record name resolve to the record ID used by the list'
,
()
=>
{
assert
.
equal
(
getSelectedGraphRecordId
({
...
node
,
relatedRegulation
:
current
.
name
},
records
),
current
.
_id
);
});
test
(
'a pending addition selects its directly referenced revision plan'
,
()
=>
{
const
revision
=
{
_id
:
'addition'
,
name
:
'addition-plan'
,
metadata
:
{
type
:
'新增'
}
};
const
additionNode
=
{
id
:
'addition-node'
,
relatedReviseRegulation
:
revision
.
name
};
assert
.
equal
(
getSelectedGraphRecordId
(
additionNode
,
[...
records
,
revision
]),
revision
.
_id
);
});
test
(
'the current regulation takes precedence when a node also references a revision plan'
,
()
=>
{
const
revision
=
{
_id
:
'revision'
,
metadata
:
{
origin_regulation
:
current
.
_id
,
type
:
'修订'
}
};
assert
.
equal
(
getSelectedGraphRecordId
({
...
node
,
relatedReviseRegulation
:
revision
.
_id
},
[
revision
,
...
records
]),
current
.
_id
);
});
test
(
'clearing selection or choosing a flow node leaves every record unhighlighted'
,
()
=>
{
assert
.
deepEqual
(
highlightedIds
(
null
,
current
.
_id
),
[]);
assert
.
deepEqual
(
highlightedIds
({
id
:
'flow-node'
,
type
:
'groupNode'
}),
[]);
assert
.
deepEqual
(
highlightedIds
({
id
:
'missing-node'
,
relatedRegulation
:
'missing-record'
}),
[]);
});
test
(
'records without an ID use their name and missing identities remain unselected'
,
()
=>
{
const
named
=
{
name
:
'named-record'
};
assert
.
equal
(
getGraphRecordId
(
named
),
named
.
name
);
assert
.
equal
(
getSelectedGraphRecordId
({
relatedRegulation
:
named
.
name
},
[
named
]),
named
.
name
);
assert
.
equal
(
getGraphRecordId
({}),
null
);
});
const
occurrences
=
[
'a'
,
'b'
,
'c'
].
map
((
id
)
=>
({
...
node
,
id
}));
test
(
'repeated clicks cycle through every occurrence and wrap while keeping only the clicked record highlighted'
,
()
=>
{
let
selectedNodeId
=
null
;
let
selectedRecordId
=
null
;
const
visited
=
Array
.
from
({
length
:
7
},
()
=>
{
const
next
=
getNextGraphRecordNode
(
current
,
occurrences
,
selectedNodeId
,
selectedRecordId
);
selectedNodeId
=
next
.
id
;
selectedRecordId
=
current
.
_id
;
assert
.
deepEqual
(
highlightedIds
(
next
,
selectedRecordId
),
[
current
.
_id
]);
return
next
.
id
;
});
assert
.
deepEqual
(
visited
,
[
'a'
,
'b'
,
'c'
,
'a'
,
'b'
,
'c'
,
'a'
]);
});
test
(
'one occurrence stays selected on repeated clicks and missing associations return no target'
,
()
=>
{
assert
.
equal
(
getNextGraphRecordNode
(
current
,
[
node
],
node
.
id
,
current
.
_id
),
node
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
[]),
null
);
assert
.
equal
(
getNextGraphRecordNode
({
_id
:
'other'
},
occurrences
,
'a'
,
current
.
_id
),
null
);
});
test
(
'switching records or selecting a canvas node restarts from the first occurrence'
,
()
=>
{
assert
.
equal
(
getNextGraphRecordNode
(
current
,
occurrences
,
'b'
,
history
[
0
].
_id
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
occurrences
,
'b'
,
null
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
history
[
0
],
occurrences
,
'b'
,
current
.
_id
).
id
,
'a'
);
});
test
(
'removing the previously selected occurrence restarts the cycle using the current graph'
,
()
=>
{
assert
.
equal
(
getNextGraphRecordNode
(
current
,
occurrences
.
slice
(
1
),
'a'
,
current
.
_id
).
id
,
'b'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
[{
...
occurrences
[
0
],
removed
:
true
},
...
occurrences
.
slice
(
1
)],
'a'
,
current
.
_id
).
id
,
'b'
);
});
test
(
'cycles prefer unremoved occurrences even when removed nodes appear first'
,
()
=>
{
const
nodes
=
[{
...
occurrences
[
2
],
removed
:
true
},
...
occurrences
.
slice
(
0
,
2
)];
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'a'
,
current
.
_id
).
id
,
'b'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'b'
,
current
.
_id
).
id
,
'a'
);
});
test
(
'all removed occurrences remain locatable and cycle in graph order'
,
()
=>
{
const
nodes
=
occurrences
.
map
((
item
)
=>
({
...
item
,
removed
:
true
}));
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'a'
,
current
.
_id
).
id
,
'b'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'c'
,
current
.
_id
).
id
,
'a'
);
});
test
(
'ID and name references are combined in graph order without duplicating an occurrence'
,
()
=>
{
const
nodes
=
[
{
...
occurrences
[
0
],
relatedRegulation
:
current
.
name
},
{
...
occurrences
[
1
],
relatedReviseRegulation
:
current
.
name
},
occurrences
[
2
],
];
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'a'
,
current
.
_id
).
id
,
'b'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'b'
,
current
.
_id
).
id
,
'c'
);
assert
.
equal
(
getNextGraphRecordNode
(
current
,
nodes
,
'c'
,
current
.
_id
).
id
,
'a'
);
});
test
(
'history and revision records cycle through their origin when they have no direct association'
,
()
=>
{
const
historical
=
{
...
history
[
0
],
metadata
:
{
...
history
[
0
].
metadata
,
origin_regulation
:
{
_id
:
current
.
_id
}
}
};
assert
.
equal
(
getNextGraphRecordNode
(
historical
,
occurrences
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
historical
,
occurrences
,
'a'
,
historical
.
_id
).
id
,
'b'
);
const
revision
=
{
_id
:
'revision'
,
metadata
:
{
type
:
'修订'
,
origin_regulation
:
current
.
_id
}
};
assert
.
equal
(
getNextGraphRecordNode
(
revision
,
occurrences
,
'b'
,
revision
.
_id
).
id
,
'c'
);
});
test
(
'direct history associations take precedence over fallback origin nodes'
,
()
=>
{
const
direct
=
{
...
node
,
id
:
'direct-history'
,
relatedRegulation
:
history
[
0
].
_id
,
removed
:
true
};
const
nodes
=
[...
occurrences
,
direct
];
assert
.
equal
(
getNextGraphRecordNode
(
history
[
0
],
nodes
),
direct
);
assert
.
equal
(
getNextGraphRecordNode
(
history
[
0
],
nodes
,
direct
.
id
,
history
[
0
].
_id
),
direct
);
});
test
(
'pending additions and name-only records also support repeated occurrence navigation'
,
()
=>
{
const
addition
=
{
_id
:
'addition'
,
name
:
'addition-plan'
,
metadata
:
{
type
:
'新增'
}
};
const
nodes
=
occurrences
.
map
((
item
)
=>
({
id
:
item
.
id
,
relatedReviseRegulation
:
addition
.
name
}));
assert
.
equal
(
getNextGraphRecordNode
(
addition
,
nodes
).
id
,
'a'
);
assert
.
equal
(
getNextGraphRecordNode
(
addition
,
nodes
,
'a'
,
addition
.
_id
).
id
,
'b'
);
const
named
=
{
name
:
addition
.
name
};
assert
.
equal
(
getNextGraphRecordNode
(
named
,
nodes
,
'c'
,
named
.
name
).
id
,
'a'
);
});
scripts/graph-xmind.test.mjs
0 → 100644
View file @
d0b00c39
import
assert
from
'node:assert/strict'
;
import
{
writeFile
}
from
'node:fs/promises'
;
import
test
from
'node:test'
;
import
JSZip
from
'jszip'
;
import
{
buildGraphXmindSheets
,
createGraphXmindArchive
}
from
'../src/services/regulationGraphXmind.js'
;
function
fixture
()
{
return
{
nodes
:
[
{
id
:
'root'
,
type
:
'rootNode'
,
title
:
'制度体系'
,
parent
:
null
},
{
id
:
'group'
,
type
:
'groupNode'
,
title
:
'公司治理'
,
parent
:
'root'
,
content
:
'流程备注'
},
{
id
:
'regulation'
,
type
:
'regulationNode'
,
title
:
'合同管理办法'
,
parent
:
'group'
,
content
:
'第一行
\
n第二行'
,
relatedRegulationType
:
'revise'
},
{
id
:
'addition'
,
type
:
'regulationNode'
,
title
:
'待新增制度'
,
parent
:
'root'
,
relatedRegulationType
:
'addition'
},
{
id
:
'removed'
,
type
:
'groupNode'
,
title
:
'已移除流程'
,
parent
:
'root'
,
removed
:
true
},
],
edges
:
[
{
source
:
'root'
,
target
:
'group'
},
{
source
:
'group'
,
target
:
'regulation'
},
{
source
:
'root'
,
target
:
'addition'
},
{
source
:
'root'
,
target
:
'removed'
},
{
source
:
'regulation'
,
target
:
'addition'
},
],
};
}
function
topics
(
root
)
{
const
result
=
[];
const
queue
=
[
root
];
while
(
queue
.
length
)
{
const
topic
=
queue
.
shift
();
result
.
push
(
topic
);
queue
.
push
(...(
topic
.
children
?.
attached
||
[]));
}
return
result
;
}
test
(
'single-root exports retain the entire hierarchy, notes and status labels'
,
()
=>
{
const
graph
=
fixture
();
const
before
=
JSON
.
stringify
(
graph
);
const
[
sheet
]
=
buildGraphXmindSheets
(
graph
);
assert
.
equal
(
sheet
.
rootTopic
.
id
,
'root'
);
assert
.
equal
(
sheet
.
rootTopic
.
structureClass
,
'org.xmind.ui.logic.right'
);
assert
.
equal
(
sheet
.
title
,
'制度拓扑图(当前版本)'
);
const
all
=
topics
(
sheet
.
rootTopic
);
assert
.
equal
(
all
.
length
,
graph
.
nodes
.
length
);
const
group
=
all
.
find
((
topic
)
=>
topic
.
id
===
'group'
);
assert
.
equal
(
group
.
children
.
attached
[
0
].
title
,
'《合同管理办法》'
);
assert
.
equal
(
group
.
notes
.
plain
.
content
,
'流程备注'
);
assert
.
equal
(
group
.
children
.
attached
[
0
].
notes
.
plain
.
content
,
'第一行
\
n第二行'
);
assert
.
deepEqual
(
group
.
children
.
attached
[
0
].
labels
,
[
'待修订'
]);
assert
.
deepEqual
(
all
.
find
((
topic
)
=>
topic
.
id
===
'addition'
).
labels
,
[
'待新增'
]);
assert
.
deepEqual
(
all
.
find
((
topic
)
=>
topic
.
id
===
'removed'
).
labels
,
[
'已移除'
]);
assert
.
equal
(
JSON
.
stringify
(
graph
),
before
,
'export must not mutate the canvas'
);
});
test
(
'cross-branch edges become relationships instead of duplicating topics'
,
()
=>
{
const
[
sheet
]
=
buildGraphXmindSheets
(
fixture
());
assert
.
equal
(
sheet
.
relationships
.
length
,
1
);
assert
.
equal
(
sheet
.
relationships
[
0
].
end1Id
,
'regulation'
);
assert
.
equal
(
sheet
.
relationships
[
0
].
end2Id
,
'addition'
);
assert
.
equal
(
sheet
.
relationships
[
0
].
class
,
'relationship'
);
});
test
(
'multiple roots and missing parents remain visible under a synthetic root'
,
()
=>
{
const
graph
=
fixture
();
graph
.
nodes
.
push
({
id
:
'orphan'
,
title
:
'孤立流程'
,
type
:
'groupNode'
,
parent
:
'missing'
});
graph
.
nodes
.
push
({
id
:
'internal-regulation-xmind-root'
,
title
:
'另一个根节点'
,
type
:
'rootNode'
});
const
[
sheet
]
=
buildGraphXmindSheets
(
graph
,
'2026-09 归档'
);
assert
.
equal
(
sheet
.
rootTopic
.
title
,
'制度拓扑图'
);
assert
.
notEqual
(
sheet
.
rootTopic
.
id
,
'internal-regulation-xmind-root'
);
assert
.
equal
(
sheet
.
rootTopic
.
children
.
attached
.
length
,
3
);
assert
.
equal
(
topics
(
sheet
.
rootTopic
).
length
,
graph
.
nodes
.
length
+
1
);
assert
.
equal
(
sheet
.
title
,
'制度拓扑图(2026-09 归档)'
);
});
test
(
'removed, hidden and pending records remain in the complete export'
,
()
=>
{
const
graph
=
fixture
();
graph
.
nodes
[
2
].
hidden
=
true
;
graph
.
nodes
[
2
].
removed
=
true
;
const
[
sheet
]
=
buildGraphXmindSheets
(
graph
);
const
node
=
topics
(
sheet
.
rootTopic
).
find
((
topic
)
=>
topic
.
id
===
'regulation'
);
assert
.
deepEqual
(
node
.
labels
,
[
'待修订'
,
'已移除'
]);
assert
.
equal
(
node
.
title
,
'《合同管理办法》'
);
});
test
(
'legacy data and bracketed titles export without double wrapping'
,
()
=>
{
const
[
sheet
]
=
buildGraphXmindSheets
({
nodes
:
[{
id
:
'legacy'
,
data
:
{
type
:
'regulationNode'
,
title
:
'《原制度》'
,
content
:
'旧备注'
,
related_regulation_type
:
'forbidden'
,
}
}]
});
assert
.
equal
(
sheet
.
rootTopic
.
title
,
'《原制度》'
);
assert
.
equal
(
sheet
.
rootTopic
.
notes
.
plain
.
content
,
'旧备注'
);
assert
.
deepEqual
(
sheet
.
rootTopic
.
labels
,
[
'待废止'
]);
});
test
(
'empty graphs, duplicate IDs and parent cycles fail with usable messages'
,
()
=>
{
assert
.
throws
(()
=>
buildGraphXmindSheets
({
nodes
:
[]
}),
/没有节点/
);
assert
.
throws
(()
=>
buildGraphXmindSheets
({
nodes
:
[{
id
:
'a'
},
{
id
:
'a'
}]
}),
/重复节点 ID/
);
assert
.
throws
(()
=>
buildGraphXmindSheets
({
nodes
:
[{
id
:
'a'
,
parent
:
'b'
},
{
id
:
'b'
,
parent
:
'a'
}]
}),
/循环父子关系/
);
assert
.
throws
(()
=>
buildGraphXmindSheets
({
nodes
:
[{
id
:
'self'
,
parent
:
'self'
}]
}),
/循环父子关系/
);
});
test
(
'deep graphs never serialize nested runtime ancestor objects'
,
()
=>
{
const
nodes
=
Array
.
from
({
length
:
40
},
(
_
,
index
)
=>
({
id
:
`node-
${
index
}
`
,
title
:
`流程
${
index
}
`
,
type
:
'groupNode'
,
parent
:
index
?
`node-
${
index
-
1
}
`
:
null
}));
nodes
.
forEach
((
node
,
index
)
=>
{
node
.
ancestors
=
nodes
.
slice
(
0
,
index
);
node
.
data
=
{
ancestors
:
node
.
ancestors
};
});
const
sheets
=
buildGraphXmindSheets
({
nodes
});
assert
.
equal
(
topics
(
sheets
[
0
].
rootTopic
).
length
,
40
);
assert
.
equal
(
JSON
.
stringify
(
sheets
).
length
<
10000
,
true
);
});
test
(
'downloads contain the official XMind package entries at the archive root'
,
async
()
=>
{
const
bytes
=
await
createGraphXmindArchive
(
fixture
());
assert
.
deepEqual
([...
bytes
.
slice
(
0
,
2
)],
[
0x50
,
0x4b
]);
const
zip
=
await
JSZip
.
loadAsync
(
bytes
);
assert
.
deepEqual
(
Object
.
keys
(
zip
.
files
).
sort
(),
[
'content.json'
,
'manifest.json'
,
'metadata.json'
]);
const
content
=
JSON
.
parse
(
await
zip
.
file
(
'content.json'
).
async
(
'text'
));
const
metadata
=
JSON
.
parse
(
await
zip
.
file
(
'metadata.json'
).
async
(
'text'
));
const
manifest
=
JSON
.
parse
(
await
zip
.
file
(
'manifest.json'
).
async
(
'text'
));
assert
.
equal
(
metadata
.
dataStructureVersion
,
'2'
);
assert
.
deepEqual
(
Object
.
keys
(
manifest
[
'file-entries'
]).
sort
(),
[
'content.json'
,
'metadata.json'
]);
assert
.
equal
(
content
[
0
].
class
,
'sheet'
);
assert
.
equal
(
topics
(
content
[
0
].
rootTopic
).
length
,
5
);
await
writeFile
(
'/tmp/codex-regulation-topology-export.xmind'
,
bytes
);
});
src/pages/internal_regulation_manager/GraphNode.jsx
View file @
d0b00c39
...
@@ -4,9 +4,9 @@ import { ChevronRight, Close, Info } from '@/components/AppIcons';
...
@@ -4,9 +4,9 @@ import { ChevronRight, Close, Info } from '@/components/AppIcons';
const
TYPE_LABELS
=
{
rootNode
:
'根节点'
,
groupNode
:
'组节点'
,
regulationNode
:
'制度节点'
};
const
TYPE_LABELS
=
{
rootNode
:
'根节点'
,
groupNode
:
'组节点'
,
regulationNode
:
'制度节点'
};
export
default
function
GraphNode
({
data
})
{
export
default
function
GraphNode
({
data
})
{
const
{
title
,
type
=
'regulationNode'
,
childCount
=
0
,
expanded
,
removed
,
selected
,
relatedRegulationType
=
'active'
,
content
}
=
data
;
const
{
title
,
type
=
'regulationNode'
,
childCount
=
0
,
expanded
,
removed
,
selected
,
dropTarget
,
relatedRegulationType
=
'active'
,
content
}
=
data
;
const
statusClass
=
relatedRegulationType
===
'addition'
?
'border-blue-300 bg-blue-50'
:
relatedRegulationType
===
'revise'
?
'border-amber-300 bg-amber-50'
:
relatedRegulationType
===
'forbidden'
?
'border-red-300 bg-red-50'
:
'border-slate-200 bg-white'
;
const
statusClass
=
relatedRegulationType
===
'addition'
?
'border-blue-300 bg-blue-50'
:
relatedRegulationType
===
'revise'
?
'border-amber-300 bg-amber-50'
:
relatedRegulationType
===
'forbidden'
?
'border-red-300 bg-red-50'
:
'border-slate-200 bg-white'
;
return
<
div
className=
{
`relative flex h-11 w-[240px] items-center rounded-lg border px-3 shadow-sm transition ${statusClass} ${type === 'rootNode' ? 'w-[280px] border-teal-300 bg-teal-50' : ''} ${selected ? 'ring-2 ring-accent/40' : ''} ${removed ? 'opacity-45' : ''}`
}
title=
{
content
||
TYPE_LABELS
[
type
]
||
'制度节点'
}
>
return
<
div
className=
{
`relative flex h-11 w-[240px] items-center rounded-lg border px-3 shadow-sm transition ${statusClass} ${type === 'rootNode' ? 'w-[280px] border-teal-300 bg-teal-50' : ''} ${
dropTarget ? 'ring-2 ring-accent !border-accent' :
selected ? 'ring-2 ring-accent/40' : ''} ${removed ? 'opacity-45' : ''}`
}
title=
{
content
||
TYPE_LABELS
[
type
]
||
'制度节点'
}
>
<
Handle
type=
"target"
position=
{
Position
.
Left
}
className=
"!border-0 !bg-transparent"
/>
<
Handle
type=
"target"
position=
{
Position
.
Left
}
className=
"!border-0 !bg-transparent"
/>
{
removed
?
<
Close
className=
"mr-1 shrink-0 text-danger"
size=
{
14
}
/>
:
null
}
{
removed
?
<
Close
className=
"mr-1 shrink-0 text-danger"
size=
{
14
}
/>
:
null
}
<
span
className=
{
`min-w-0 flex-1 truncate ${type === 'rootNode' ? 'font-semibold' : 'font-medium'}`
}
>
{
title
}
</
span
>
<
span
className=
{
`min-w-0 flex-1 truncate ${type === 'rootNode' ? 'font-semibold' : 'font-medium'}`
}
>
{
title
}
</
span
>
...
...
src/pages/internal_regulation_manager/InternalRegulationManager.jsx
View file @
d0b00c39
...
@@ -59,13 +59,6 @@ export default function InternalRegulationManager() {
...
@@ -59,13 +59,6 @@ export default function InternalRegulationManager() {
},
[
activeTab
,
error
,
loading
,
options
,
subject
]);
},
[
activeTab
,
error
,
loading
,
options
,
subject
]);
return
(
return
(
<
div
className=
"space-y-5"
>
<
div
className=
"flex flex-wrap items-end justify-between gap-4"
>
<
div
>
<
p
className=
"text-sm text-muted"
>
内部制度管理
</
p
>
<
h1
className=
"mt-1 text-2xl font-semibold tracking-tight"
>
内部制度
</
h1
>
</
div
>
</
div
>
<
Card
className=
"rc-card-surface"
>
<
Card
className=
"rc-card-surface"
>
<
Card
.
Content
className=
"p-0"
>
<
Card
.
Content
className=
"p-0"
>
<
Tabs
className=
"w-fit max-w-full"
selectedKey=
{
activeTab
}
variant=
"primary"
onSelectionChange=
{
changeTab
}
>
<
Tabs
className=
"w-fit max-w-full"
selectedKey=
{
activeTab
}
variant=
"primary"
onSelectionChange=
{
changeTab
}
>
...
@@ -78,6 +71,5 @@ export default function InternalRegulationManager() {
...
@@ -78,6 +71,5 @@ export default function InternalRegulationManager() {
<
div
className=
"p-4 sm:p-6"
>
{
content
}
</
div
>
<
div
className=
"p-4 sm:p-6"
>
{
content
}
</
div
>
</
Card
.
Content
>
</
Card
.
Content
>
</
Card
>
</
Card
>
</
div
>
);
);
}
}
src/pages/internal_regulation_manager/RegulationGraph.jsx
View file @
d0b00c39
import
{
Button
,
Checkbox
,
Chip
,
Input
,
Label
,
Spinner
,
Tabs
,
TextArea
,
TextField
,
Tooltip
}
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
,
useMemo
,
useRef
,
useState
}
from
'react'
;
...
@@ -7,6 +7,7 @@ import 'reactflow/dist/style.css';
...
@@ -7,6 +7,7 @@ import 'reactflow/dist/style.css';
import
{
history
}
from
'umi'
;
import
{
history
}
from
'umi'
;
import
{
import
{
ArrowPathIcon
,
ArrowPathIcon
,
ChevronDown
,
ChevronRight
,
ChevronRight
,
Download
,
Download
,
Edit
,
Edit
,
...
@@ -21,7 +22,11 @@ import {
...
@@ -21,7 +22,11 @@ import {
import
ProcessCascade
from
'@/components/ProcessCascade'
;
import
ProcessCascade
from
'@/components/ProcessCascade'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanModal
from
'@/components/XinyuanModal'
;
import
XinyuanModal
from
'@/components/XinyuanModal'
;
import
{
attachGraphAncestors
}
from
'@/services/regulationGraphData'
;
import
{
createGraphXmindArchive
}
from
'@/services/regulationGraphXmind'
;
import
GraphNode
from
'./GraphNode'
;
import
GraphNode
from
'./GraphNode'
;
import
{
getGraphRecordId
,
getNextGraphRecordNode
,
getSelectedGraphRecordId
}
from
'./graphSelection'
;
import
{
findGraphDropTarget
,
REGULATION_DRAG_TYPE
}
from
'./graphDrop'
;
import
{
import
{
loadGraph
,
loadGraph
,
loadGraphArchive
,
loadGraphArchive
,
...
@@ -143,20 +148,7 @@ function enrichGraph(nextGraph, regulations, revisions) {
...
@@ -143,20 +148,7 @@ function enrichGraph(nextGraph, regulations, revisions) {
const
relatedRecord
=
relationId
?
regulationById
.
get
(
String
(
relationId
))
:
titleMatch
?.
record
;
const
relatedRecord
=
relationId
?
regulationById
.
get
(
String
(
relationId
))
:
titleMatch
?.
record
;
return
normalizeNode
({
...
node
,
relatedRegulation
:
relationId
||
node
.
relatedRegulation
,
relatedRegulationType
:
status
||
'active'
,
historyRegulation
:
history
,
title
:
node
.
title
||
metadataOf
(
relatedRecord
).
regulation_name
||
relatedRecord
?.
title
});
return
normalizeNode
({
...
node
,
relatedRegulation
:
relationId
||
node
.
relatedRegulation
,
relatedRegulationType
:
status
||
'active'
,
historyRegulation
:
history
,
title
:
node
.
title
||
metadataOf
(
relatedRecord
).
regulation_name
||
relatedRecord
?.
title
});
});
});
const
byId
=
new
Map
(
nodes
.
map
((
node
)
=>
[
node
.
id
,
node
]));
return
{
nodes
:
attachGraphAncestors
(
nodes
),
edges
:
graph
.
edges
};
nodes
.
forEach
((
node
)
=>
{
const
ancestors
=
[];
let
parent
=
node
.
parent
?
byId
.
get
(
String
(
node
.
parent
))
:
null
;
const
visited
=
new
Set
();
while
(
parent
&&
!
visited
.
has
(
parent
.
id
))
{
visited
.
add
(
parent
.
id
);
ancestors
.
unshift
(
parent
);
parent
=
parent
.
parent
?
byId
.
get
(
String
(
parent
.
parent
))
:
null
;
}
node
.
ancestors
=
ancestors
;
node
.
data
.
ancestors
=
ancestors
;
});
return
{
nodes
,
edges
:
graph
.
edges
};
}
}
function
parseXmindJson
(
value
)
{
function
parseXmindJson
(
value
)
{
...
@@ -248,20 +240,26 @@ export default function RegulationGraph() {
...
@@ -248,20 +240,26 @@ export default function RegulationGraph() {
const
[
showRemoved
,
setShowRemoved
]
=
useState
(
true
);
const
[
showRemoved
,
setShowRemoved
]
=
useState
(
true
);
const
[
collapsedIds
,
setCollapsedIds
]
=
useState
(
new
Set
());
const
[
collapsedIds
,
setCollapsedIds
]
=
useState
(
new
Set
());
const
[
selectedId
,
setSelectedId
]
=
useState
(
null
);
const
[
selectedId
,
setSelectedId
]
=
useState
(
null
);
const
[
selectedRecordId
,
setSelectedRecordId
]
=
useState
(
null
);
const
[
leftOpen
,
setLeftOpen
]
=
useState
(
true
);
const
[
leftOpen
,
setLeftOpen
]
=
useState
(
true
);
const
[
rightOpen
,
setRightOpen
]
=
useState
(
true
);
const
[
rightOpen
,
setRightOpen
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
exporting
,
setExporting
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
notice
,
setNotice
]
=
useState
(
''
);
const
[
notice
,
setNotice
]
=
useState
(
''
);
const
[
contextMenu
,
setContextMenu
]
=
useState
(
null
);
const
[
contextMenu
,
setContextMenu
]
=
useState
(
null
);
const
[
nodeDialog
,
setNodeDialog
]
=
useState
(
null
);
const
[
nodeDialog
,
setNodeDialog
]
=
useState
(
null
);
const
[
nodeForm
,
setNodeForm
]
=
useState
({
title
:
''
,
content
:
''
});
const
[
nodeForm
,
setNodeForm
]
=
useState
({
title
:
''
,
content
:
''
});
const
[
draggedPositions
,
setDraggedPositions
]
=
useState
({});
const
[
draggedPositions
,
setDraggedPositions
]
=
useState
({});
const
[
dropPreview
,
setDropPreview
]
=
useState
(
null
);
const
flowRef
=
useRef
(
null
);
const
flowRef
=
useRef
(
null
);
const
focusRequestRef
=
useRef
(
0
);
const
canvasRef
=
useRef
(
null
);
const
canvasRef
=
useRef
(
null
);
const
importInputRef
=
useRef
(
null
);
const
applyGraph
=
useCallback
((
nextGraph
)
=>
{
const
applyGraph
=
useCallback
((
nextGraph
)
=>
{
focusRequestRef
.
current
+=
1
;
const
normalized
=
normalizeGraph
(
nextGraph
);
const
normalized
=
normalizeGraph
(
nextGraph
);
const
edgeList
=
normalized
.
edges
.
map
((
edge
)
=>
({
...
edge
,
type
:
'smoothstep'
,
style
:
{
stroke
:
'#94a3b8'
,
strokeWidth
:
1.5
}
}));
const
edgeList
=
normalized
.
edges
.
map
((
edge
)
=>
({
...
edge
,
type
:
'smoothstep'
,
style
:
{
stroke
:
'#94a3b8'
,
strokeWidth
:
1.5
}
}));
const
initialVisible
=
normalized
.
nodes
.
filter
((
node
)
=>
!
defaultCollapsedIds
(
normalized
.
nodes
).
has
(
node
.
id
)
&&
!
node
.
removed
);
const
initialVisible
=
normalized
.
nodes
.
filter
((
node
)
=>
!
defaultCollapsedIds
(
normalized
.
nodes
).
has
(
node
.
id
)
&&
!
node
.
removed
);
...
@@ -271,8 +269,10 @@ export default function RegulationGraph() {
...
@@ -271,8 +269,10 @@ export default function RegulationGraph() {
const
positionedById
=
new
Map
(
positionedVisible
.
map
((
node
)
=>
[
node
.
id
,
node
]));
const
positionedById
=
new
Map
(
positionedVisible
.
map
((
node
)
=>
[
node
.
id
,
node
]));
setGraph
({
nodes
:
normalized
.
nodes
.
map
((
node
)
=>
positionedById
.
get
(
node
.
id
)
||
node
),
edges
:
edgeList
});
setGraph
({
nodes
:
normalized
.
nodes
.
map
((
node
)
=>
positionedById
.
get
(
node
.
id
)
||
node
),
edges
:
edgeList
});
setSelectedId
((
current
)
=>
normalized
.
nodes
.
some
((
node
)
=>
node
.
id
===
current
)
?
current
:
null
);
setSelectedId
((
current
)
=>
normalized
.
nodes
.
some
((
node
)
=>
node
.
id
===
current
)
?
current
:
null
);
setSelectedRecordId
(
null
);
setCollapsedIds
(
defaultCollapsedIds
(
normalized
.
nodes
));
setCollapsedIds
(
defaultCollapsedIds
(
normalized
.
nodes
));
setDraggedPositions
({});
setDraggedPositions
({});
setDropPreview
(
null
);
window
.
requestAnimationFrame
(()
=>
flowRef
.
current
?.
fitView
?.({
padding
:
0.22
,
duration
:
240
}));
window
.
requestAnimationFrame
(()
=>
flowRef
.
current
?.
fitView
?.({
padding
:
0.22
,
duration
:
240
}));
},
[]);
},
[]);
const
refresh
=
useCallback
(
async
()
=>
{
const
refresh
=
useCallback
(
async
()
=>
{
...
@@ -365,6 +365,7 @@ export default function RegulationGraph() {
...
@@ -365,6 +365,7 @@ 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
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
]);
...
@@ -387,15 +388,29 @@ export default function RegulationGraph() {
...
@@ -387,15 +388,29 @@ export default function RegulationGraph() {
content
:
node
.
content
,
content
:
node
.
content
,
removed
:
node
.
removed
,
removed
:
node
.
removed
,
selected
:
node
.
id
===
selectedId
,
selected
:
node
.
id
===
selectedId
,
dropTarget
:
dropPreview
?.
nodeId
===
node
.
id
,
expanded
:
!
collapsedIds
.
has
(
node
.
id
),
expanded
:
!
collapsedIds
.
has
(
node
.
id
),
childCount
:
(
childrenByParent
.
get
(
node
.
id
)
||
[]).
length
,
childCount
:
(
childrenByParent
.
get
(
node
.
id
)
||
[]).
length
,
relatedRegulationType
:
nodeStatus
(
node
),
relatedRegulationType
:
nodeStatus
(
node
),
onToggle
:
toggleCollapsed
,
onToggle
:
toggleCollapsed
,
},
},
}));
}));
},
[
collapsedIds
,
draggedPositions
,
graph
.
nodes
,
selectedId
,
visibleEdges
,
visibleNodes
]);
},
[
collapsedIds
,
draggedPositions
,
dropPreview
?.
nodeId
,
graph
.
nodes
,
selectedId
,
visibleEdges
,
visibleNodes
]);
const
focusNode
=
useCallback
((
node
)
=>
{
const
selectNode
=
useCallback
((
node
,
recordId
=
null
)
=>
{
if
(
!
node
)
return
;
if
(
!
node
)
return
;
focusRequestRef
.
current
+=
1
;
setSelectedId
(
node
.
id
);
setSelectedRecordId
(
recordId
);
setRightOpen
(
true
);
},
[]);
const
focusNode
=
useCallback
((
node
,
recordId
=
null
)
=>
{
if
(
!
node
)
return
;
const
zoom
=
flowRef
.
current
?.
getZoom
();
if
(
!
filteredCandidates
.
some
((
candidate
)
=>
candidate
.
id
===
node
.
id
))
{
setSelectedGroupIds
([]);
setSelectedStatuses
([]);
setShowCurrent
(
true
);
}
const
ancestors
=
[];
const
ancestors
=
[];
let
parent
=
node
.
parent
?
nodeById
.
get
(
String
(
node
.
parent
))
:
null
;
let
parent
=
node
.
parent
?
nodeById
.
get
(
String
(
node
.
parent
))
:
null
;
while
(
parent
)
{
while
(
parent
)
{
...
@@ -408,22 +423,35 @@ export default function RegulationGraph() {
...
@@ -408,22 +423,35 @@ export default function RegulationGraph() {
return
next
;
return
next
;
});
});
if
(
node
.
removed
)
setShowRemoved
(
true
);
if
(
node
.
removed
)
setShowRemoved
(
true
);
setSelectedId
(
node
.
id
);
selectNode
(
node
,
recordId
);
setRightOpen
(
true
);
const
focusRequestId
=
focusRequestRef
.
current
;
const
target
=
{
id
:
String
(
node
.
id
)
};
let
attempts
=
0
;
let
attempts
=
0
;
const
fitTarget
=
()
=>
{
const
centerTarget
=
()
=>
{
if
(
focusRequestId
!==
focusRequestRef
.
current
)
return
;
attempts
+=
1
;
attempts
+=
1
;
const
didFocus
=
flowRef
.
current
?.
fitView
?.({
nodes
:
[
target
],
padding
:
0.38
,
duration
:
280
});
const
instance
=
flowRef
.
current
;
// A newly expanded node may need one more render before React Flow has
const
target
=
instance
?.
getNode
(
String
(
node
.
id
));
// measured its bounds. Retry for a few frames instead of silently
// Expanded nodes need to render and be measured before their center is
// leaving the viewport at the previous location.
// available. Pan at the existing zoom instead of fitting a single node.
if
(
!
didFocus
&&
attempts
<
8
)
window
.
requestAnimationFrame
(
fitTarget
);
if
(
target
?.
width
&&
target
?.
height
)
{
const
position
=
target
.
positionAbsolute
||
target
.
position
;
instance
.
setCenter
(
position
.
x
+
target
.
width
/
2
,
position
.
y
+
target
.
height
/
2
,
{
zoom
:
zoom
??
instance
.
getZoom
(),
duration
:
280
});
}
else
if
(
attempts
<
8
)
{
window
.
requestAnimationFrame
(
centerTarget
);
}
};
};
window
.
requestAnimationFrame
(()
=>
{
window
.
requestAnimationFrame
(()
=>
{
window
.
requestAnimationFrame
(
fit
Target
);
window
.
requestAnimationFrame
(
center
Target
);
});
});
},
[
nodeById
]);
},
[
filteredCandidates
,
nodeById
,
selectNode
]);
const
focusRecordNode
=
useCallback
((
record
)
=>
{
const
node
=
getNextGraphRecordNode
(
record
,
graph
.
nodes
,
selectedId
,
selectedRecordId
);
if
(
!
node
)
{
toast
.
warning
(
'该制度在当前拓扑图中暂无关联节点。'
);
return
;
}
focusNode
(
node
,
getGraphRecordId
(
record
));
},
[
focusNode
,
graph
.
nodes
,
selectedId
,
selectedRecordId
]);
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
});
...
@@ -437,6 +465,26 @@ export default function RegulationGraph() {
...
@@ -437,6 +465,26 @@ export default function RegulationGraph() {
const
csv
=
`\uFEFF
${
Object
.
keys
(
rows
[
0
]
||
{
制度名称
:
''
,
描述
:
''
,
状态
:
''
}).
join
(
','
)}
\n
${
rows
.
map
((
row
)
=>
Object
.
values
(
row
).
map
((
value
)
=>
`"
${
String
(
value
).
replaceAll
(
'"'
,
'""'
)}
"`
).
join
(
','
)).
join
(
'
\
n'
)}
`
;
const
csv
=
`\uFEFF
${
Object
.
keys
(
rows
[
0
]
||
{
制度名称
:
''
,
描述
:
''
,
状态
:
''
}).
join
(
','
)}
\n
${
rows
.
map
((
row
)
=>
Object
.
values
(
row
).
map
((
value
)
=>
`"
${
String
(
value
).
replaceAll
(
'"'
,
'""'
)}
"`
).
join
(
','
)).
join
(
'
\
n'
)}
`
;
const
url
=
URL
.
createObjectURL
(
new
Blob
([
csv
],
{
type
:
'text/csv;charset=utf-8'
}));
const
link
=
document
.
createElement
(
'a'
);
link
.
href
=
url
;
link
.
download
=
`internal-regulation-graph-
${
archive
}
.csv`
;
link
.
click
();
URL
.
revokeObjectURL
(
url
);
setNotice
(
'制度列表已导出'
);
const
url
=
URL
.
createObjectURL
(
new
Blob
([
csv
],
{
type
:
'text/csv;charset=utf-8'
}));
const
link
=
document
.
createElement
(
'a'
);
link
.
href
=
url
;
link
.
download
=
`internal-regulation-graph-
${
archive
}
.csv`
;
link
.
click
();
URL
.
revokeObjectURL
(
url
);
setNotice
(
'制度列表已导出'
);
};
};
const
exportXmindGraph
=
async
()
=>
{
if
(
exporting
)
return
;
try
{
setExporting
(
true
);
const
bytes
=
await
createGraphXmindArchive
(
graph
,
archive
);
const
url
=
URL
.
createObjectURL
(
new
Blob
([
bytes
],
{
type
:
'application/octet-stream'
}));
const
link
=
document
.
createElement
(
'a'
);
const
version
=
archive
.
replace
(
/
[\\/
:*?"<>|
\u
0000-
\u
001f
]
/g
,
'_'
);
link
.
href
=
url
;
link
.
download
=
`制度拓扑图-
${
version
}
.xmind`
;
link
.
click
();
window
.
setTimeout
(()
=>
URL
.
revokeObjectURL
(
url
),
1000
);
setNotice
(
'拓扑图已导出为 XMind'
);
setError
(
''
);
}
catch
(
exportError
)
{
setError
(
exportError
?.
message
||
'XMind 导出失败,请稍后重试。'
);
}
finally
{
setExporting
(
false
);
}
};
const
openAddNode
=
(
parentId
,
mode
=
'group'
)
=>
{
setContextMenu
(
null
);
setNodeForm
({
title
:
mode
===
'description'
?
''
:
'新节点'
,
content
:
''
});
setNodeDialog
({
mode
,
parentId
:
parentId
||
graph
.
nodes
.
find
((
node
)
=>
node
.
type
===
'rootNode'
)?.
id
||
null
});
};
const
openAddNode
=
(
parentId
,
mode
=
'group'
)
=>
{
setContextMenu
(
null
);
setNodeForm
({
title
:
mode
===
'description'
?
''
:
'新节点'
,
content
:
''
});
setNodeDialog
({
mode
,
parentId
:
parentId
||
graph
.
nodes
.
find
((
node
)
=>
node
.
type
===
'rootNode'
)?.
id
||
null
});
};
const
submitNodeDialog
=
()
=>
{
const
submitNodeDialog
=
()
=>
{
const
title
=
nodeForm
.
title
.
trim
();
const
title
=
nodeForm
.
title
.
trim
();
...
@@ -449,9 +497,69 @@ export default function RegulationGraph() {
...
@@ -449,9 +497,69 @@ export default function RegulationGraph() {
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
try
{
setSaving
(
true
);
const
tree
=
file
.
name
.
toLowerCase
().
endsWith
(
'.json'
)
?
parseXmindJson
(
JSON
.
parse
(
await
file
.
text
()))
:
await
readXmindTree
(
file
);
const
imported
=
xmindTreeToNodes
(
tree
,
regulations
,
revisions
);
applyGraph
(
enrichGraph
(
imported
,
regulations
,
revisions
));
await
syncGraphCategories
(
imported
.
nodes
,
regulations
);
setNotice
(
'XMind 已导入,保存后写入拓扑图'
);
setError
(
''
);
}
catch
(
importError
)
{
setError
(
importError
?.
message
||
'XMind 导入失败。'
);
}
finally
{
setSaving
(
false
);
event
.
target
.
value
=
''
;
}
try
{
setSaving
(
true
);
const
tree
=
file
.
name
.
toLowerCase
().
endsWith
(
'.json'
)
?
parseXmindJson
(
JSON
.
parse
(
await
file
.
text
()))
:
await
readXmindTree
(
file
);
const
imported
=
xmindTreeToNodes
(
tree
,
regulations
,
revisions
);
applyGraph
(
enrichGraph
(
imported
,
regulations
,
revisions
));
await
syncGraphCategories
(
imported
.
nodes
,
regulations
);
setNotice
(
'XMind 已导入,保存后写入拓扑图'
);
setError
(
''
);
}
catch
(
importError
)
{
setError
(
importError
?.
message
||
'XMind 导入失败。'
);
}
finally
{
setSaving
(
false
);
event
.
target
.
value
=
''
;
}
};
};
const
clearDropPreview
=
()
=>
{
setDropPreview
(
null
);
};
const
getDropTarget
=
(
event
)
=>
{
const
instance
=
flowRef
.
current
;
if
(
!
instance
||
!
event
.
target
.
closest
?.(
'.react-flow'
))
return
null
;
const
point
=
instance
.
screenToFlowPosition
({
x
:
event
.
clientX
,
y
:
event
.
clientY
});
return
findGraphDropTarget
(
instance
.
getNodes
(),
point
);
};
const
previewRecordDrop
=
(
event
)
=>
{
if
(
!
Array
.
from
(
event
.
dataTransfer
.
types
).
includes
(
REGULATION_DRAG_TYPE
))
return
;
event
.
preventDefault
();
if
(
archive
!==
'当前版本'
||
saving
)
{
event
.
dataTransfer
.
dropEffect
=
'none'
;
setDropPreview
(
null
);
return
;
}
const
target
=
getDropTarget
(
event
);
event
.
dataTransfer
.
dropEffect
=
target
?
'copy'
:
'none'
;
setDropPreview
(
target
?
{
...
target
,
viewport
:
flowRef
.
current
.
getViewport
()
}
:
null
);
};
const
leaveRecordDrop
=
(
event
)
=>
{
const
bounds
=
event
.
currentTarget
.
getBoundingClientRect
();
if
(
event
.
clientX
<=
bounds
.
left
||
event
.
clientX
>=
bounds
.
right
||
event
.
clientY
<=
bounds
.
top
||
event
.
clientY
>=
bounds
.
bottom
)
setDropPreview
(
null
);
};
const
dropPendingRevision
=
(
event
)
=>
{
const
dropPendingRevision
=
(
event
)
=>
{
event
.
preventDefault
();
const
raw
=
event
.
dataTransfer
.
getData
(
'application/x-regulation-revision'
);
if
(
!
raw
)
return
;
if
(
!
Array
.
from
(
event
.
dataTransfer
.
types
).
includes
(
REGULATION_DRAG_TYPE
))
return
;
try
{
const
revision
=
JSON
.
parse
(
raw
);
const
parent
=
selectedNode
?.
id
||
graph
.
nodes
.
find
((
node
)
=>
node
.
type
===
'rootNode'
)?.
id
;
if
(
!
parent
)
throw
new
Error
(
'请先在拓扑图中创建根节点。'
);
const
meta
=
revision
.
metadata
||
{};
const
type
=
revision
.
__pendingStatus
||
(
meta
.
type
===
'新增'
?
'addition'
:
meta
.
type
===
'废止'
?
'forbidden'
:
meta
.
type
===
'修订'
?
'revise'
:
'active'
);
const
id
=
makeId
(
'regulation'
);
const
node
=
normalizeNode
({
id
,
type
:
'regulationNode'
,
title
:
meta
.
regulation_name
||
revision
.
title
,
parent
,
relatedRegulation
:
type
===
'active'
?
revision
.
_id
||
revision
.
name
:
referenceId
(
meta
.
origin_regulation
),
relatedReviseRegulation
:
meta
.
type
===
'新增'
?
revision
.
_id
||
revision
.
name
:
null
,
relatedRegulationType
:
type
,
content
:
revision
.
content
||
''
,
position
:
{
x
:
0
,
y
:
0
}
});
setGraph
((
current
)
=>
({
nodes
:
[...
current
.
nodes
,
node
],
edges
:
[...
current
.
edges
,
{
id
:
`
${
parent
}
->
${
id
}
`
,
source
:
parent
,
target
:
id
}]
}));
setNotice
(
'制度已加入拓扑图,请保存'
);
}
catch
(
dropError
)
{
setError
(
dropError
?.
message
||
'加入拓扑图失败。'
);
}
event
.
preventDefault
();
const
raw
=
event
.
dataTransfer
.
getData
(
REGULATION_DRAG_TYPE
);
clearDropPreview
();
if
(
archive
!==
'当前版本'
||
saving
)
return
;
const
target
=
getDropTarget
(
event
);
if
(
!
target
)
{
toast
.
warning
(
'请拖到目标节点右侧的连接区域。'
);
return
;
}
try
{
const
record
=
JSON
.
parse
(
raw
);
const
meta
=
metadataOf
(
record
);
const
parentNode
=
nodeById
.
get
(
target
.
nodeId
);
if
(
!
parentNode
||
parentNode
.
removed
)
return
;
const
isRevision
=
record
.
subject
===
REVISION_SUBJECT
||
[
'新增'
,
'修订'
,
'废止'
].
includes
(
meta
.
type
);
const
type
=
record
.
__pendingStatus
||
(
meta
.
type
===
'新增'
?
'addition'
:
meta
.
type
===
'废止'
?
'forbidden'
:
meta
.
type
===
'修订'
?
'revise'
:
'active'
);
const
id
=
makeId
(
'regulation'
);
const
node
=
normalizeNode
({
id
,
type
:
'regulationNode'
,
title
:
meta
.
regulation_name
||
record
.
title
,
parent
:
parentNode
.
id
,
relatedRegulation
:
isRevision
?
referenceId
(
meta
.
origin_regulation
)
:
record
.
_id
||
record
.
name
,
relatedReviseRegulation
:
isRevision
&&
meta
.
type
===
'新增'
?
record
.
_id
||
record
.
name
:
null
,
relatedRegulationType
:
type
,
content
:
record
.
content
||
''
,
position
:
{
x
:
target
.
end
.
x
,
y
:
target
.
end
.
y
-
22
},
});
const
edge
=
{
id
:
`
${
parentNode
.
id
}
->
${
id
}
`
,
source
:
parentNode
.
id
,
target
:
id
,
type
:
'smoothstep'
,
style
:
{
stroke
:
'#94a3b8'
,
strokeWidth
:
1.5
}
};
setGraph
((
current
)
=>
enrichGraph
({
nodes
:
[...
current
.
nodes
,
node
],
edges
:
[...
current
.
edges
,
edge
]
},
regulations
,
revisions
));
focusNode
(
node
);
setNotice
(
`制度已连接到「
${
parentNode
.
title
}
」,请保存拓扑图`
);
setError
(
''
);
}
catch
(
dropError
)
{
setError
(
dropError
?.
message
||
'加入拓扑图失败。'
);
}
};
};
const
loadArchive
=
async
(
value
)
=>
{
const
loadArchive
=
async
(
value
)
=>
{
try
{
try
{
...
@@ -479,13 +587,45 @@ export default function RegulationGraph() {
...
@@ -479,13 +587,45 @@ export default function RegulationGraph() {
if
(
loading
)
return
<
div
className=
"flex min-h-72 items-center justify-center"
><
Spinner
label=
"正在加载制度拓扑图..."
/></
div
>;
if
(
loading
)
return
<
div
className=
"flex min-h-72 items-center justify-center"
><
Spinner
label=
"正在加载制度拓扑图..."
/></
div
>;
return
(
return
(
<
div
className=
"flex h-[calc(100dvh-7.5rem)] min-h-0 flex-col gap-4 overflow-hidden"
onClick=
{
()
=>
setContextMenu
(
null
)
}
>
<
div
className=
"flex h-[calc(100dvh-7.5rem)] min-h-0 flex-col gap-4 overflow-hidden"
onClick=
{
()
=>
setContextMenu
(
null
)
}
>
<
div
><
h1
className=
"text-2xl font-semibold"
>
制度拓扑图
</
h1
></
div
>
{
error
?
<
div
role=
"alert"
className=
"rounded-xl border border-danger/30 bg-danger/5 p-3 text-sm text-danger"
>
{
error
}
</
div
>
:
null
}
{
error
?
<
div
role=
"alert"
className=
"rounded-xl border border-danger/30 bg-danger/5 p-3 text-sm text-danger"
>
{
error
}
</
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
}
{
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=
"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
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
}
onDragStart=
{
(
event
)
=>
{
event
.
dataTransfer
.
effectAllowed
=
'copy'
;
event
.
dataTransfer
.
setData
(
'application/x-regulation-revision'
,
JSON
.
stringify
(
node
));
}
}
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 ${selectedId === node.id ? 'bg-accent/10 text-accent' : ''}`
}
key=
{
node
.
_id
||
node
.
id
}
onClick=
{
()
=>
isRecordList
?
null
:
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=
{
(
event
)
=>
event
.
preventDefault
()
}
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=
"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
)
=>
focusNode
(
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
)
=>
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
>
{
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
>
<
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
>
<
aside
className=
{
`shrink-0 overflow-hidden border-s border-separator transition-[width] ${rightOpen ? 'w-80' : 'w-0'}`
}
><
div
className=
"flex h-full w-80 flex-col"
>
{
selectedNode
?
<><
div
className=
"flex 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
>
{
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=
"border-t border-separator p-3"
><
div
className=
"grid grid-cols-4 gap-2"
><
label
className=
{
`flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-dashed border-separator text-xs text-muted hover:border-accent ${archive !== '当前版本' || saving ? 'pointer-events-none opacity-50' : ''}`
}
><
Upload
size=
{
15
}
/><
span
className=
"sr-only"
>
导入 XMind
</
span
><
input
accept=
".xmind,.json"
className=
"sr-only"
disabled=
{
archive
!==
'当前版本'
||
saving
}
type=
"file"
onChange=
{
importGraph
}
/></
label
><
Button
isDisabled=
{
saving
||
archive
!==
'当前版本'
}
variant=
"secondary"
onPress=
{
save
}
><
Save
size=
{
15
}
/>
保存
</
Button
><
Button
variant=
"tertiary"
onPress=
{
exportGraph
}
><
Download
size=
{
15
}
/>
导出
</
Button
><
Button
isIconOnly
aria
-
label=
"添加流程节点"
variant=
"tertiary"
onPress=
{
()
=>
openAddNode
(
selectedNode
.
id
,
'group'
)
}
><
PlusIcon
size=
{
16
}
/></
Button
></
div
></
div
></>
:
<
div
className=
"flex h-full items-center justify-center p-6 text-center text-sm text-muted"
>
点击中间节点查看编辑和流程信息
</
div
>
}
</
div
></
aside
>
</
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
>
}
{
dropPreview
?
<>
<
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})`
}
>
<
rect
{
...
dropPreview
.
zone
}
fill=
"currentColor"
fillOpacity=
"0.05"
stroke=
"currentColor"
strokeDasharray=
"5 5"
strokeOpacity=
"0.4"
rx=
"8"
/>
<
path
d=
{
`M ${dropPreview.source.x} ${dropPreview.source.y} H ${(dropPreview.source.x + dropPreview.end.x) / 2} V ${dropPreview.end.y} H ${dropPreview.end.x}`
}
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
/>
<
circle
cx=
{
dropPreview
.
end
.
x
}
cy=
{
dropPreview
.
end
.
y
}
r=
"5"
fill=
"currentColor"
/>
</
g
>
</
svg
>
<
div
className=
"pointer-events-none absolute z-[6] rounded-lg border border-accent/40 bg-background px-3 py-2 text-xs text-accent shadow-sm"
style=
{
{
left
:
dropPreview
.
end
.
x
*
dropPreview
.
viewport
.
zoom
+
dropPreview
.
viewport
.
x
,
top
:
dropPreview
.
end
.
y
*
dropPreview
.
viewport
.
zoom
+
dropPreview
.
viewport
.
y
+
12
}
}
>
松开连接到「
{
nodeById
.
get
(
dropPreview
.
nodeId
)?.
title
}
」
</
div
>
</>
:
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
>
<
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
aria
-
label=
"拓扑图操作"
role=
"group"
className=
"grid shrink-0 grid-cols-3 items-center gap-2 border-b border-separator p-3"
>
<
Button
aria
-
label=
"导入 XMind"
className=
"h-9 min-w-0 gap-1.5 whitespace-nowrap px-2"
fullWidth
isDisabled=
{
saving
||
archive
!==
'当前版本'
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
importInputRef
.
current
?.
click
()
}
><
Upload
className=
"shrink-0"
size=
{
15
}
/><
span
>
导入
</
span
></
Button
>
<
Dropdown
>
<
Button
aria
-
label=
"导出"
className=
"h-9 min-w-0 gap-1.5 whitespace-nowrap px-2"
fullWidth
isDisabled=
{
exporting
}
isPending=
{
exporting
}
size=
"sm"
variant=
"secondary"
>
{
exporting
?
<
Spinner
size=
"sm"
/>
:
<
Download
className=
"shrink-0"
size=
{
15
}
/>
}
<
span
>
{
exporting
?
'导出中'
:
'导出'
}
</
span
>
{
!
exporting
?
<
ChevronDown
className=
"shrink-0"
size=
{
14
}
/>
:
null
}
</
Button
>
<
Dropdown
.
Popover
placement=
"bottom end"
>
<
Dropdown
.
Menu
aria
-
label=
"导出格式"
onAction=
{
(
key
)
=>
{
if
(
key
===
'csv'
)
exportGraph
();
if
(
key
===
'xmind'
)
exportXmindGraph
();
}
}
>
<
Dropdown
.
Item
id=
"csv"
textValue=
"制度列表(.csv)"
>
制度列表(.csv)
</
Dropdown
.
Item
>
<
Dropdown
.
Item
id=
"xmind"
textValue=
"拓扑图(.xmind)"
>
拓扑图(.xmind)
</
Dropdown
.
Item
>
</
Dropdown
.
Menu
>
</
Dropdown
.
Popover
>
</
Dropdown
>
<
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
>
<
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
>
</
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
>
...
...
src/pages/internal_regulation_manager/RevisionList.jsx
View file @
d0b00c39
...
@@ -144,9 +144,9 @@ export default function RevisionList({ options = {} }) {
...
@@ -144,9 +144,9 @@ export default function RevisionList({ options = {} }) {
<
XinyuanModal
isOpen=
{
publishOpen
}
onOpenChange=
{
setPublishOpen
}
size=
"2xl"
title=
"提交发文"
footer=
{
<
div
className=
"flex justify-end gap-2"
><
Button
variant=
"tertiary"
onPress=
{
()
=>
setPublishOpen
(
false
)
}
>
取消
</
Button
><
Button
isDisabled=
{
saving
||
!
publishForm
.
public_date
}
variant=
"primary"
onPress=
{
handlePublish
}
>
{
saving
?
'发文中...'
:
'确认发文'
}
</
Button
></
div
>
}
>
<
XinyuanModal
isOpen=
{
publishOpen
}
onOpenChange=
{
setPublishOpen
}
size=
"2xl"
title=
"提交发文"
footer=
{
<
div
className=
"flex justify-end gap-2"
><
Button
variant=
"tertiary"
onPress=
{
()
=>
setPublishOpen
(
false
)
}
>
取消
</
Button
><
Button
isDisabled=
{
saving
||
!
publishForm
.
public_date
}
variant=
"primary"
onPress=
{
handlePublish
}
>
{
saving
?
'发文中...'
:
'确认发文'
}
</
Button
></
div
>
}
>
<
div
className=
"space-y-4"
><
TextField
fullWidth
isRequired
value=
{
publishForm
.
public_date
}
onChange=
{
(
value
)
=>
setPublishForm
((
current
)
=>
({
...
current
,
public_date
:
value
}))
}
><
Label
>
发文日期
</
Label
><
Input
fullWidth
type=
"date"
variant=
"secondary"
/></
TextField
>
{
editing
?.
metadata
?.
type
!==
'废止'
?
<
div
><
Label
className=
"xy-field-label"
>
附件(可选)
</
Label
><
label
className=
"mt-2 flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-separator bg-surface-secondary text-sm text-muted hover:border-accent"
><
Upload
size=
{
20
}
/><
span
className=
"mt-2"
>
点击上传 doc / docx 文件
</
span
><
input
accept=
".doc,.docx"
className=
"sr-only"
type=
"file"
onChange=
{
onUpload
}
/></
label
>
{
publishForm
.
attachments
.
length
?
<
p
className=
"mt-2 text-sm text-success"
>
已上传
{
publishForm
.
attachments
.
length
}
个附件
</
p
>
:
null
}
</
div
>
:
null
}
<
TextField
fullWidth
value=
{
publishForm
.
content
}
onChange=
{
(
value
)
=>
setPublishForm
((
current
)
=>
({
...
current
,
content
:
value
}))
}
><
Label
>
正文摘要
</
Label
><
TextArea
fullWidth
rows=
{
6
}
placeholder=
"可填写正文摘要或保留上传文档内容"
variant=
"secondary"
/></
TextField
></
div
>
<
div
className=
"space-y-4"
><
TextField
fullWidth
isRequired
value=
{
publishForm
.
public_date
}
onChange=
{
(
value
)
=>
setPublishForm
((
current
)
=>
({
...
current
,
public_date
:
value
}))
}
><
Label
>
发文日期
</
Label
><
Input
fullWidth
type=
"date"
variant=
"secondary"
/></
TextField
>
{
editing
?.
metadata
?.
type
!==
'废止'
?
<
div
><
Label
className=
"xy-field-label"
>
附件(可选)
</
Label
><
label
className=
"mt-2 flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-separator bg-surface-secondary text-sm text-muted hover:border-accent"
><
Upload
size=
{
20
}
/><
span
className=
"mt-2"
>
点击上传 doc / docx 文件
</
span
><
input
accept=
".doc,.docx"
className=
"sr-only"
type=
"file"
onChange=
{
onUpload
}
/></
label
>
{
publishForm
.
attachments
.
length
?
<
p
className=
"mt-2 text-sm text-success"
>
已上传
{
publishForm
.
attachments
.
length
}
个附件
</
p
>
:
null
}
</
div
>
:
null
}
<
TextField
fullWidth
value=
{
publishForm
.
content
}
onChange=
{
(
value
)
=>
setPublishForm
((
current
)
=>
({
...
current
,
content
:
value
}))
}
><
Label
>
正文摘要
</
Label
><
TextArea
fullWidth
rows=
{
6
}
placeholder=
"可填写正文摘要或保留上传文档内容"
variant=
"secondary"
/></
TextField
></
div
>
</
XinyuanModal
>
</
XinyuanModal
>
<
XinyuanModal
isOpen=
{
archiveOpen
}
onOpenChange=
{
setArchiveOpen
}
size=
"md"
title=
"归档
已发文
制度"
footer=
{
<
div
className=
"flex justify-end gap-2"
><
Button
variant=
"tertiary"
onPress=
{
()
=>
setArchiveOpen
(
false
)
}
>
取消
</
Button
><
Button
isDisabled=
{
saving
||
!
archiveTag
.
trim
()
}
variant=
"primary"
onPress=
{
handleArchive
}
>
{
saving
?
'归档中...'
:
'确认归档'
}
</
Button
></
div
>
}
>
<
XinyuanModal
isOpen=
{
archiveOpen
}
onOpenChange=
{
setArchiveOpen
}
size=
"md"
title=
"归档
现行有效
制度"
footer=
{
<
div
className=
"flex justify-end gap-2"
><
Button
variant=
"tertiary"
onPress=
{
()
=>
setArchiveOpen
(
false
)
}
>
取消
</
Button
><
Button
isDisabled=
{
saving
||
!
archiveTag
.
trim
()
}
variant=
"primary"
onPress=
{
handleArchive
}
>
{
saving
?
'归档中...'
:
'确认归档'
}
</
Button
></
div
>
}
>
<
TextField
fullWidth
isRequired
value=
{
archiveTag
}
onChange=
{
setArchiveTag
}
><
Label
>
归档
标签
</
Label
><
Input
fullWidth
placeholder=
"例如:2026 年度"
variant=
"secondary"
/></
TextField
>
<
TextField
fullWidth
isRequired
value=
{
archiveTag
}
onChange=
{
setArchiveTag
}
><
Label
>
归档
批次
</
Label
><
Input
fullWidth
placeholder=
"例如:2026 年度"
variant=
"secondary"
/></
TextField
>
<
p
className=
"mt-3 text-sm text-muted"
>
归档会复制所有“已发文”制度到归档对象,并保留原制度与修订记录
。
</
p
>
<
p
className=
"mt-3 text-sm text-muted"
>
将所有“现行有效”的制度保存为该批次的归档副本;如有已保存的当前拓扑图,也会一并归档。原制度与修订记录继续保留
。
</
p
>
</
XinyuanModal
>
</
XinyuanModal
>
</
div
>
</
div
>
);
);
...
...
src/pages/internal_regulation_manager/graphDrop.js
0 → 100644
View file @
d0b00c39
export
const
REGULATION_DRAG_TYPE
=
'application/x-regulation-revision'
;
export
const
DROP_ZONE_WIDTH
=
220
;
export
const
DROP_ZONE_PADDING
=
32
;
function
nodeBounds
(
node
)
{
const
position
=
node
.
positionAbsolute
||
node
.
position
;
const
width
=
node
.
width
||
(
node
.
type
===
'rootNode'
?
280
:
240
);
const
height
=
node
.
height
||
44
;
return
{
left
:
position
.
x
,
top
:
position
.
y
,
right
:
position
.
x
+
width
,
bottom
:
position
.
y
+
height
,
height
};
}
export
function
findGraphDropTarget
(
nodes
,
point
)
{
const
candidates
=
nodes
.
filter
((
node
)
=>
!
node
.
hidden
&&
!
node
.
removed
&&
!
node
.
data
?.
removed
);
// Do not attach to a node behind another node's body. The connection zone
// starts at the right handle and extends into the space to its right.
if
(
candidates
.
some
((
node
)
=>
{
const
bounds
=
nodeBounds
(
node
);
return
point
.
x
>=
bounds
.
left
&&
point
.
x
<
bounds
.
right
-
16
&&
point
.
y
>=
bounds
.
top
&&
point
.
y
<=
bounds
.
bottom
;
}))
return
null
;
let
target
=
null
;
let
bestDistance
=
Infinity
;
candidates
.
forEach
((
node
)
=>
{
const
bounds
=
nodeBounds
(
node
);
if
(
point
.
x
<
bounds
.
right
-
16
||
point
.
x
>
bounds
.
right
+
DROP_ZONE_WIDTH
||
point
.
y
<
bounds
.
top
-
DROP_ZONE_PADDING
||
point
.
y
>
bounds
.
bottom
+
DROP_ZONE_PADDING
)
return
;
const
centerY
=
bounds
.
top
+
bounds
.
height
/
2
;
const
distance
=
Math
.
abs
(
point
.
y
-
centerY
)
+
Math
.
max
(
0
,
point
.
x
-
bounds
.
right
)
*
0.15
;
if
(
distance
>=
bestDistance
)
return
;
bestDistance
=
distance
;
target
=
{
nodeId
:
String
(
node
.
id
),
source
:
{
x
:
bounds
.
right
,
y
:
centerY
},
end
:
{
x
:
Math
.
max
(
point
.
x
,
bounds
.
right
+
72
),
y
:
point
.
y
},
zone
:
{
x
:
bounds
.
right
,
y
:
bounds
.
top
-
DROP_ZONE_PADDING
,
width
:
DROP_ZONE_WIDTH
,
height
:
bounds
.
height
+
DROP_ZONE_PADDING
*
2
},
};
});
return
target
;
}
src/pages/internal_regulation_manager/graphSelection.js
0 → 100644
View file @
d0b00c39
export
function
getGraphRecordId
(
record
)
{
const
id
=
record
?.
_id
||
record
?.
name
;
return
id
?
String
(
id
)
:
null
;
}
export
function
getSelectedGraphRecordId
(
node
,
records
,
selectedRecordId
=
null
)
{
if
(
!
node
)
return
null
;
// Several historical records can locate the same node. Keep the clicked
// record identity so that locating a node does not select all its versions.
if
(
selectedRecordId
)
return
String
(
selectedRecordId
);
for
(
const
reference
of
[
node
.
relatedRegulation
,
node
.
relatedReviseRegulation
].
filter
(
Boolean
))
{
const
record
=
records
.
find
((
item
)
=>
[
item
.
_id
,
item
.
name
]
.
filter
(
Boolean
).
some
((
id
)
=>
String
(
id
)
===
String
(
reference
)));
if
(
record
)
return
getGraphRecordId
(
record
);
}
return
null
;
}
function
referenceId
(
value
)
{
const
id
=
typeof
value
===
'object'
?
value
?.
_id
||
value
?.
id
||
value
?.
name
:
value
;
return
id
?
String
(
id
)
:
null
;
}
export
function
getNextGraphRecordNode
(
record
,
nodes
,
selectedNodeId
=
null
,
selectedRecordId
=
null
)
{
const
recordIds
=
new
Set
([
record
?.
_id
,
record
?.
name
].
filter
(
Boolean
).
map
(
String
));
const
matches
=
(
node
,
ids
)
=>
[
node
.
relatedRegulation
,
node
.
relatedReviseRegulation
]
.
some
((
value
)
=>
ids
.
has
(
referenceId
(
value
)));
// Match both aliases of a record before falling back to its current version.
// Filtering the graph once keeps every occurrence in the graph's order.
let
candidates
=
nodes
.
filter
((
node
)
=>
matches
(
node
,
recordIds
));
const
originId
=
referenceId
(
record
?.
metadata
?.
origin_regulation
);
if
(
!
candidates
.
length
&&
originId
)
{
candidates
=
nodes
.
filter
((
node
)
=>
matches
(
node
,
new
Set
([
originId
])));
}
if
(
!
candidates
.
length
)
return
null
;
const
unremovedNodes
=
candidates
.
filter
((
node
)
=>
!
node
.
removed
);
if
(
unremovedNodes
.
length
)
candidates
=
unremovedNodes
;
const
isRepeatedClick
=
selectedRecordId
!==
null
&&
String
(
selectedRecordId
)
===
getGraphRecordId
(
record
);
const
currentIndex
=
isRepeatedClick
&&
selectedNodeId
!==
null
?
candidates
.
findIndex
((
node
)
=>
String
(
node
.
id
)
===
String
(
selectedNodeId
))
:
-
1
;
return
candidates
[(
currentIndex
+
1
)
%
candidates
.
length
];
}
src/pages/internal_regulation_manager/internal-regulation.business-flow.md
View file @
d0b00c39
...
@@ -6,6 +6,7 @@
...
@@ -6,6 +6,7 @@
-
`/internal_regulation/:name`
:制度详情、正文、附件、关联制度与历史沿革入口。
-
`/internal_regulation/:name`
:制度详情、正文、附件、关联制度与历史沿革入口。
-
`/internal_regulation_revision/:name`
:修订计划编辑页。
-
`/internal_regulation_revision/:name`
:修订计划编辑页。
-
`/graph`
:制度拓扑图(与
`/internal_regulation_manager`
制度目录为平级入口),根据
`internal_regulation_graph.metadata.graph_info`
展示制度、流程节点和父子连线;图谱记录缺失或节点为空时,根据制度引用关系生成降级图,确保首次进入页面仍能看到制度节点,同时保留非空图谱中的删除节点供“删除节点”列表查看。
-
`/graph`
:制度拓扑图(与
`/internal_regulation_manager`
制度目录为平级入口),根据
`internal_regulation_graph.metadata.graph_info`
展示制度、流程节点和父子连线;图谱记录缺失或节点为空时,根据制度引用关系生成降级图,确保首次进入页面仍能看到制度节点,同时保留非空图谱中的删除节点供“删除节点”列表查看。
-
“内部制度”和“制度拓扑图”页面的名称统一由顶部导航栏展示,主内容区不再重复显示模块说明或页面标题;制度管理内容直接从三个列表 Tab 开始,拓扑内容直接从画布及工具栏开始。
## 制度目录
## 制度目录
...
@@ -27,15 +28,27 @@
...
@@ -27,15 +28,27 @@
## 归档与拓扑
## 归档与拓扑
-
制度拓扑图与制度目录是两个平级入口;进入拓扑图后不呈现制度目录层级,也不提供返回制度目录操作,用户可通过左侧一级菜单在两个模块之间切换。
-
制度拓扑图与制度目录是两个平级入口;进入拓扑图后不呈现制度目录层级,也不提供返回制度目录操作,用户可通过左侧一级菜单在两个模块之间切换。
-
归档入口位于“修订计划 → 归档”,弹窗标题为“归档现行有效制度”;填写必填归档批次并确认后,将全部
`metadata.effective_status = 现行有效`
的制度保存到
`internal_regulation_archive`
,保留原制度与修订记录及其状态。归档范围不受列表状态、关键字或分页影响;如有已保存的当前拓扑图,也会一并保存为该批次的归档版本。归档期间禁用确认按钮,失败显示错误并保留已填写的批次。
-
归档历史从
`internal_regulation_archive`
读取,按归档批次和制度名称筛选,只读展示;制度名称输入后点击“查询”或按 Enter 才提交搜索,输入过程不实时请求。
-
归档历史从
`internal_regulation_archive`
读取,按归档批次和制度名称筛选,只读展示;制度名称输入后点击“查询”或按 Enter 才提交搜索,输入过程不实时请求。
-
归档历史支持按当前批次和关键字导出 CSV,详情入口回到对应现行制度。
-
归档历史支持按当前批次和关键字导出 CSV,详情入口回到对应现行制度。
-
拓扑图沿用旧版 React Flow + Dagre 的层级画布逻辑,左侧提供“全部 / 待分类 / 节点 / 删除节点”四个列表 Tab;“节点”和“删除节点”列表支持搜索和点击定位,点击隐藏层级中的节点时会先自动展开该节点的全部祖先路径,再将画布平滑移动到目标节点并打开详情。打开时默认展开根节点和第一层流程节点,第二层及更深节点按节点右侧计数按钮逐级展开。节点支持拖拽、缩放、邻接连线、展开/折叠和双击进入关联制度详情。
-
拓扑图沿用旧版 React Flow + Dagre 的层级画布逻辑,左侧提供“全部 / 待分类 / 节点 / 删除节点”四个列表 Tab;“节点”和“删除节点”列表支持搜索和点击定位,点击隐藏层级中的节点时会先自动展开该节点的全部祖先路径,再保持当前缩放比例将画布平滑移动到目标节点并打开详情。打开时默认展开根节点和第一层流程节点,第二层及更深节点按节点右侧计数按钮逐级展开。节点支持拖拽、缩放、邻接连线、展开/折叠和双击进入关联制度详情。
-
拓扑页高度锁定为当前视口可用高度,标题、工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
-
“全部”和“待分类”列表点击制度记录时,按记录 ID/name 匹配节点的现行制度或新增修订计划引用;修订/历史记录没有直接关联时再按
`metadata.origin_regulation`
查找。存在多个关联节点时优先定位未移除节点;列表的选中记录 ID 与画布的选中节点 ID 独立维护,点击制度记录后仅高亮该条记录,即使多个历史版本通过
`origin_regulation`
定位到同一个节点也不联动高亮。直接点击画布或“节点 / 删除节点”列表时,清除此前点击的记录选择,仅高亮节点直接引用的现行制度或新增修订计划(先匹配现行制度引用,再匹配修订计划引用);没有直接关联记录时不高亮制度列表。切换或重新加载拓扑版本时清除记录选择并按保留的节点选择重新匹配;定位复用“节点”Tab 的祖先展开、保持当前缩放比例的平滑移动和右侧详情打开逻辑。若目标被流程或状态筛选隐藏,定位时清除这两类筛选并开启现行制度显示;若目标已移除,则自动开启已移除节点显示。当前及归档拓扑加载均保留新增修订计划引用,确保已保存的待新增节点可继续定位。
-
“全部 / 待分类”列表首次点击制度记录时定位第一个关联节点,连续点击同一条记录时按图谱节点顺序循环定位(A → B → C → A);只有一个关联节点时保持定位该节点。记录
`_id/name`
的直接关联节点合并后参与循环,只有没有直接关联时才按
`metadata.origin_regulation`
查找。优先在未移除节点中循环;全部关联节点已移除时才在已移除节点中循环,并自动开启“显示已移除”。点击其他制度、选中画布或“节点 / 删除节点”列表、重新加载或切换拓扑版本后,再点击该制度从第一个关联节点重新开始。循环过程仍只高亮点击的制度记录,自动展开目标祖先、保持缩放并打开详情;快速连续点击时取消尚未执行的旧定位回调,画布最终定位到最新选中的节点。
-
当前所查看的拓扑版本没有关联节点时,点击制度记录显示 HeroUI 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。
-
画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。
-
拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
-
工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
-
工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
-
流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
-
流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
-
右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;右键可添加流程节点或描述。
-
“导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开
`.xmind/.json`
文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。
-
当前版本支持上传
`.xmind`
(兼容
`content.json`
与
`content.xml`
),会将
`《制度名称》`
主题匹配到制度记录,生成流程节点及连线,并同步
`graph_process`
分类;也支持将待处理制度拖入选中节点下。
-
XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按
`parent`
保留层级;非父子连线转为 XMind 关联线。制度主题使用
`《制度名称》`
,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;XMind 使用向右的逻辑图布局,不承诺复制画布的手工坐标、缩放或筛选状态。
-
保存写入
`internal_regulation_graph`
的
`graph_info.nodes/edges`
,导出生成制度列表 CSV;XMind 导入、节点编辑和拖拽归类均需保存后持久化图谱。
-
XMind 文件在浏览器内生成,使用现有 JSZip 打包
`content.json/metadata.json/manifest.json`
,下载名称为
`制度拓扑图-版本.xmind`
。生成期间显示“正在导出”并禁用重复导出;无节点、重复节点 ID 或循环父子关系时显示明确错误,失败保留当前图谱。CSV 继续按原规则导出制度列表。
-
右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;“添加流程节点”位于详情的节点操作区,右键也可添加流程节点或描述。
-
当前版本支持上传
`.xmind`
(兼容
`content.json`
与
`content.xml`
),会将
`《制度名称》`
主题匹配到制度记录,生成流程节点及连线,并同步
`graph_process`
分类。
-
“全部 / 待分类”的制度记录可拖到画布中任意未移除可见节点的右侧连接区域:区域向右延伸 220 个画布单位,上下各扩展 32 个单位;相邻区域重叠时优先选择离鼠标最近的节点。命中后高亮父节点、显示连接区域与横向连线预览,并提示“松开连接到「节点名称」”;缩放、平移及手动移动节点后仍按当前画布坐标判断。
-
松手后按落点目标建立父子节点及连线,展开祖先路径和父节点、选中新制度并打开详情;同时清除流程/状态筛选并开启现行显示,让新节点可见。待新增关联修订计划 ID,现行/待修订/待废止制度保留制度 ID,修订计划通过
`origin_regulation`
关联现行制度。
-
拖到连接区域外不创建节点或默认挂到根节点,显示“请拖到目标节点右侧的连接区域。”;离开画布、取消拖拽或完成连接后清除预览。已移除节点不能作为拖入目标;归档版本及保存期间禁用列表拖拽和落点写入。拖拽连接只改变当前图谱,点击“保存”后持久化;失败时保留现有图谱并显示错误。
-
保存写入
`internal_regulation_graph`
的
`graph_info.nodes/edges`
;导出菜单可生成制度列表 CSV 或完整拓扑图 XMind 文件。XMind 导入、节点编辑和拖拽归类均需保存后持久化图谱。
-
保存通过
`POST /record/import`
提交
`internal_regulation_graph.metadata.graph_info`
,只持久化节点 ID、类型、标题、备注、父节点 ID、制度/修订计划引用 ID、位置、展开/移除状态及连线;历史制度只保留 ID、名称和标题摘要,不复制正文、附件或完整 Record。祖先路径仅为
`id/title/type`
摘要,加载和连接后按父节点关系重建,不随保存请求重复嵌套;因此只点击节点后直接保存也能正常发起请求。请求期间禁用重复保存,失败保留画布和编辑内容。
## 加载、错误与限制
## 加载、错误与限制
...
...
src/services/internalRegulationService.js
View file @
d0b00c39
import
dayjs
from
'dayjs'
;
import
dayjs
from
'dayjs'
;
import
{
serializeGraph
}
from
'./regulationGraphData'
;
import
{
import
{
createDocument
,
createDocument
,
createRecord
,
createRecord
,
...
@@ -286,6 +287,7 @@ export async function loadGraph() {
...
@@ -286,6 +287,7 @@ export async function loadGraph() {
title
:
node
.
title
||
node
.
data
?.
title
||
node
.
id
,
title
:
node
.
title
||
node
.
data
?.
title
||
node
.
id
,
type
:
node
.
type
||
node
.
data
?.
type
||
'regulationNode'
,
type
:
node
.
type
||
node
.
data
?.
type
||
'regulationNode'
,
relatedRegulation
:
referenceId
(
node
.
related_regulation
||
node
.
data
?.
related_regulation
),
relatedRegulation
:
referenceId
(
node
.
related_regulation
||
node
.
data
?.
related_regulation
),
relatedReviseRegulation
:
referenceId
(
node
.
related_revise_regulation
||
node
.
data
?.
related_revise_regulation
),
relatedRegulationType
:
node
.
related_regulation_type
||
node
.
data
?.
related_regulation_type
||
'active'
,
relatedRegulationType
:
node
.
related_regulation_type
||
node
.
data
?.
related_regulation_type
||
'active'
,
parent
:
node
.
parent
||
node
.
data
?.
parent
||
null
,
parent
:
node
.
parent
||
node
.
data
?.
parent
||
null
,
content
:
node
.
content
||
node
.
data
?.
content
||
''
,
content
:
node
.
content
||
node
.
data
?.
content
||
''
,
...
@@ -331,6 +333,7 @@ export async function loadGraphArchive(archive) {
...
@@ -331,6 +333,7 @@ export async function loadGraphArchive(archive) {
title
:
node
.
title
||
node
.
data
?.
title
||
node
.
id
,
title
:
node
.
title
||
node
.
data
?.
title
||
node
.
id
,
type
:
node
.
type
||
node
.
data
?.
type
||
'regulationNode'
,
type
:
node
.
type
||
node
.
data
?.
type
||
'regulationNode'
,
relatedRegulation
:
referenceId
(
node
.
related_regulation
||
node
.
data
?.
related_regulation
),
relatedRegulation
:
referenceId
(
node
.
related_regulation
||
node
.
data
?.
related_regulation
),
relatedReviseRegulation
:
referenceId
(
node
.
related_revise_regulation
||
node
.
data
?.
related_revise_regulation
),
relatedRegulationType
:
node
.
related_regulation_type
||
node
.
data
?.
related_regulation_type
||
'active'
,
relatedRegulationType
:
node
.
related_regulation_type
||
node
.
data
?.
related_regulation_type
||
'active'
,
parent
:
node
.
parent
||
node
.
data
?.
parent
||
null
,
parent
:
node
.
parent
||
node
.
data
?.
parent
||
null
,
content
:
node
.
content
||
node
.
data
?.
content
||
''
,
content
:
node
.
content
||
node
.
data
?.
content
||
''
,
...
@@ -344,33 +347,7 @@ export async function loadGraphArchive(archive) {
...
@@ -344,33 +347,7 @@ export async function loadGraphArchive(archive) {
}
}
export
async
function
saveGraph
(
graph
,
archive
=
'当前版本'
)
{
export
async
function
saveGraph
(
graph
,
archive
=
'当前版本'
)
{
const
nodes
=
(
graph
?.
nodes
||
[]).
map
((
node
)
=>
({
const
{
nodes
,
edges
}
=
serializeGraph
(
graph
);
...
node
,
id
:
String
(
node
.
id
),
type
:
node
.
type
||
node
.
data
?.
type
||
'regulationNode'
,
position
:
node
.
position
||
{
x
:
0
,
y
:
0
},
data
:
{
...(
node
.
data
||
{}),
id
:
node
.
id
,
title
:
node
.
title
||
node
.
data
?.
title
||
node
.
id
,
parent
:
node
.
parent
||
node
.
data
?.
parent
||
null
,
type
:
node
.
type
||
node
.
data
?.
type
||
'regulationNode'
,
content
:
node
.
content
??
node
.
data
?.
content
??
null
,
related_regulation
:
node
.
relatedRegulation
||
node
.
data
?.
related_regulation
||
null
,
related_revise_regulation
:
node
.
relatedReviseRegulation
||
node
.
data
?.
related_revise_regulation
||
null
,
related_regulation_type
:
node
.
relatedRegulationType
||
node
.
data
?.
related_regulation_type
||
null
,
expanded
:
Boolean
(
node
.
expanded
??
node
.
data
?.
expanded
),
ancestors
:
node
.
ancestors
||
node
.
data
?.
ancestors
||
[],
history_regulation
:
node
.
historyRegulation
||
node
.
data
?.
history_regulation
||
[],
removed
:
Boolean
(
node
.
removed
||
node
.
data
?.
removed
),
},
}));
const
edges
=
(
graph
?.
edges
||
[]).
map
((
edge
)
=>
({
...
edge
,
id
:
edge
.
id
||
`
${
edge
.
source
}
->
${
edge
.
target
}
`
,
source
:
String
(
edge
.
source
),
target
:
String
(
edge
.
target
),
}));
return
importRecords
(
GRAPH_SUBJECT
,
[{
name
:
'graph_data'
,
title
:
'节点数据'
,
metadata
:
{
archive
,
type
:
'active'
,
graph_info
:
{
nodes
,
edges
}
}
}]);
return
importRecords
(
GRAPH_SUBJECT
,
[{
name
:
'graph_data'
,
title
:
'节点数据'
,
metadata
:
{
archive
,
type
:
'active'
,
graph_info
:
{
nodes
,
edges
}
}
}]);
}
}
...
...
src/services/record-api.md
View file @
d0b00c39
...
@@ -135,6 +135,14 @@
...
@@ -135,6 +135,14 @@
当前仅用于删除空模型分组。本期不提供模型、批次或模型数据删除。
当前仅用于删除空模型分组。本期不提供模型、批次或模型数据删除。
### 3.7 批量导入 Record
`POST /record/import`
内部制度拓扑图沿用此接口保存图谱,请求体为
`{ "subject": "internal_regulation_graph", "records": [...] }`
。图谱 Record 使用
`name: "graph_data"`
、
`title: "节点数据"`
,业务字段为
`metadata.archive`
、
`metadata.type`
和
`metadata.graph_info: { nodes, edges }`
。
节点按持久化字段构造,Reference 仅提交 ID,祖先路径和画布回调不写入请求;历史制度仅保存 ID、名称、标题摘要,避免嵌套完整节点或 Record 导致 JSON 膨胀。节点和连线结构遵循内部制度管理流程文档中的保存规则。
## 4. 用户查询接口
## 4. 用户查询接口
`POST /user/query`
`POST /user/query`
...
...
src/services/regulationGraphData.js
0 → 100644
View file @
d0b00c39
function
graphReferenceId
(
value
)
{
const
id
=
value
&&
typeof
value
===
'object'
?
value
.
_id
||
value
.
id
||
value
.
name
:
value
;
return
id
?
String
(
id
)
:
null
;
}
export
function
attachGraphAncestors
(
nodes
)
{
const
byId
=
new
Map
(
nodes
.
map
((
node
)
=>
[
String
(
node
.
id
),
node
]));
return
nodes
.
map
((
node
)
=>
{
const
ancestors
=
[];
const
visited
=
new
Set
([
String
(
node
.
id
)]);
let
parent
=
node
.
parent
?
byId
.
get
(
String
(
node
.
parent
))
:
null
;
while
(
parent
&&
!
visited
.
has
(
String
(
parent
.
id
)))
{
visited
.
add
(
String
(
parent
.
id
));
ancestors
.
unshift
({
id
:
String
(
parent
.
id
),
title
:
parent
.
title
,
type
:
parent
.
type
});
parent
=
parent
.
parent
?
byId
.
get
(
String
(
parent
.
parent
))
:
null
;
}
return
{
...
node
,
ancestors
,
data
:
{
...
node
.
data
,
ancestors
}
};
});
}
function
historySummary
(
record
)
{
return
{
_id
:
graphReferenceId
(
record
),
name
:
record
.
name
,
title
:
record
.
title
,
metadata
:
{
regulation_name
:
record
.
metadata
?.
regulation_name
},
};
}
export
function
serializeGraph
(
graph
)
{
const
nodes
=
(
graph
?.
nodes
||
[]).
map
((
node
)
=>
{
const
data
=
node
.
data
||
{};
const
id
=
String
(
node
.
id
??
data
.
id
);
const
type
=
node
.
type
||
data
.
type
||
'regulationNode'
;
const
title
=
node
.
title
||
data
.
title
||
id
;
const
parent
=
graphReferenceId
(
node
.
parent
??
data
.
parent
);
const
content
=
node
.
content
??
data
.
content
??
null
;
const
relatedRegulation
=
graphReferenceId
(
node
.
relatedRegulation
??
node
.
related_regulation
??
data
.
related_regulation
);
const
relatedReviseRegulation
=
graphReferenceId
(
node
.
relatedReviseRegulation
??
node
.
related_revise_regulation
??
data
.
related_revise_regulation
);
const
relatedRegulationType
=
node
.
relatedRegulationType
||
node
.
related_regulation_type
||
data
.
related_regulation_type
||
'active'
;
const
expanded
=
Boolean
(
node
.
expanded
??
data
.
expanded
);
const
removed
=
Boolean
(
node
.
removed
??
data
.
removed
);
const
history
=
node
.
historyRegulation
||
node
.
history_regulation
||
data
.
history_regulation
||
[];
// Persist only graph fields. Runtime ancestors can recursively contain all
// earlier nodes; spreading node/data here makes JSON grow exponentially.
return
{
id
,
type
,
title
,
parent
,
content
,
relatedRegulation
,
relatedReviseRegulation
,
relatedRegulationType
,
expanded
,
removed
,
position
:
{
x
:
node
.
position
?.
x
??
0
,
y
:
node
.
position
?.
y
??
0
},
data
:
{
id
,
type
,
title
,
parent
,
content
,
related_regulation
:
relatedRegulation
,
related_revise_regulation
:
relatedReviseRegulation
,
related_regulation_type
:
relatedRegulationType
,
expanded
,
removed
,
history_regulation
:
history
.
map
(
historySummary
),
},
};
});
const
edges
=
(
graph
?.
edges
||
[]).
map
((
edge
)
=>
{
const
source
=
graphReferenceId
(
edge
.
source
);
const
target
=
graphReferenceId
(
edge
.
target
);
return
{
id
:
edge
.
id
||
`
${
source
}
->
${
target
}
`
,
source
,
target
,
type
:
edge
.
type
||
'smoothstep'
};
});
return
{
nodes
,
edges
};
}
src/services/regulationGraphXmind.js
0 → 100644
View file @
d0b00c39
import
JSZip
from
'jszip'
;
import
{
serializeGraph
}
from
'./regulationGraphData.js'
;
const
STATUS_LABELS
=
{
active
:
'现行有效'
,
addition
:
'待新增'
,
revise
:
'待修订'
,
forbidden
:
'待废止'
,
};
const
XMIND_STRUCTURE
=
'org.xmind.ui.logic.right'
;
function
uniqueId
(
prefix
,
usedIds
)
{
let
id
=
prefix
;
let
suffix
=
1
;
while
(
usedIds
.
has
(
id
))
id
=
`
${
prefix
}
-
${
suffix
++
}
`
;
usedIds
.
add
(
id
);
return
id
;
}
export
function
buildGraphXmindSheets
(
graph
,
archive
=
'当前版本'
)
{
const
{
nodes
,
edges
}
=
serializeGraph
(
graph
);
if
(
!
nodes
.
length
)
throw
new
Error
(
'当前拓扑图没有节点,无法导出 XMind。'
);
const
byId
=
new
Map
(
nodes
.
map
((
node
)
=>
[
node
.
id
,
node
]));
if
(
byId
.
size
!==
nodes
.
length
)
throw
new
Error
(
'拓扑图存在重复节点 ID,无法导出 XMind。'
);
// XMind topics form a tree. Detect invalid parent cycles before constructing
// nested objects, so they cannot cause JSON serialization to grow or loop.
const
checked
=
new
Set
();
nodes
.
forEach
((
node
)
=>
{
const
path
=
new
Set
();
let
current
=
node
;
while
(
current
&&
!
checked
.
has
(
current
.
id
))
{
if
(
path
.
has
(
current
.
id
))
throw
new
Error
(
`节点「
${
current
.
title
}
」存在循环父子关系,请修正后再导出。`
);
path
.
add
(
current
.
id
);
current
=
current
.
parent
?
byId
.
get
(
current
.
parent
)
:
null
;
}
path
.
forEach
((
id
)
=>
checked
.
add
(
id
));
});
const
topicsById
=
new
Map
(
nodes
.
map
((
node
)
=>
{
const
name
=
String
(
node
.
title
||
node
.
id
);
const
topic
=
{
id
:
node
.
id
,
class
:
'topic'
,
title
:
node
.
type
===
'regulationNode'
&&
!
/^《.*》$/
.
test
(
name
)
?
`《
${
name
}
》`
:
name
,
};
if
(
node
.
content
)
topic
.
notes
=
{
plain
:
{
content
:
String
(
node
.
content
)
}
};
const
labels
=
[];
if
(
node
.
type
===
'regulationNode'
)
labels
.
push
(
STATUS_LABELS
[
node
.
relatedRegulationType
]
||
'现行有效'
);
if
(
node
.
removed
)
labels
.
push
(
'已移除'
);
if
(
labels
.
length
)
topic
.
labels
=
labels
;
return
[
node
.
id
,
topic
];
}));
const
roots
=
[];
nodes
.
forEach
((
node
)
=>
{
const
topic
=
topicsById
.
get
(
node
.
id
);
const
parent
=
topicsById
.
get
(
node
.
parent
);
if
(
parent
)
{
parent
.
children
||=
{
attached
:
[]
};
parent
.
children
.
attached
.
push
(
topic
);
}
else
{
roots
.
push
(
topic
);
}
});
const
usedIds
=
new
Set
(
byId
.
keys
());
const
rootTopic
=
roots
.
length
===
1
?
roots
[
0
]
:
{
id
:
uniqueId
(
'internal-regulation-xmind-root'
,
usedIds
),
class
:
'topic'
,
title
:
'制度拓扑图'
,
children
:
{
attached
:
roots
},
};
rootTopic
.
structureClass
=
XMIND_STRUCTURE
;
const
sheet
=
{
id
:
uniqueId
(
'internal-regulation-xmind-sheet'
,
usedIds
),
class
:
'sheet'
,
title
:
`制度拓扑图(
${
archive
}
)`
,
rootTopic
,
};
// Parent links are represented by the topic tree. Other graph edges become
// XMind relationships, retaining references between different branches.
const
relationships
=
edges
.
filter
((
edge
)
=>
byId
.
has
(
edge
.
source
)
&&
byId
.
has
(
edge
.
target
)
&&
byId
.
get
(
edge
.
target
).
parent
!==
edge
.
source
).
map
((
edge
)
=>
({
id
:
uniqueId
(
'internal-regulation-xmind-relationship'
,
usedIds
),
class
:
'relationship'
,
end1Id
:
edge
.
source
,
end2Id
:
edge
.
target
,
title
:
''
,
}));
if
(
relationships
.
length
)
sheet
.
relationships
=
relationships
;
return
[
sheet
];
}
export
async
function
createGraphXmindArchive
(
graph
,
archive
=
'当前版本'
)
{
const
sheets
=
buildGraphXmindSheets
(
graph
,
archive
);
// Package follows XMind's official xmind-generator serializer:
// https://github.com/xmindltd/xmind-generator/blob/main/src/internal/serializer.ts
const
zip
=
new
JSZip
();
zip
.
file
(
'content.json'
,
JSON
.
stringify
(
sheets
));
zip
.
file
(
'metadata.json'
,
JSON
.
stringify
({
creator
:
{
name
:
'内部制度管理'
},
dataStructureVersion
:
'2'
}));
zip
.
file
(
'manifest.json'
,
JSON
.
stringify
({
'file-entries'
:
{
'content.json'
:
{},
'metadata.json'
:
{}
}
}));
return
zip
.
generateAsync
({
type
:
'uint8array'
,
compression
:
'DEFLATE'
});
}
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