Commit a288b353 authored by drigle's avatar drigle

feat: add regulation record graph

parent 5519d074
......@@ -45,7 +45,8 @@ export default defineConfig({
{ path: '/import', component: './LegacyFeaturePage' },
{ path: '/query', component: './LegacyFeaturePage' },
{ path: '/settings', component: './LegacyFeaturePage' },
{ path: '/graph', component: './LegacyFeaturePage' },
{ path: '/graph', component: './RecordGraphPage' },
{ path: '/graph/:subject', component: './RecordGraphPage' },
{ path: '/files', component: './LegacyFeaturePage' },
{ path: '/spider', component: './LegacyFeaturePage' },
{ path: '/spider/:spiderName', component: './LegacyFeaturePage' },
......
This diff is collapsed.
......@@ -29,20 +29,24 @@
"@umijs/max": "^4.5.3",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dagre": "^0.8.5",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"lodash": "^4.18.1",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^13.1.0",
"parse-mind": "^1.0.2",
"react": "^19.2.8",
"react-aria": "^3.51.0",
"react-aria-components": "1.19.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"reactflow": "^11.11.4",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
......@@ -53,7 +57,8 @@
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/react": "^19.2.18",
......
This diff is collapsed.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const esbuild = require('esbuild');
const root = require('node:path').resolve(__dirname, '..');
function load(file, mocks = {}) {
const output = esbuild.buildSync({ entryPoints: [root + '/' + file], bundle: true, write: false, platform: 'node', format: 'cjs', packages: 'external', external: ['./DataService'] }).outputFiles[0].text;
const module = { exports: {} };
vm.runInNewContext(output, { module, exports: module.exports, require: name => mocks[name] || require(name), console });
return module.exports;
}
(async () => {
const { createGraphData } = load('src/components/RecordFlowGraph/graphData.js');
const records = [
{ _id: 'r1', name: 'first', title: '一级制度', order: 2, metadata: { level: '1级', related_regulations: [{ _id: 'r2' }] } },
{ _id: 'r2', name: 'second', title: '二级制度', order: 1, metadata: { level: '2级', related_regulations: [] } },
{ _id: 'r3', name: 'third', title: '先排制度', order: 1, metadata: { level: '1级', related_regulations: ['r2'] } },
];
const graph = createGraphData(records, ['1级', '2级'], false);
assert.equal(graph.nodes.length, 5);
assert.equal(graph.edges.length, 2);
assert.equal(graph.nodes.find(n => n.id === 'r3').position.y, 60);
assert.equal(graph.nodes.find(n => n.id === 'r1').position.y, 170);
assert.equal(graph.nodes.find(n => n.id === 'r2').position.x, 480);
assert.equal(graph.nodes.find(n => n.id === 'r1').metadata.related_regulations[0], 'r2');
const centered = createGraphData(records, ['1级', '2级'], true);
assert.equal(centered.nodes.find(n => n.id === 'r2').position.y, 115);
assert.equal(createGraphData(records.filter(r => r._id !== 'r2'), ['1级', '2级'], false).edges.length, 0);
const writes = [];
const service = load('src/services/RecordGraphService.js', { './DataService': {
updateRecord: async (...args) => writes.push(args), reorderRecords: async data => writes.push(data),
} });
await service.saveRecordRelations({ name: 'internal_regulation' }, graph.nodes.find(n => n.id === 'r1'), ['r2', 'r3']);
assert.equal(writes[0][0], 'r1');
assert.equal(writes[0][1].subject, 'internal_regulation');
assert.equal(writes[0][1].title, '一级制度');
assert.equal(writes[0][1].metadata.related_regulations.join(','), 'r2,r3');
await service.saveRecordGraphOrder({ first: 2 });
assert.equal(writes[1].first, 2);
const entry = fs.readFileSync(root + '/src/pages/LawSubjectPage.jsx', 'utf8');
assert(entry.includes('/graph/${encodeURIComponent(subject.name)}?node='));
console.log('PASS: card entry, schema levels/order, relation IDs, filtered edges, centered layout, relation and reorder payloads');
})().catch(error => { console.error(error); process.exitCode = 1; });
import { BezierEdge } from 'reactflow';
export default function GraphEdge(props) {
return <>
<BezierEdge {...props} style={{ stroke: props.selected ? 'var(--accent-soft)' : 'none', strokeWidth: 8 }} />
<BezierEdge {...props} style={{ ...props.style, stroke: props.selected || props.data?.active ? 'var(--accent)' : 'var(--border)', strokeWidth: 2 }} />
</>;
}
import { memo } from 'react';
import { Handle, Position } from 'reactflow';
export default memo(function GraphNode({ data, selected }) {
return <div className={`record-graph-node ${selected ? 'is-selected' : ''} ${data.active ? 'is-active' : ''}`}>
<Handle type="target" position={Position.Left} />
<div>{data.label}</div>
<Handle type="source" position={Position.Right} />
</div>;
});
import { useContext, useEffect, useRef, useState } from 'react';
import ReactFlow, { useNodesState, useEdgesState, addEdge, updateEdge } from 'reactflow';
import 'reactflow/dist/style.css';
import { UserContext } from '@/wrapper/Auth';
import { APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import { saveRecordRelations, saveRecordGraphOrder } from '@/services/RecordGraphService';
import { createGraphData, GRAPH_CONFIG } from './graphData';
import GraphNode from './GraphNode';
import TitleNode from './TitleNode';
import GraphEdge from './GraphEdge';
import './recordGraph.css';
const NODE_TYPES = { title: TitleNode, graph: GraphNode };
const EDGE_TYPES = { graph: GraphEdge };
export default function RecordFlowGraph({ list, levelArr, subject, centered, filterConfig, onError, onSuccess, onReload }) {
const user = useContext(UserContext);
const canEdit = user?.type === 'admin' || user?.type === 'system';
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const containerRef = useRef(null);
const [width, setWidth] = useState(0);
const edgeUpdateSuccessful = useRef(true);
const writing = useRef(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
const data = createGraphData(list, levelArr, centered, filterConfig);
setNodes(data.nodes); setEdges(data.edges);
}, [list, levelArr, centered]);
const write = async action => {
if (!canEdit || writing.current) return;
writing.current = true; setBusy(true); onError('');
try { await action(); onSuccess('保存成功'); }
catch (error) { onError(error.message || '保存失败,请刷新后重试'); onReload(); }
finally { writing.current = false; setBusy(false); }
};
const applyRelations = (id, related) => setNodes(current => current.map(node => node.id === id ? {
...node, metadata: { ...node.metadata, related_regulations: related },
} : node));
const onConnect = connection => {
const source = nodes.find(node => node.id === connection.source);
if (!source || source.metadata.related_regulations.includes(connection.target)) return;
write(async () => {
const related = [...source.metadata.related_regulations, connection.target];
await saveRecordRelations(subject, source, related);
applyRelations(source.id, related);
setEdges(current => addEdge({ ...connection, type: 'graph', selected: false, data: { active: false } }, current));
});
};
const onEdgeUpdate = (oldEdge, connection) => {
edgeUpdateSuccessful.current = true;
if (edges.some(edge => edge.source === connection.source && edge.target === connection.target)) return;
const oldSource = nodes.find(node => node.id === oldEdge.source);
const source = nodes.find(node => node.id === connection.source);
if (!oldSource || !source) return;
write(async () => {
const previous = oldSource.metadata.related_regulations.filter(id => id !== oldEdge.target);
if (oldSource.id !== source.id) {
await saveRecordRelations(subject, oldSource, previous);
applyRelations(oldSource.id, previous);
}
const related = [...(source.id === oldSource.id ? previous : source.metadata.related_regulations), connection.target];
await saveRecordRelations(subject, source, related);
applyRelations(source.id, related);
setEdges(current => updateEdge(oldEdge, connection, current));
});
};
const onEdgeUpdateEnd = (_, edge) => {
if (!edgeUpdateSuccessful.current) {
const source = nodes.find(node => node.id === edge.source);
if (source) write(async () => {
const related = source.metadata.related_regulations.filter(id => id !== edge.target);
await saveRecordRelations(subject, source, related);
applyRelations(source.id, related);
setEdges(current => current.filter(item => item.id !== edge.id));
});
}
edgeUpdateSuccessful.current = true;
};
const onNodeClick = (_, node) => {
setEdges(current => current.map(edge => ({ ...edge, selected: false, data: { ...edge.data, active: edge.source === node.id || edge.target === node.id } })));
setNodes(current => current.map(item => ({ ...item, selected: item.id === node.id, data: { ...item.data,
active: Boolean(node.metadata?.related_regulations?.includes(item.id) || item.metadata?.related_regulations?.includes(node.id)),
} })));
};
const onEdgeClick = (_, edge) => {
setEdges(current => current.map(item => ({ ...item, selected: item.id === edge.id, data: { ...item.data, active: item.id === edge.id } })));
setNodes(current => current.map(node => ({ ...node, selected: false, data: { ...node.data, active: node.id === edge.source || node.id === edge.target } })));
};
const onPaneClick = () => {
setNodes(current => current.map(node => ({ ...node, selected: false, data: { ...node.data, active: false } })));
setEdges(current => current.map(edge => ({ ...edge, selected: false, data: { ...edge.data, active: false } })));
};
const onNodeDragStop = (_, node) => {
if (!canEdit || node.type !== 'graph') return;
const sorted = nodes.filter(item => item.type === 'graph' && item.level === node.level).sort((a, b) => a.position.y - b.position.y);
const oldIndex = node.index;
const newIndex = sorted.findIndex(item => item.id === node.id);
if (oldIndex === newIndex) {
setNodes(current => current.map(item => item.id === node.id ? { ...item, position: {
x: node.level * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX),
y: (createGraphData(list, levelArr, centered).nodes.find(n => n.id === node.id)?.position.y || 0),
} } : item));
return;
}
write(async () => {
const related = sorted.slice(0, Math.max(oldIndex, newIndex) + 1);
const max = Math.max(...related.map(item => item.order || 0));
const orderMap = Object.fromEntries([...related].reverse().map((item, index) => [item.name, max - index]));
await saveRecordGraphOrder(orderMap);
const maxLength = Math.max(...levelArr.map(level => list.filter(record => record.metadata?.level === level).length));
const startY = GRAPH_CONFIG.paddingTop + (centered ? (maxLength - sorted.length) * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) / 2 : 0);
setNodes(current => current.map(item => {
const index = sorted.findIndex(candidate => candidate.id === item.id);
return index < 0 ? item : { ...item, index, order: orderMap[item.name] ?? item.order,
position: { x: item.level * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX), y: startY + index * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) } };
}));
});
};
return <div className="h-full w-full" ref={containerRef} aria-busy={busy}>
{width > 0 && <ReactFlow nodes={nodes} edges={edges} nodeTypes={NODE_TYPES} edgeTypes={EDGE_TYPES}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
onNodeClick={onNodeClick} onEdgeClick={onEdgeClick} onPaneClick={onPaneClick}
onNodeDoubleClick={(_, node) => { if (node.type === 'graph') window.open(`/${APP_ROUTE_NAME}/${subject.name}/${encodeURIComponent(node.name || node.id)}`, '_blank', 'noopener,noreferrer'); }}
onNodeDragStop={onNodeDragStop}
{...(canEdit ? { onEdgeUpdate, onEdgeUpdateStart: () => { edgeUpdateSuccessful.current = false; }, onEdgeUpdateEnd } : {})}
defaultViewport={{ x: width / 16, y: 0, zoom: width / (GRAPH_CONFIG.nodeWidth * 8) }}
panOnScroll={false} zoomOnPinch zoomOnDoubleClick={false} panOnDrag
nodesDraggable={canEdit && !busy} nodesConnectable={canEdit && !busy} edgesUpdatable={canEdit && !busy}
nodesFocusable={false} deleteKeyCode={null} maxZoom={5} minZoom={0.1}
connectionLineStyle={{ animation: 'dashdraw 0.5s linear infinite', stroke: 'var(--accent)', strokeDasharray: 5, strokeWidth: 2 }}
/>}
</div>;
}
import { memo } from 'react';
export default memo(function TitleNode({ data }) {
return <div className="record-graph-title"><strong>{data.label}</strong></div>;
});
import _ from 'lodash';
export const referenceId = value => typeof value === 'string' ? value : value?._id;
export const GRAPH_CONFIG = { paddingTop: 60, nodeWidth: 240, nodeHeight: 50, paddingX: 240, paddingY: 60 };
const nodeConfig = { selectable: false, sourcePosition: 'right', targetPosition: 'left' };
const edgeConfig = { style: { strokeWidth: 2 } };
export function createGraphData(list, levelArr, centered, filterConfig = {}) {
let nodeArr = []
let edgeArr = []
let levelMap = _.groupBy(list, (v) => v?.metadata?.level)
levelArr.map((v) => {
levelMap[v] = _.sortBy(levelMap[v], (v) => v?.order)
})
let max = _.maxBy(levelArr.map((v) => levelMap[v]), (v) => v?.length)
let maxLength = max?.length
// 计算节点坐标
levelArr.map((v, xIndex) => {
let startY = GRAPH_CONFIG.paddingTop
let current = levelMap?.[v]
let currentLength = current?.length
nodeArr.push({
type: 'title',
id: v,
selectable: false,
draggable: false,
connectable: false,
data: {
label: v
},
position: {
x: xIndex * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX),
y: 0
},
style: {
width: GRAPH_CONFIG.nodeWidth,
height: GRAPH_CONFIG.paddingTop
}
})
levelMap?.[v]?.map((item, yIndex) => {
// let already = nodes.find((n) => n?.id == item?._id)
let filterNode = list.find((l) => l?._id == filterConfig?._id)
let chosen = item?._id == filterNode?._id
let related = item?.metadata?.related_regulations?.map((v) => v?._id).includes(filterNode?._id)
|| filterNode?.metadata?.related_regulations?.map((v) => v?._id).includes(item?._id)
// 插入点
if (centered) {
let maxHeight = maxLength * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) - GRAPH_CONFIG.paddingY
let currentHeight = currentLength * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY) - GRAPH_CONFIG.paddingY
startY = GRAPH_CONFIG.paddingTop + (maxHeight - currentHeight) / 2
}
nodeArr.push({
type: 'graph',
id: item?._id,
name: item?.name,
title: item?.title,
data: {
active: false,
label: item?.title
},
level: xIndex,
index: yIndex,
order: item?.order,
metadata: {
...item?.metadata,
related_regulations: (item?.metadata?.related_regulations || []).map(referenceId)
},
// type: xIndex == 0 ? 'input' : xIndex == (levelArr.length - 1) ? 'output' : 'default',
position: {
x: xIndex * (GRAPH_CONFIG.nodeWidth + GRAPH_CONFIG.paddingX),
y: startY + yIndex * (GRAPH_CONFIG.nodeHeight + GRAPH_CONFIG.paddingY)
},
...nodeConfig
})
// 插入边
item?.metadata?.related_regulations?.map((reg) => {
let chosenEdge = item?._id == filterNode?._id || reg?._id == filterNode?._id
edgeArr.push({
id: `${item?._id}-${referenceId(reg)}`,
source: item?._id,
target: referenceId(reg),
sourceName: item?.title,
targetName: reg?.title,
type: 'graph',
data: {
active: false
},
...edgeConfig
})
})
})
})
const ids = new Set(nodeArr.map(node => node.id));
return { nodes: nodeArr, edges: edgeArr.filter(edge => ids.has(edge.source) && ids.has(edge.target)) }
}
.record-graph-page { height: calc(100vh - 8rem); min-height: 420px; display: flex; flex-direction: column; overflow: hidden; }
.record-graph-filter { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 20px; min-height: 64px; padding: 12px 24px; border-bottom: 1px solid var(--border); }
.record-graph-node { box-sizing: border-box; transition: all .5s; display: flex; align-items: center; justify-content: center; padding: 8px; border-radius: 5px; width: 240px; height: 60px; border: 2px solid var(--border); background: var(--surface); outline: 4px solid transparent; color: var(--foreground); text-align: center; font-size: 16px; cursor: pointer; }
.record-graph-node.is-active, .record-graph-node.is-selected { border-color: var(--accent); background: var(--accent-soft); font-weight: bold; }
.record-graph-node.is-selected { outline-color: var(--accent-soft); }
.record-graph-node:hover { border-color: var(--accent); }
.record-graph-node .react-flow__handle { transition: all .5s; border: 2px solid var(--border); background: var(--surface); width: 10px; height: 10px; border-radius: 50%; }
.record-graph-node.is-active .react-flow__handle, .record-graph-node.is-selected .react-flow__handle, .record-graph-node:hover .react-flow__handle { border-color: var(--accent); }
.record-graph-title { display: flex; align-items: center; justify-content: center; height: 60px; font-size: 18px; font-weight: 500; }
import { Tooltip } from '@heroui/react';
import { useRef, useState } from 'react';
import { useStoreApi } from 'reactflow';
import 'reactflow/dist/style.css';
import useStore from './store';
const STATUS_COLOR = {
修订: 'warning',
废止: 'danger',
新增: 'primary',
};
export default function DragItem(props) {
const {
nodes,
updateNode,
setDraggingItem,
flowRef,
findAncestors,
onNodeClick,
searchCount,
setSearchCount,
searchTitle,
setSearchTitle,
} = useStore((state) => ({
...state,
}));
const store = useStoreApi();
const { data } = props;
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
const handleDragStart = (e) => {
setDraggingItem(data);
if (dragRef.current && e.dataTransfer) {
e.dataTransfer.setDragImage(dragRef.current, 0, 0);
}
e.dataTransfer.effectAllowed = 'move';
setDragging(true);
};
const handleDragEnd = () => {
setDragging(false);
};
const renderRegulationStatus = (data) => {
if (data.metadata?.related_revision) {
return `待${data.metadata?.related_revision?.type}`;
}
if (data.metadata?.type) {
return `待${data.metadata.type}`;
}
return '现行有效';
};
const matchChipColor = (data) => {
const status = data.metadata?.related_revision?.type || data.metadata?.type;
return STATUS_COLOR[status] || 'default';
};
const fitViewToSelectedRegulation = () => {
const regulationNodes = nodes.filter((node) => node.data.title === data.title);
let count =
searchTitle == data.title
? searchCount + 1 > regulationNodes.length
? 0
: searchCount + 1
: 0;
const regulationNode = regulationNodes[count] || null;
setSearchTitle(data.title);
setSearchCount(count);
if (regulationNode) {
const { ancestors } = findAncestors(regulationNode, nodes);
ancestors.forEach((node) => {
node.data.expanded = true;
updateNode(node);
});
onNodeClick(null, regulationNode);
// todo 待优化
setTimeout(() => {
const currentNode = flowRef?.getNode(regulationNode.id);
const position = currentNode?.position || null;
if (!position) return;
flowRef.setCenter(position.x + 120, position.y + 18, {
duration: 200,
});
}, 500);
}
};
return (
<Tooltip delay={0}>
<div
ref={dragRef}
className="graph-list-item"
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={() => fitViewToSelectedRegulation(data)}
style={{ opacity: dragging ? 0.5 : 1 }}
>
<span className="graph-list-item-title">{props.children}</span>
<span className={`graph-status-chip graph-status-chip-${matchChipColor(data)}`}>
{renderRegulationStatus(data)}
</span>
</div>
<Tooltip.Content>{data.title}</Tooltip.Content>
</Tooltip>
);
}
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import '../graph.css';
export default function CustomNode({ data }) {
const { type, selected, related_regulation_type, opacity } = data;
const className = `${type} ${type === 'regulation' ? related_regulation_type : ''} ${
selected ? 'selected' : ''
}`;
return (
<div
className={className}
style={{
padding: 10,
borderRadius: 5,
width: 172,
height: 36,
opacity: opacity,
}}
>
<Handle type="target" position="left" style={{ background: '#555' }} />
<div className="graph-node-title" style={{ width: '220px' }}>
{data.title}
</div>
<Handle type="source" position="right" style={{ background: '#555' }} />
</div>
);
}
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { useCallback, useRef } from 'react';
import ReactFlow, { Controls, MiniMap, useReactFlow, useStoreApi } from 'reactflow';
import 'reactflow/dist/style.css';
import '../graph.css';
import useStore from '../store';
import useCopyPaste from '../useCopyPaste';
import CustomNode from './CustomNode';
import GroupNode from './GroupNode';
import PlaceholderNode from './PlaceholderNode';
import RegulationNode from './RegulationNode';
import RootNode from './RootNode';
import useAnimatedNodes from './useAnimatedNodes';
import useExpandCollapse from './useExpandCollapse';
const nodeTypes = {
groupNode: GroupNode,
custom: CustomNode,
regulationNode: RegulationNode,
rootNode: RootNode,
placeholder: PlaceholderNode,
};
const EDGE_COLOR = '#1677ff';
export default function DndFlow() {
const {
nodes,
aggregateNodes,
edges,
aggregateNodeEdges,
onNodeClick,
onEdgesChange,
onNodesDelete,
onNodeDoubleClick,
onNodeDrag,
onNodeDragStart,
onNodeDragStop,
addNode,
addEdge,
updateNode,
handleClick,
draggingItem,
removeNode,
setEdges,
setNodes,
setAggregateNodes,
setAggregateNodeEdges,
setLayoutedElements,
setFlowRef,
} = useStore((state) => ({
...state,
}));
const { copy, paste, bufferedNode } = useCopyPaste();
const store = useStoreApi();
const reactFlowWrapper = useRef(null);
const { screenToFlowPosition } = useReactFlow();
const draggingInfo = useRef(null);
const { nodes: visibleNodes, edges: visibleEdges } = useExpandCollapse(
aggregateNodes || nodes,
aggregateNodeEdges || edges,
{ treeWidth: 320, treeHeight: 30 },
);
const { nodes: animatedNodes } = useAnimatedNodes(visibleNodes, {
animationDuration: 0,
});
const virtualNode = useRef(null);
const onDragOver = useCallback(
(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
if (
draggingInfo.current?.x === position.x &&
draggingInfo.current?.y === position.y
) {
return;
}
draggingInfo.current = {
x: position.x,
y: position.y,
};
const { nodeInternals } = store.getState();
const currentNodes = Array.from(nodeInternals.values());
const targetNode = currentNodes.find(
(node) =>
position.x < node.position.x + 550 &&
position.x > node.position.x + 250 &&
position.y < node.position.y + 50 &&
position.y > node.position.y - 50,
);
if (targetNode) {
if (virtualNode.current?.data?.parent === targetNode.id) return;
if (!nodes.some((node) => node.data.parent == targetNode.id)) {
targetNode.data.expanded = true;
updateNode(targetNode.id, {
expanded: true,
});
}
if (!targetNode.data.expanded) return;
if (virtualNode.current) {
removeNode(virtualNode.current.id);
setLayoutedElements();
virtualNode.current = null;
}
const newNodeId = nanoid(6);
const newNode = {
id: newNodeId,
type: 'placeholder',
draggable: true,
connectable: true,
position: { x: position.x, y: position.y },
data: {
id: newNodeId,
parent: targetNode.id,
type: 'regulationNode',
title: draggingItem.title,
related_regulation: draggingItem.metadata?.related_revise
? null
: draggingItem._id,
related_revise_regulation:
draggingItem.metadata?.related_revision?._id ||
(draggingItem.metadata.type == '新增' ? draggingItem._id : null),
related_regulation_type: draggingItem.metadata.related_regulation_type || null,
content: draggingItem.content,
},
};
virtualNode.current = newNode;
addNode(virtualNode.current);
const edge = {
id: nanoid(6),
source: targetNode.id,
target: newNodeId,
animated: true,
style: {
stroke: EDGE_COLOR,
},
};
addEdge(edge);
} else {
if (virtualNode.current) {
removeNode(virtualNode.current.id);
virtualNode.current = null;
}
}
},
[draggingItem],
);
const onDrop = (event) => {
const currentEdges = _.cloneDeep(edges);
const currentAggregateNodeEdges = _.cloneDeep(aggregateNodeEdges);
setNodes(
nodes.map((node) =>
node.type === 'placeholder' ? { ...node, type: 'regulationNode' } : node,
),
);
setAggregateNodes(
aggregateNodes
? aggregateNodes.map((node) =>
node.type === 'placeholder' ? { ...node, type: 'regulationNode' } : node,
)
: null,
);
// todo
if (virtualNode.current) {
const virtualNodeEdge =
currentEdges?.find((edge) => edge.target === virtualNode.current.id) || null;
if (virtualNodeEdge) {
virtualNodeEdge.animated = false;
virtualNodeEdge.style = {
stroke: '#666',
};
setEdges(currentEdges);
}
const virtualAggregateNodeEdge =
currentAggregateNodeEdges?.find((edge) => edge.target === virtualNode.current.id) ||
null;
if (virtualAggregateNodeEdge) {
virtualAggregateNodeEdge.animated = false;
virtualAggregateNodeEdge.style = {
stroke: '#666',
};
setAggregateNodeEdges(currentAggregateNodeEdges);
}
virtualNode.current = null;
}
};
return (
<div style={{ width: '100%', height: '100%' }}>
<div ref={reactFlowWrapper} style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={animatedNodes}
edges={visibleEdges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
onNodesDelete={onNodesDelete}
onEdgesChange={onEdgesChange}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeDoubleClick={onNodeDoubleClick}
onClick={handleClick}
fitView
proOptions={{
hideAttribution: true,
}}
onInit={(instance) => setFlowRef(instance)}
>
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</div>
</div>
);
}
import { Back, ChevronRight, Close } from '@/components/AppIcons';
import { Button, Popover } from '@heroui/react';
import HeroCheckbox from '@/components/HeroCheckbox';
import _ from 'lodash';
import { useMemo, useState } from 'react';
/**
* 多级联动的多选筛选器,替代 streams-admin 中的 antd Cascader(multiple)。
* 选中值 value 为路径数组(每条路径是从根到所选节点的 id 数组),与 store 的 cascaderValue 结构一致。
*/
export default function GraphCascadeFilter({ options, value, onChange, placeholder = '筛选制度' }) {
const [open, setOpen] = useState(false);
const [activePath, setActivePath] = useState([]);
const flatMap = useMemo(() => {
const map = {};
const walk = (nodes, path) => {
nodes.forEach((node) => {
const nodePath = [...path, node.value];
map[node.value] = { node, path: nodePath };
if (node.children?.length) walk(node.children, nodePath);
});
};
walk(options || [], []);
return map;
}, [options]);
const columns = useMemo(() => {
const cols = [options || []];
let current = options || [];
for (const id of activePath) {
const next = current.find((n) => n.value === id);
if (!next?.children?.length) break;
cols.push(next.children);
current = next.children;
}
return cols;
}, [options, activePath]);
const isChecked = (nodeValue) =>
(value || []).some((path) => _.isEqual(path, flatMap[nodeValue]?.path));
const toggle = (nodeValue) => {
const path = flatMap[nodeValue]?.path;
if (!path) return;
const next = isChecked(nodeValue)
? (value || []).filter((p) => !_.isEqual(p, path))
: [...(value || []), path];
onChange(next);
};
const selectedCount = (value || []).length;
return (
<Popover isOpen={open} onOpenChange={(next) => { setOpen(next); if (!next) setActivePath([]); }}>
<Popover.Trigger>
<Button variant="secondary" className="graph-cascade-trigger">
<span className="graph-cascade-label">{selectedCount ? `已选 ${selectedCount} 项` : placeholder}</span>
<Close
size={12}
className="graph-cascade-clear"
onClick={(e) => {
e.stopPropagation();
onChange([]);
}}
/>
</Button>
</Popover.Trigger>
<Popover.Content placement="bottom start">
<div className="graph-cascade-panel">
<div className="graph-cascade-head">
<span className="graph-cascade-title">{placeholder}</span>
<Button
size="sm"
variant="tertiary"
onPress={() => {
onChange([]);
setOpen(false);
}}
>
清空
</Button>
</div>
<div
className="graph-cascade-columns"
style={{ width: `${Math.min(columns.length, 3) * 15}rem` }}
>
{columns.map((column, depth) => (
<div className="graph-cascade-column" key={depth}>
{depth > 0 ? (
<div className="graph-cascade-column-head">
<Button
isIconOnly
size="sm"
variant="tertiary"
aria-label="返回上一级"
onPress={() => setActivePath((current) => current.slice(0, depth - 1))}
>
<Back size={14} />
</Button>
<span className="graph-cascade-column-title" title={flatMap[activePath[depth - 1]]?.node?.label}>
{flatMap[activePath[depth - 1]]?.node?.label}
</span>
</div>
) : null}
<div className="graph-cascade-list">
{column.length ? (
column.map((node) => {
const active = activePath[depth] === node.value;
const hasChildren = node.children?.length > 0;
return (
<div
key={node.value}
className={`graph-cascade-option ${active ? 'active' : ''}`}
>
<HeroCheckbox
isSelected={isChecked(node.value)}
aria-label={node.label}
onChange={() => toggle(node.value)}
>
<span className="graph-cascade-option-label" title={node.label}>
{node.label}
</span>
</HeroCheckbox>
{hasChildren ? (
<Button
isIconOnly
size="sm"
variant="tertiary"
aria-label="展开下一级"
onPress={() => {
setActivePath((current) => [...current.slice(0, depth), node.value]);
}}
>
<ChevronRight size={14} />
</Button>
) : null}
</div>
);
})
) : (
<div className="graph-cascade-empty">暂无子节点</div>
)}
</div>
</div>
))}
</div>
</div>
</Popover.Content>
</Popover>
);
}
import { Menu, Popover } from '@heroui/react';
/**
* 右键上下文菜单:使用 HeroUI Popover + Menu 实现,锚定在触发节点上。
* 仅通过 onContextMenu 打开,左键点击不会触发打开。
*/
export default function GraphContextMenu({ isOpen, onOpenChange, items, onAction, children }) {
return (
<Popover isOpen={isOpen} onOpenChange={(open) => { if (!open) onOpenChange(false); }}>
<Popover.Trigger>{children}</Popover.Trigger>
<Popover.Content placement="bottom start">
<Menu aria-label="节点操作" onAction={onAction}>
{items.map((item) => (
<Menu.Item key={item.key} id={item.key} textValue={item.label}>
{item.label}
</Menu.Item>
))}
</Menu>
</Popover.Content>
</Popover>
);
}
import { Back, Close } from '@/components/AppIcons';
import { Button, Tooltip } from '@heroui/react';
import { QuestionCircleFilled } from '@ant-design/icons';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import useStore from '../store';
import GraphContextMenu from './GraphContextMenu';
export default function GroupNode({ data }) {
const { nodes, onNodeExpand, addNode, addEdge, aggregateNodes, setLayoutedElements } = useStore(
(state) => ({
...state,
}),
);
const { selected, opacity, expanded, removed = false } = data;
const className = `groupNode ${selected ? 'selected' : ''} ${removed ? 'removed' : ''}`;
const [childNodes, setChildNodes] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const currentNodes = aggregateNodes || nodes;
const childNodes = currentNodes.filter((node) => node.data.parent === data.id);
setChildNodes(childNodes);
}, [nodes, aggregateNodes]);
const expandedButton = useMemo(() => {
if (childNodes.length === 0) return;
return expanded ? (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label="折叠子节点"
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
<Back size={12} />
</Button>
) : (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label={`展开 ${childNodes.length} 个子节点`}
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
{childNodes.length}
</Button>
);
}, [childNodes, expanded]);
const handleAddGroupNode = () => {
const nodeId = nanoid(6);
addNode({
type: 'groupNode',
id: nodeId,
data: {
title: '新节点',
parent: data.id,
content: null,
id: nodeId,
related_regulation: null,
related_regulation_type: null,
expanded: false,
},
});
addEdge({
source: data.id,
target: nodeId,
style: {
stroke: '#666',
},
});
setLayoutedElements();
};
return (
<GraphContextMenu
isOpen={menuOpen}
onOpenChange={setMenuOpen}
items={[{ key: 'addGroupNode', label: '添加节点' }]}
onAction={(key) => {
if (key === 'addGroupNode') handleAddGroupNode();
setMenuOpen(false);
}}
>
<div
className={className}
style={{
padding: 10,
borderRadius: 5,
width: 240,
height: 36,
position: 'relative',
opacity: removed ? 0.5 : opacity,
}}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
<Tooltip delay={0} isDisabled={!data.content}>
<div className="graph-node-title">
{removed ? <Close size={12} style={{ color: 'red', marginRight: 4 }} /> : null}
{data.title}
</div>
<Tooltip.Content>
<div>
<p style={{ fontWeight: 'bold' }}>{data.title}</p>
{data.content ? <p>{data.content}</p> : null}
</div>
</Tooltip.Content>
</Tooltip>
<div>
{data.content ? (
<span className="graph-node-hint">
<QuestionCircleFilled />
</span>
) : null}
</div>
{expandedButton}
<Handle type="source" position="right" style={{ opacity: 0 }} />
</div>
</GraphContextMenu>
);
}
import { Handle } from 'reactflow';
export default function PlaceholderNode() {
return (
<div
style={{
borderRadius: 5,
width: 80,
height: 20,
background: '#e6f4ff',
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
</div>
);
}
import { Back, Close } from '@/components/AppIcons';
import { QuestionCircleFilled } from '@ant-design/icons';
import { Button, Textarea, Tooltip } from '@heroui/react';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import { APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import XinyuanModal from '@/components/XinyuanModal';
import useStore from '../store';
import GraphContextMenu from './GraphContextMenu';
export default function RegulationNode({ data }) {
const {
nodes,
onNodeExpand,
addEdge,
addNode,
showCurrentRegulationNode,
aggregateNodes,
updateNode,
setLayoutedElements,
} = useStore((state) => ({
...state,
}));
const {
selected,
related_regulation_type,
opacity,
expanded,
highlighted,
removed = false,
} = data;
const [showModal, setShowModal] = useState(false);
const [childNodes, setChildNodes] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const className = useMemo(() => {
return `regulation ${
showCurrentRegulationNode ? related_regulation_type : 'active'
} ${selected ? 'selected' : ''} ${removed ? 'removed' : ''}`;
}, [showCurrentRegulationNode, related_regulation_type, selected, removed]);
useEffect(() => {
const currentNodes = aggregateNodes || nodes;
const childNodes = currentNodes.filter((node) => node.data.parent === data.id);
setChildNodes(childNodes);
}, [nodes, aggregateNodes]);
const expandedButton = useMemo(() => {
if (childNodes.length === 0) return;
return expanded ? (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label="折叠子节点"
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
<Back size={12} />
</Button>
) : (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label={`展开 ${childNodes.length} 个子节点`}
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
{childNodes.length}
</Button>
);
}, [childNodes, expanded]);
const handleAddGroupNode = () => {
const nodeId = nanoid(6);
addNode({
type: 'groupNode',
id: nodeId,
data: {
title: '新节点',
parent: data.id,
content: null,
id: nodeId,
related_regulation: null,
related_regulation_type: null,
},
});
addEdge({
source: data.id,
target: nodeId,
style: {
stroke: '#666',
},
});
setLayoutedElements();
};
const handleViewDetails = () => {
const { related_regulation } = data;
if (related_regulation) {
window.open(`/${APP_ROUTE_NAME}/internal_regulation/${related_regulation}`, '_blank');
}
};
return (
<>
<GraphContextMenu
isOpen={menuOpen}
onOpenChange={setMenuOpen}
items={[
{ key: 'addGroupNode', label: '添加节点' },
{ key: 'addDescription', label: '添加描述' },
{ key: 'viewDetails', label: '查看详情' },
]}
onAction={(key) => {
if (key === 'addGroupNode') handleAddGroupNode();
if (key === 'addDescription') setShowModal(true);
if (key === 'viewDetails') handleViewDetails();
setMenuOpen(false);
}}
>
<div
className={className}
style={{
padding: 10,
borderRadius: 5,
opacity: opacity,
position: 'relative',
backgroundColor: highlighted ? 'yellow' : 'white',
}}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
<Tooltip delay={0} isDisabled={!data.content}>
<div className="graph-node-title">
{removed ? <Close size={12} style={{ color: 'red', marginRight: 4 }} /> : null}
{data.title}
</div>
<Tooltip.Content>
<div>
<p style={{ fontWeight: 'bold' }}>{data.title}</p>
{data.content ? <p>{data.content}</p> : null}
</div>
</Tooltip.Content>
</Tooltip>
<div>
{data.content ? (
<span className="graph-node-hint">
<QuestionCircleFilled />
</span>
) : null}
</div>
{expandedButton}
<Handle type="source" position="right" style={{ opacity: 0 }} />
</div>
</GraphContextMenu>
<DescriptionModal
isOpen={showModal}
onOpenChange={setShowModal}
initialValue={data.content || ''}
onSubmit={(value) => {
updateNode(data.id, { content: value });
setShowModal(false);
}}
/>
</>
);
}
function DescriptionModal({ initialValue, isOpen, onOpenChange, onSubmit }) {
const [value, setValue] = useState(initialValue || '');
useEffect(() => {
if (isOpen) setValue(initialValue || '');
}, [isOpen, initialValue]);
return (
<XinyuanModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title="节点描述"
footer={
<>
<Button variant="secondary" onPress={() => onOpenChange(false)}>
取消
</Button>
<Button
onPress={() => {
onSubmit(value);
}}
>
保存
</Button>
</>
}
>
<Textarea
aria-label="节点描述"
label="节点描述"
placeholder="请输入节点描述信息"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</XinyuanModal>
);
}
import { Back } from '@/components/AppIcons';
import { Button } from '@heroui/react';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { Handle } from 'reactflow';
import 'reactflow/dist/style.css';
import useStore from '../store';
import GraphContextMenu from './GraphContextMenu';
export default function RootNode({ data }) {
const { selected, opacity, expanded } = data;
const className = `rootNode ${selected ? 'selected' : ''}`;
const { nodes, onNodeExpand, addNode, addEdge, aggregateNodes, setLayoutedElements } = useStore(
(state) => ({
...state,
}),
);
const [childNodes, setChildNodes] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const currentNodes = aggregateNodes || nodes;
const childNodes = currentNodes.filter((node) => node.data.parent === data.id);
setChildNodes(childNodes);
}, [aggregateNodes, nodes]);
const expandedButton = useMemo(() => {
if (childNodes.length === 0) return;
return expanded ? (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label="折叠子节点"
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
<Back size={12} />
</Button>
) : (
<Button
isIconOnly
size="sm"
variant="secondary"
className="graph-node-count"
aria-label={`展开 ${childNodes.length} 个子节点`}
onPress={(e) => {
e?.stopPropagation?.();
onNodeExpand(data.id);
}}
>
{childNodes.length}
</Button>
);
}, [childNodes, expanded]);
const handleAddGroupNode = () => {
const nodeId = nanoid(6);
addNode({
type: 'groupNode',
id: nodeId,
data: {
title: '新节点',
parent: data.id,
content: null,
id: nodeId,
related_regulation: null,
related_regulation_type: null,
},
});
addEdge({
source: data.id,
target: nodeId,
style: {
stroke: '#666',
},
});
setLayoutedElements();
};
return (
<GraphContextMenu
isOpen={menuOpen}
onOpenChange={setMenuOpen}
items={[{ key: 'addGroupNode', label: '添加节点' }]}
onAction={(key) => {
if (key === 'addGroupNode') handleAddGroupNode();
setMenuOpen(false);
}}
>
<div
className={className}
style={{ opacity }}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
>
<Handle type="target" position="left" style={{ opacity: 0 }} />
<div className="graph-node-title" style={{ width: '220px' }}>
{data.title}
</div>
{expandedButton}
<Handle type="source" position="right" style={{ opacity: 0 }} />
</div>
</GraphContextMenu>
);
}
import { timer } from 'd3-timer';
import { useEffect, useState } from 'react';
import { useReactFlow } from 'reactflow';
import 'reactflow/dist/style.css';
const useAnimatedNodes = (nodes, { animationDuration = 300 } = {}) => {
const [tmpNodes, setTmpNodes] = useState(nodes);
const { getNode } = useReactFlow();
useEffect(() => {
const transitions = nodes.map((node) => ({
id: node.id,
from: getNode(node.id)?.position ?? node.position,
to: node.position,
node,
}));
const t = timer((elapsed) => {
const s = elapsed / animationDuration;
const currNodes = transitions.map(({ node, from, to }) => {
return {
...node,
position: {
x: from.x + (to.x - from.x) * s,
y: from.y + (to.y - from.y) * s,
},
};
});
setTmpNodes(currNodes);
if (elapsed > animationDuration) {
setTmpNodes(nodes);
t.stop();
}
});
return () => t.stop();
}, [nodes, getNode, animationDuration]);
return { nodes: tmpNodes };
};
export default useAnimatedNodes;
import { useMemo } from 'react';
import dagre from 'dagre';
import 'reactflow/dist/style.css';
const filterCollapsedChildren = (dagreGraph, node) => {
const children = dagreGraph.successors(node.id);
if (!node.data.expanded) {
while (children?.length) {
const child = children.pop();
children.push(...dagreGraph.successors(child));
dagreGraph.removeNode(child);
}
}
};
const useExpandCollapse = (
nodes,
edges,
{ layoutNodes = true, treeWidth = 200, treeHeight = 30 } = {},
) => {
return useMemo(() => {
if (!layoutNodes) return { nodes, edges };
const dagreGraph = new dagre.graphlib.Graph()
.setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: 'LR' });
for (const node of nodes) {
dagreGraph.setNode(node.id, {
width: treeWidth,
height: treeHeight,
data: node.data,
});
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
for (const node of nodes) {
filterCollapsedChildren(dagreGraph, node);
}
dagre.layout(dagreGraph);
return {
nodes: nodes.flatMap((node) => {
if (!dagreGraph.hasNode(node.id)) return [];
const { x, y } = dagreGraph.node(node.id);
return [
{
...node,
position: { x, y },
data: { ...node.data },
},
];
}),
edges,
};
}, [nodes, edges, layoutNodes, treeWidth, treeHeight]);
};
export default useExpandCollapse;
.graph-page {
position: relative;
display: flex;
width: 100%;
min-width: 0;
height: calc(100vh - 4rem - var(--rc-page-padding-block-start, 0px) - var(--rc-page-padding-block-end, 0px));
margin-block-start: calc(-1 * var(--rc-page-padding-block-start, 0px));
margin-block-end: calc(-1 * var(--rc-page-padding-block-end, 0px));
overflow: hidden;
}
.graph-layout {
position: relative;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
gap: 0;
}
.graph-side {
position: relative;
height: 100%;
flex: none;
border-right: 1px solid var(--rc-color-border, #e8e8e8);
background-color: var(--rc-color-surface, #fff);
}
.graph-side-left {
width: 320px;
}
.graph-side-right {
width: 280px;
border-right: none;
border-left: 1px solid var(--rc-color-border, #e8e8e8);
}
.graph-canvas {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
}
.graph-toggle-btn {
position: absolute;
top: 48px;
z-index: 10;
}
.graph-list {
height: calc(100% - 60px);
overflow: hidden auto;
}
.graph-toolbar {
position: absolute;
inset-block-start: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 60px;
padding: 0 12px;
background-color: var(--rc-color-surface, #fff);
}
.graph-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 12px;
cursor: grab;
border-block-end: 1px solid var(--rc-color-border, #f0f0f0);
}
.graph-list-item:hover {
background-color: var(--rc-color-surface-hover, #f5f5f5);
}
.graph-list-item-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 240px;
}
.groupNode {
transition: 0.25s all;
display: flex;
justify-content: center;
align-items: center;
border: none;
background-color: #ececec;
border: 2px solid transparent;
}
.groupNode.selected {
border-color: #ccc;
}
.regulation {
display: flex;
justify-content: center;
align-items: center;
border: 2px solid grey;
background-color: #fff;
width: 240px;
height: 36px;
}
.regulation.selected {
outline: 4px solid rgba(128, 128, 128, 0.2);
}
.regulation.removed {
opacity: 0.5;
}
.addition {
border: 2px solid #065de7;
}
.addition.selected {
outline: 4px solid rgba(6, 93, 231, 0.2);
}
.revise {
border: 2px solid #dcac04;
}
.revise.selected {
outline: 4px solid rgba(220, 172, 4, 0.2);
}
.forbidden {
border: 2px solid #da241a;
}
.forbidden.selected {
outline: 4px solid rgba(218, 36, 26, 0.2);
}
.rootNode {
transition: 0.25s all;
display: flex;
justify-content: center;
align-items: center;
border: none;
font-size: large;
padding: 5px;
font-weight: bold;
border-radius: 5px;
background-color: #ececec;
border: 2px solid transparent;
}
.rootNode.selected {
border-color: #ccc;
}
.graph-node-wrapper {
position: relative;
}
.graph-node-count {
position: absolute;
top: 6px;
right: -10px;
z-index: 1;
}
.graph-node-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 210px;
}
.graph-node-hint {
position: absolute;
right: 0;
padding-right: 7px;
opacity: 0.3;
}
.collapse-icon {
display: none;
position: absolute;
right: 0px;
top: 6px;
cursor: pointer;
}
.graph-status-chip {
flex: none;
padding: 1px 8px;
border-radius: 9999px;
font-size: 12px;
line-height: 20px;
white-space: nowrap;
border: 1px solid transparent;
}
.graph-status-chip-default {
color: #595959;
background-color: #fafafa;
border-color: #d9d9d9;
}
.graph-status-chip-primary {
color: #1668dc;
background-color: #e6f4ff;
border-color: #91caff;
}
.graph-status-chip-warning {
color: #d48806;
background-color: #fffbe6;
border-color: #ffe58f;
}
.graph-status-chip-danger {
color: #d9363e;
background-color: #fff1f0;
border-color: #ffa39e;
}
.graph-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.graph-tabs-panel {
flex: 1;
min-height: 0;
overflow: hidden auto;
}
.graph-detail-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.graph-timeline-item {
position: relative;
padding-block-end: 12px;
padding-inline-start: 18px;
}
.graph-timeline-item::before {
content: '';
position: absolute;
inset-inline-start: 4px;
top: 6px;
bottom: 0;
width: 2px;
background-color: var(--rc-color-border, #d9d9d9);
}
.graph-timeline-item::after {
content: '';
position: absolute;
inset-inline-start: 0;
top: 5px;
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: var(--rc-color-primary, #1677ff);
}
.graph-timeline-item:last-child::before {
display: none;
}
.graph-cascade-trigger {
position: relative;
min-width: 10rem;
}
.graph-cascade-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.graph-cascade-clear {
position: absolute;
inset-inline-end: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.graph-cascade-trigger:hover .graph-cascade-clear {
opacity: 1;
pointer-events: auto;
}
.graph-cascade-panel {
display: flex;
flex-direction: column;
min-width: 15rem;
}
.graph-cascade-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-block-end: 1px solid var(--rc-color-border, #e8e8e8);
}
.graph-cascade-title {
font-weight: 600;
}
.graph-cascade-columns {
display: flex;
min-height: 240px;
max-height: 320px;
overflow: hidden auto;
}
.graph-cascade-column {
display: flex;
flex-direction: column;
min-width: 15rem;
max-height: 320px;
border-inline-end: 1px solid var(--rc-color-border, #f0f0f0);
}
.graph-cascade-column-head {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-block-end: 1px solid var(--rc-color-border, #f0f0f0);
}
.graph-cascade-column-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
color: var(--rc-color-muted, #8c8c8c);
}
.graph-cascade-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.graph-cascade-option {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
}
.graph-cascade-option.active {
background-color: var(--rc-color-surface-hover, #f5f5f5);
}
.graph-cascade-option-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 9rem;
}
.graph-cascade-empty {
padding: 12px;
font-size: 12px;
color: var(--rc-color-muted, #8c8c8c);
}
.graph-archive-trigger {
min-width: 11rem;
justify-content: space-between;
}
import { utils, writeFile } from 'xlsx';
function replaceNullWithEmptyString(data) {
return data.map((row) => {
const newRow = {};
Object.keys(row).forEach((key) => {
newRow[key] = row[key] === null || row[key] === undefined ? '' : row[key];
});
return newRow;
});
}
export function saveRegulationSheet(data, filename = '制度列表.xlsx') {
const rows = replaceNullWithEmptyString(data || []);
const worksheet = utils.json_to_sheet(rows);
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, '制度列表');
writeFile(workbook, filename);
}
This diff is collapsed.
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useKeyPress, useStore as useReactFlowStore } from 'reactflow';
import graphStore from './store';
export function useCopyPaste() {
const { nodes, aggregateNodes, addNode, addEdge, selectedNode, findDescendants } = graphStore(
(state) => ({
...state,
}),
);
const mousePosRef = useRef({ x: 0, y: 0 });
const rfDomNode = useReactFlowStore((state) => state.domNode);
const [bufferedNode, setBufferedNode] = useState(null);
// Set up the paste buffers to store the copied nodes and edges.
// initialize the copy/paste hook
// 1. remove native copy/paste/cut handlers
// 2. add mouse move handler to keep track of the current mouse position
useEffect(() => {
const events = ['copy', 'paste'];
if (rfDomNode) {
const preventDefault = (e) => e.preventDefault();
const onMouseMove = (event) => {
mousePosRef.current = {
x: event.clientX,
y: event.clientY,
};
};
for (const event of events) {
rfDomNode.addEventListener(event, preventDefault);
}
rfDomNode.addEventListener('mousemove', onMouseMove);
return () => {
for (const event of events) {
rfDomNode.removeEventListener(event, preventDefault);
}
rfDomNode.removeEventListener('mousemove', onMouseMove);
};
}
}, [rfDomNode]);
const copy = useCallback(() => {
setBufferedNode(selectedNode);
}, [selectedNode]);
const paste = useCallback(() => {
if (!bufferedNode || !selectedNode) return;
const nodeId = nanoid(6);
const childNodes = findDescendants(bufferedNode, nodes);
let newNodes = childNodes
.map((node) => {
return {
id: `${node.id}_${nodeId}`,
type: node.type,
draggable: true,
connectable: true,
position: {
x: 0,
y: 0,
},
data: {
id: `${node.id}_${nodeId}`,
parent: `${node.data.parent}_${nodeId}`,
type: node.type,
title: node.data.title,
related_regulation: node.data.related_regulation,
related_revise_regulation: node.data.related_revise_regulation,
related_regulation_type: node.data.related_regulation_type,
content: node.data.content,
},
};
})
.reverse();
newNodes = _.uniqBy(newNodes, 'id');
const parentNode = newNodes.find((node) => node.data.id.includes(bufferedNode.data.id));
parentNode.data.parent = selectedNode.id;
for (const node of newNodes) {
addNode(node);
addEdge({
source: node.data.parent,
target: node.id,
style: {
stroke: '#666',
},
});
}
}, [bufferedNode, selectedNode]);
useShortcut(['Meta+c', 'Control+c'], copy);
useShortcut(['Meta+v', 'Control+v'], paste);
return { copy, paste, bufferedNode, nodes, aggregateNodes };
}
function useShortcut(keyCode, callback) {
const [didRun, setDidRun] = useState(false);
const shouldRun = useKeyPress(keyCode);
useEffect(() => {
if (shouldRun && !didRun) {
callback();
setDidRun(true);
} else {
setDidRun(shouldRun);
}
}, [shouldRun, didRun, callback]);
}
export default useCopyPaste;
......@@ -370,7 +370,7 @@ function RecordActions({ className, record, subjectName, onEdit, onRemove, inclu
<TableActionButton icon={Eye} label="查看" onPress={() => openRecordDetail(subjectName, record)} />
<TableActionButton icon={ArrowDownTrayIcon} isDisabled={!firstFile} label="下载" onPress={() => openFile(firstFile)} />
<TableActionButton icon={Edit} label="编辑" onPress={() => onEdit(record)} />
{includeGraph ? <TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph?subject=${subjectName}&node=${record?._id || record?.name}`)} /> : null}
{includeGraph ? <TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph/${encodeURIComponent(subjectName)}?node=${encodeURIComponent(record?._id || record?.name)}`)} /> : null}
{includeDelete ? <TableActionButton icon={Delete} label="删除" tone="delete" onPress={() => onRemove(record)} /> : null}
</TableActionGroup>
);
......@@ -960,7 +960,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr
<TableActionGroup className="justify-center">
<TableActionButton icon={ArrowDownTrayIcon} isDisabled={!firstFile} label="下载" onPress={() => openFile(firstFile)} />
<TableActionButton icon={Edit} label="编辑" onPress={() => onEdit(record)} />
<TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph?subject=${subject.name}&node=${record?._id || record?.name}`)} />
<TableActionButton icon={OpenExternal} label="关系图" onPress={() => history.push(`/graph/${encodeURIComponent(subject.name)}?node=${encodeURIComponent(record?._id || record?.name)}`)} />
</TableActionGroup>
</div>
) : null}
......
import { useContext, useEffect, useMemo, useState } from 'react';
import { history, useLocation, useParams } from '@umijs/max';
import { Button, Spinner } from '@heroui/react';
import { ArrowPathIcon } from '@/components/AppIcons';
import HeroCheckbox from '@/components/HeroCheckbox';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import RecordFlowGraph from '@/components/RecordFlowGraph/RecordFlowGraph';
import { loadRecordGraph } from '@/services/RecordGraphService';
import { ApplicationContext } from './LawWorkbenchLayout';
export default function RecordGraphPage() {
const params = useParams();
const location = useLocation();
const query = new URLSearchParams(location.search);
const subjectName = params.subject || query.get('subject');
const application = useContext(ApplicationContext);
const subjectConfig = application?.layouts?.default?.subjects?.[subjectName];
const groupsKey = JSON.stringify(subjectConfig?.tag?.group?.record || []);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [operationError, setOperationError] = useState('');
const [success, setSuccess] = useState('');
const [centered, setCentered] = useState(false);
const [refresh, setRefresh] = useState(0);
const filterConfig = { _id: query.get('node'), department: query.get('dep'), tag: query.get('tag') };
useEffect(() => {
let active = true;
if (!subjectName) { setLoading(false); setError('未指定关系图的数据对象,请从制度卡片进入'); return; }
setLoading(true); setError('');
loadRecordGraph(subjectName, JSON.parse(groupsKey)).then(result => { if (active) setData(result); })
.catch(err => { if (active) setError(err.message || '关系图加载失败'); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [subjectName, groupsKey, refresh, filterConfig.department, filterConfig.tag, centered]);
const levels = useMemo(() => data?.subject?.fields?.find(field => field.name === 'level')?.settings?.options?.map(option => option.value) || [], [data]);
const list = useMemo(() => (data?.records || []).filter(record =>
(!filterConfig.department || record.metadata?.departments?.includes(filterConfig.department)) &&
(!filterConfig.tag || record.tags?.some(tag => (tag?._id || tag) === filterConfig.tag)),
), [data, filterConfig.department, filterConfig.tag]);
const changeFilter = (field, value) => {
const next = new URLSearchParams();
const values = { tag: filterConfig.tag, dep: filterConfig.department, [field]: value };
Object.entries(values).forEach(([key, item]) => { if (item) next.set(key, item); });
setSuccess(''); setOperationError('');
history.push(`/graph/${encodeURIComponent(subjectName)}${next.size ? `?${next}` : ''}`);
};
return <section className="record-graph-page" aria-label="制度关系图">
<div className="record-graph-filter">
<XinyuanAutocomplete className="w-[220px]" aria-label="标签筛选" placeholder="请选择标签" showSearch={false}
selectedKey={filterConfig.tag} options={(data?.tags || []).map(tag => ({ value: tag._id, label: tag.label }))}
onSelectionChange={value => changeFilter('tag', value)} onClear={() => changeFilter('tag', null)} />
<XinyuanAutocomplete className="w-[220px]" aria-label="部门筛选" placeholder="请选择部门"
selectedKey={filterConfig.department} options={(data?.departments || []).map(value => ({ value, label: value }))}
onSelectionChange={value => changeFilter('dep', value)} onClear={() => changeFilter('dep', null)} />
<HeroCheckbox isSelected={centered} onChange={setCentered}>居中</HeroCheckbox>
<Button isIconOnly variant="secondary" aria-label="刷新关系图" isDisabled={loading} onPress={() => { setRefresh(value => value + 1); setOperationError(''); setSuccess(''); }}><ArrowPathIcon size={16} /></Button>
</div>
{(error || operationError) && <div role="alert" className="flex items-center justify-center gap-3 p-3 text-danger">{error || operationError}<Button size="sm" variant="secondary" onPress={() => setRefresh(value => value + 1)}>重试</Button></div>}
{success && !operationError && <p role="status" className="px-4 py-2 text-sm text-success">{success}</p>}
<div className="relative min-h-0 flex-1">
{loading ? <div className="absolute inset-0 flex items-center justify-center gap-2" role="status"><Spinner />正在加载关系图…</div> : !error && data ? <>
<RecordFlowGraph key={`${subjectName}/${filterConfig._id}/${filterConfig.department}/${filterConfig.tag}/${centered}/${refresh}`}
list={list} levelArr={levels} subject={data.subject} centered={centered} filterConfig={filterConfig}
onError={setOperationError} onSuccess={setSuccess} onReload={() => setRefresh(value => value + 1)} />
{(!list.length || !levels.length) && <p className="pointer-events-none absolute inset-0 flex items-center justify-center text-muted">{!levels.length ? '当前对象未配置层级选项' : '没有符合筛选条件的制度'}</p>}
</> : null}
</div>
</section>;
}
......@@ -65,3 +65,16 @@
点击结果数量进入 `/:subject/:record`;`audit_indicator_list` 的记录详情或 `detail.view: audit_indicator_detail` 显示执行结果页面,提供标题搜索和分页。管理者可通过 Modal 查看执行数据、导出 Excel 和确认删除单条结果;结构化数组使用 DataGrid,非表格数据以格式化 JSON 展示。删除当前页最后一条时自动返回上一页。
验证命令:`node scripts/verify-view-parity.cjs` 校验配置范围、关闭开关、特殊字符编码、安全高亮、引用写入、表单校验和计算视图执行协议;`npm run build` 校验打包。浏览器使用隔离模拟数据验证六种视图及搜索、权限、指标表单,不向真实业务对象写入测试数据。
## 制度卡片关系图(2026-09-29)
入口是内部制度看板展开卡片后,下载、编辑右侧的“关系图”按钮。与 streams-admin 的 `RecordColumn` 一致进入 `/graph/:subject?node=<记录ID>`,页面对应 `RecordGraphView` 的迁移实现 `RecordGraphPage`;兼容已有 `/graph?subject=...&node=...` 地址。
- 读取当前 Subject 的 `level.settings.options`,按配置顺序排列层级标题和制度节点。同层按 `order` 升序;节点宽 240、高 60,布局横向步长 480、纵向步长 110,初始视口按容器宽度缩放。全部记录通过 `loadRecords(subject,0,99999,{},{})` 获取,不只查现行有效制度。
- 连线来源为 `metadata.related_regulations`,方向从来源制度指向被关联制度。筛选后缺失端点的线不渲染。保留 node 参数,但按 streams-admin 当前实际实现不以它裁剪记录或自动聚焦;单击节点高亮直接相邻制度及关联线,单击线高亮两端,单击画布清除高亮;双击制度新窗口打开详情。
- 顶部只有标签、部门、居中和刷新。标签读取配置 `tag.group.record` 对应组,部门聚合 `metadata.departments`。选择或清空筛选更新 URL 的 tag/dep 并重新读取数据,和来源一样清除 node 参数;条件在前端应用。居中使各列按最长列垂直居中,并重新读取记录。刷新重建图;无数据、缺少层级配置和加载失败均明确提示。
- 权限沿用来源 `useAuthorization().admin`:只有 type 为 admin/system 的用户可以拖动节点、创建或改接连线。普通用户可浏览、筛选、高亮和打开详情。新增连线更新来源制度的 `metadata.related_regulations` ID 数组;改接先移除旧关系再建立新关系;拖动线端后在空白处松开删除该关系。重复关系不重复创建。
- 同层拖动改变顺序时按来源算法生成 record name → order 映射,调用 `/record/reorder` 并恢复列内对齐;节点不跨层存储位置。连线/排序成功提示保存成功,失败显示错误并重新读取服务器状态。跨来源改接涉及两次更新,后一步失败时重新加载展示已成功的部分。
- 基础筛选控件使用 HeroUI Autocomplete/Checkbox/Button,节点和贝塞尔线保留 ReactFlow 的实现与交互,不引入其它 UI 框架。此入口没有 XMind 上传、归档版本或“保存画布”功能。
验证:`node scripts/verify-record-graph.cjs` 校验卡片入口、层级和排序、引用 ID、过滤端点、居中坐标及写入协议;浏览器使用隔离模拟数据验证页面和交互,不写入真实制度记录。
import { aggregateRecords, loadRecords, loadSubject, queryTags, updateRecord, reorderRecords } from './DataService';
export async function loadRecordGraph(subjectName, tagGroups) {
const [subject, result, departments, tags] = await Promise.all([
loadSubject(subjectName),
loadRecords(subjectName, 0, 99999, {}, {}),
aggregateRecords({ subject: subjectName, stages: [
{ $unwind: '$metadata.departments' }, { $group: { _id: '$metadata.departments' } },
] }),
queryTags({ page: 0, page_size: 999, groups: Array.isArray(tagGroups) && tagGroups.length ? tagGroups : undefined }),
]);
return { subject, records: result.records || [], departments: departments.map(item => item._id).filter(Boolean), tags: tags.tags || [] };
}
export function saveRecordRelations(subject, node, relatedRegulations) {
return updateRecord(node.id, {
name: node.name, subject: subject.name, title: node.title,
metadata: { ...node.metadata, related_regulations: relatedRegulations },
});
}
export function saveRecordGraphOrder(orderMap) {
return reorderRecords(orderMap);
}
......@@ -231,3 +231,14 @@
| `/tag/query` | POST | `{page:0,page_size:20,keyword}`,返回 `tags`。 |
指标责任人复用 `/record/query` 查询 `department`,分组/指标/结果创建、更新、软删除继续使用本文第 3 节的 Record 接口。指标结果 `execution_time` 保持原应用毫秒时间戳 Number;`related_indicator` 保存指标 `_id` 字符串。所有搜索 URL 参数均编码,避免特殊字符被当作查询参数分隔符。
## 7. 制度卡片关系图
来源为 streams-admin 的 `RecordColumn` → `RecordGraphView` → `RecordFlowGraph`。
- `GET /subject/:name`:读取 `level.settings.options`,按配置顺序生成列。
- `POST /record/query`:沿用 `loadRecords(subject, 0, 99999, {}, {})`,即 `{subject,page:0,page_size:99999,filter:{},sort:{},keyword:null,full:false,select:null}`。不附加“现行有效”限制。
- `POST /record/aggregate`:`{subject,stages:[{$unwind:'$metadata.departments'},{$group:{_id:'$metadata.departments'}}]}`,取得部门选项。
- `POST /tag/query`:`{page:0,page_size:999,groups}`,groups 取应用 Subject 配置 `tag.group.record`,无配置时省略。
- `PUT /record/:id`:连线增删/改接更新来源制度,发送 `{name,subject,title,metadata:{...metadata,related_regulations:ID[]}}`;引用值只发送 ID。
- `POST /record/reorder`:请求体直接为 `{[recordName]:order}`,同层拖动后按来源算法更新相关排序。
......@@ -206,3 +206,17 @@ type Record = {
| 廉洁谈话提纲上传 | `talk_outline_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传谈话提纲 |
| 廉洁提醒支持材料上传 | `reminder_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传提醒材料 |
| 其他活动举措 | `other_measures` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 其他举措说明 |
## 3.2 制度卡片关系图使用字段
本节补充 streams-admin 的 `RecordGraphView/RecordFlowGraph` 已实现协议,不创建或修改远程 Subject;层级选项以运行时 `GET /subject/:name` 为准。
| 字段 | 类型/含义 |
| --- | --- |
| `metadata.level` | 层级,列顺序与值来自 Subject 的 `level.settings.options` |
| `metadata.related_regulations` | 制度引用数组;读取可为含 `_id` 的引用对象,保存仅为 ID 字符串数组 |
| `metadata.departments` | 部门字符串数组,用于聚合选项及前端筛选 |
| `tags` | Record 标签引用数组,按 `_id` 筛选 |
| `_id`、`name`、`title`、`order` | 节点 ID、更新/排序键、显示标题、同层排序值 |
画布由制度记录实时生成,不使用 `internal_regulation_graph`、修订计划、XMind 或独立画布保存记录。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment