Commit d0b00c39 authored by drigle's avatar drigle

feat: 完善制度拓扑交互、保存与 XMind 导出

parent a6b49d2c
......@@ -124,8 +124,8 @@ export default defineConfig({
npmClient: 'npm',
proxy: {
'/api': {
// target: 'https://xy.streams7.com/api',
target: 'http://127.0.0.1:3000/',
target: 'https://xy.streams7.com/api',
// target: 'http://127.0.0.1:3000/',
changeOrigin: true,
pathRewrite: { '^/api': '' },
secure: false,
......
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);
});
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));
});
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');
});
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);
});
......@@ -4,9 +4,9 @@ import { ChevronRight, Close, Info } from '@/components/AppIcons';
const TYPE_LABELS = { rootNode: '根节点', groupNode: '组节点', regulationNode: '制度节点' };
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';
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" />
{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>
......
......@@ -59,13 +59,6 @@ export default function InternalRegulationManager() {
}, [activeTab, error, loading, options, subject]);
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.Content className="p-0">
<Tabs className="w-fit max-w-full" selectedKey={activeTab} variant="primary" onSelectionChange={changeTab}>
......@@ -78,6 +71,5 @@ export default function InternalRegulationManager() {
<div className="p-4 sm:p-6">{content}</div>
</Card.Content>
</Card>
</div>
);
}
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 JSZip from 'jszip';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
......@@ -7,6 +7,7 @@ import 'reactflow/dist/style.css';
import { history } from 'umi';
import {
ArrowPathIcon,
ChevronDown,
ChevronRight,
Download,
Edit,
......@@ -21,7 +22,11 @@ import {
import ProcessCascade from '@/components/ProcessCascade';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal';
import { attachGraphAncestors } from '@/services/regulationGraphData';
import { createGraphXmindArchive } from '@/services/regulationGraphXmind';
import GraphNode from './GraphNode';
import { getGraphRecordId, getNextGraphRecordNode, getSelectedGraphRecordId } from './graphSelection';
import { findGraphDropTarget, REGULATION_DRAG_TYPE } from './graphDrop';
import {
loadGraph,
loadGraphArchive,
......@@ -143,20 +148,7 @@ function enrichGraph(nextGraph, regulations, revisions) {
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 });
});
const byId = new Map(nodes.map((node) => [node.id, node]));
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 };
return { nodes: attachGraphAncestors(nodes), edges: graph.edges };
}
function parseXmindJson(value) {
......@@ -248,20 +240,26 @@ export default function RegulationGraph() {
const [showRemoved, setShowRemoved] = useState(true);
const [collapsedIds, setCollapsedIds] = useState(new Set());
const [selectedId, setSelectedId] = useState(null);
const [selectedRecordId, setSelectedRecordId] = useState(null);
const [leftOpen, setLeftOpen] = useState(true);
const [rightOpen, setRightOpen] = useState(true);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [contextMenu, setContextMenu] = useState(null);
const [nodeDialog, setNodeDialog] = useState(null);
const [nodeForm, setNodeForm] = useState({ title: '', content: '' });
const [draggedPositions, setDraggedPositions] = useState({});
const [dropPreview, setDropPreview] = useState(null);
const flowRef = useRef(null);
const focusRequestRef = useRef(0);
const canvasRef = useRef(null);
const importInputRef = useRef(null);
const applyGraph = useCallback((nextGraph) => {
focusRequestRef.current += 1;
const normalized = normalizeGraph(nextGraph);
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);
......@@ -271,8 +269,10 @@ export default function RegulationGraph() {
const positionedById = new Map(positionedVisible.map((node) => [node.id, node]));
setGraph({ nodes: normalized.nodes.map((node) => positionedById.get(node.id) || node), edges: edgeList });
setSelectedId((current) => normalized.nodes.some((node) => node.id === current) ? current : null);
setSelectedRecordId(null);
setCollapsedIds(defaultCollapsedIds(normalized.nodes));
setDraggedPositions({});
setDropPreview(null);
window.requestAnimationFrame(() => flowRef.current?.fitView?.({ padding: 0.22, duration: 240 }));
}, []);
const refresh = useCallback(async () => {
......@@ -365,6 +365,7 @@ export default function RegulationGraph() {
return [...regulationItems, ...additionItems].filter((record) => !query || String(metadataOf(record).regulation_name || record.title || '').toLocaleLowerCase().includes(query));
}, [regulations, revisions, searchText]);
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 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]);
......@@ -387,15 +388,29 @@ export default function RegulationGraph() {
content: node.content,
removed: node.removed,
selected: node.id === selectedId,
dropTarget: dropPreview?.nodeId === node.id,
expanded: !collapsedIds.has(node.id),
childCount: (childrenByParent.get(node.id) || []).length,
relatedRegulationType: nodeStatus(node),
onToggle: toggleCollapsed,
},
}));
}, [collapsedIds, draggedPositions, graph.nodes, selectedId, visibleEdges, visibleNodes]);
const focusNode = useCallback((node) => {
}, [collapsedIds, draggedPositions, dropPreview?.nodeId, graph.nodes, selectedId, visibleEdges, visibleNodes]);
const selectNode = useCallback((node, recordId = null) => {
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 = [];
let parent = node.parent ? nodeById.get(String(node.parent)) : null;
while (parent) {
......@@ -408,22 +423,35 @@ export default function RegulationGraph() {
return next;
});
if (node.removed) setShowRemoved(true);
setSelectedId(node.id);
setRightOpen(true);
const target = { id: String(node.id) };
selectNode(node, recordId);
const focusRequestId = focusRequestRef.current;
let attempts = 0;
const fitTarget = () => {
const centerTarget = () => {
if (focusRequestId !== focusRequestRef.current) return;
attempts += 1;
const didFocus = flowRef.current?.fitView?.({ nodes: [target], padding: 0.38, duration: 280 });
// A newly expanded node may need one more render before React Flow has
// measured its bounds. Retry for a few frames instead of silently
// leaving the viewport at the previous location.
if (!didFocus && attempts < 8) window.requestAnimationFrame(fitTarget);
const instance = flowRef.current;
const target = instance?.getNode(String(node.id));
// Expanded nodes need to render and be measured before their center is
// available. Pan at the existing zoom instead of fitting a single node.
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(fitTarget);
window.requestAnimationFrame(centerTarget);
});
}, [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 restoreView = () => flowRef.current?.fitView?.({ padding: 0.22, duration: 240 });
......@@ -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 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(/[\\/:*?"<>|\u0000-\u001f]/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 submitNodeDialog = () => {
const title = nodeForm.title.trim();
......@@ -449,9 +497,69 @@ export default function RegulationGraph() {
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 = ''; }
};
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) => {
event.preventDefault(); const raw = event.dataTransfer.getData('application/x-regulation-revision'); if (!raw) 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 || '加入拓扑图失败。'); }
if (!Array.from(event.dataTransfer.types).includes(REGULATION_DRAG_TYPE)) return;
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) => {
try {
......@@ -479,13 +587,45 @@ export default function RegulationGraph() {
if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载制度拓扑图..." /></div>;
return (
<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}
{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}>
<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>
<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>
<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 className="relative flex min-h-0 flex-1 overflow-hidden rounded-2xl border border-separator bg-background shadow-sm" ref={canvasRef}>
<aside className={`shrink-0 overflow-hidden border-e border-separator transition-[width] ${leftOpen ? 'w-72' : 'w-0'}`}><div className="flex h-full w-72 flex-col"><div className="border-b border-separator p-3"><TextField fullWidth value={searchText} onChange={setSearchText}><Label className="sr-only">搜索节点</Label><div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={16} /><Input className="pl-9" fullWidth placeholder="搜索制度或节点" variant="secondary" /></div></TextField></div><Tabs className="min-w-0" selectedKey={activePanel} variant="primary" onSelectionChange={(key) => setActivePanel(String(key))}><Tabs.ListContainer className="w-full min-w-0 overflow-hidden border-b border-separator px-2"><Tabs.List className="grid w-full min-w-0 grid-cols-4" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', width: '100%' }} aria-label="拓扑图节点列表"><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="all">全部<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="pending">待分类<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="nodes">节点<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="removed">删除节点<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer></Tabs><div className="min-h-0 flex-1 overflow-y-auto p-2">{listNodes.map((node) => { const isRecordList = activePanel === 'all' || activePanel === 'pending'; const isSelected = isRecordList ? highlightedRecordId !== null && getGraphRecordId(node) === highlightedRecordId : selectedId === node.id; const title = isRecordList ? metadataOf(node).regulation_name || node.title : node.title; const type = isRecordList ? node.__pendingStatus || (metadataOf(node).type === '新增' ? 'addition' : metadataOf(node).type === '废止' ? 'forbidden' : 'revise') : nodeStatus(node); return <button draggable={isRecordList && archive === '当前版本' && !saving} onDragStart={(event) => { event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData(REGULATION_DRAG_TYPE, JSON.stringify(node)); }} onDragEnd={clearDropPreview} className={`flex min-h-11 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-surface-secondary ${isSelected ? 'bg-accent/10 text-accent' : ''}`} aria-current={isSelected ? 'true' : undefined} key={node._id || node.id || node.name} onClick={() => isRecordList ? focusRecordNode(node) : focusNode(node)}><span className="min-w-0 flex-1 truncate">{title}</span><Chip color={STATUS_OPTIONS.find((item) => item.value === type)?.color || 'default'} size="sm" variant="soft"><Chip.Label>{isRecordList ? (type === 'active' ? '现行有效' : `待${metadataOf(node).type || '处理'}`) : nodeTypeLabel(node.type)}</Chip.Label></Chip></button>; })}{listNodes.length === 0 ? <p className="p-4 text-center text-sm text-muted">暂无节点</p> : null}</div></div></aside>
<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>
</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>
</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>
......
......@@ -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>}>
<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 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>
<p className="mt-3 text-sm text-muted">归档会复制所有“已发文”制度到归档对象,并保留原制度与修订记录。</p>
<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>
<p className="mt-3 text-sm text-muted">将所有“现行有效”的制度保存为该批次的归档副本;如有已保存的当前拓扑图,也会一并归档。原制度与修订记录继续保留。</p>
</XinyuanModal>
</div>
);
......
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;
}
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];
}
......@@ -6,6 +6,7 @@
- `/internal_regulation/:name`:制度详情、正文、附件、关联制度与历史沿革入口。
- `/internal_regulation_revision/:name`:修订计划编辑页。
- `/graph`:制度拓扑图(与 `/internal_regulation_manager` 制度目录为平级入口),根据 `internal_regulation_graph.metadata.graph_info` 展示制度、流程节点和父子连线;图谱记录缺失或节点为空时,根据制度引用关系生成降级图,确保首次进入页面仍能看到制度节点,同时保留非空图谱中的删除节点供“删除节点”列表查看。
- “内部制度”和“制度拓扑图”页面的名称统一由顶部导航栏展示,主内容区不再重复显示模块说明或页面标题;制度管理内容直接从三个列表 Tab 开始,拓扑内容直接从画布及工具栏开始。
## 制度目录
......@@ -27,15 +28,27 @@
## 归档与拓扑
- 制度拓扑图与制度目录是两个平级入口;进入拓扑图后不呈现制度目录层级,也不提供返回制度目录操作,用户可通过左侧一级菜单在两个模块之间切换。
- 归档入口位于“修订计划 → 归档”,弹窗标题为“归档现行有效制度”;填写必填归档批次并确认后,将全部 `metadata.effective_status = 现行有效` 的制度保存到 `internal_regulation_archive`,保留原制度与修订记录及其状态。归档范围不受列表状态、关键字或分页影响;如有已保存的当前拓扑图,也会一并保存为该批次的归档版本。归档期间禁用确认按钮,失败显示错误并保留已填写的批次。
- 归档历史从 `internal_regulation_archive` 读取,按归档批次和制度名称筛选,只读展示;制度名称输入后点击“查询”或按 Enter 才提交搜索,输入过程不实时请求。
- 归档历史支持按当前批次和关键字导出 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 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。
- 画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。
- 拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
- 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
- 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;右键可添加流程节点或描述。
- 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类;也支持将待处理制度拖入选中节点下。
- 保存写入 `internal_regulation_graph` 的 `graph_info.nodes/edges`,导出生成制度列表 CSV;XMind 导入、节点编辑和拖拽归类均需保存后持久化图谱。
- “导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开 `.xmind/.json` 文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。
- XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按 `parent` 保留层级;非父子连线转为 XMind 关联线。制度主题使用 `《制度名称》`,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;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` 摘要,加载和连接后按父节点关系重建,不随保存请求重复嵌套;因此只点击节点后直接保存也能正常发起请求。请求期间禁用重复保存,失败保留画布和编辑内容。
## 加载、错误与限制
......
import dayjs from 'dayjs';
import { serializeGraph } from './regulationGraphData';
import {
createDocument,
createRecord,
......@@ -286,6 +287,7 @@ export async function loadGraph() {
title: node.title || node.data?.title || node.id,
type: node.type || node.data?.type || 'regulationNode',
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',
parent: node.parent || node.data?.parent || null,
content: node.content || node.data?.content || '',
......@@ -331,6 +333,7 @@ export async function loadGraphArchive(archive) {
title: node.title || node.data?.title || node.id,
type: node.type || node.data?.type || 'regulationNode',
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',
parent: node.parent || node.data?.parent || null,
content: node.content || node.data?.content || '',
......@@ -344,33 +347,7 @@ export async function loadGraphArchive(archive) {
}
export async function saveGraph(graph, archive = '当前版本') {
const nodes = (graph?.nodes || []).map((node) => ({
...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),
}));
const { nodes, edges } = serializeGraph(graph);
return importRecords(GRAPH_SUBJECT, [{ name: 'graph_data', title: '节点数据', metadata: { archive, type: 'active', graph_info: { nodes, edges } } }]);
}
......
......@@ -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. 用户查询接口
`POST /user/query`
......
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 };
}
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' });
}
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