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>
);
}
......@@ -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];
}
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