Commit ba3d23a8 authored by drigle's avatar drigle

feat: 支持拓扑节点删除恢复并修正右键菜单定位

parent e7a7bf9d
import assert from 'node:assert/strict';
import test from 'node:test';
import { getGraphBranchIds, isGraphDeleteShortcut, setGraphBranchRemoved } from '../src/pages/internal_regulation_manager/graphRemoval.js';
import { getNextGraphRecordNode } from '../src/pages/internal_regulation_manager/graphSelection.js';
import { serializeGraph } from '../src/services/regulationGraphData.js';
function exampleGraph() {
const nodes = [
{ id: 'root', type: 'rootNode', parent: null },
{ id: 'branch', type: 'groupNode', parent: 'root', content: '流程备注' },
{ id: 'child', type: 'regulationNode', parent: 'branch', relatedRegulation: 'same-regulation' },
{ id: 'grandchild', type: 'regulationNode', parent: 'child', relatedReviseRegulation: 'new-plan' },
{ id: 'sibling', type: 'regulationNode', parent: 'root', relatedRegulation: 'same-regulation' },
].map((node) => ({ ...node, removed: false, data: { removed: false } }));
return { nodes, edges: nodes.filter((node) => node.parent).map((node) => ({ source: node.parent, target: node.id })) };
}
test('deleting a branch includes descendants but preserves parent and sibling occurrences of the same regulation', () => {
const original = exampleGraph();
const removed = setGraphBranchRemoved(original, 'branch', true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), ['branch', 'child', 'grandchild']);
assert.equal(removed.nodes[0], original.nodes[0]);
assert.equal(removed.nodes.at(-1), original.nodes.at(-1));
assert.equal(removed.edges, original.edges);
assert.equal(removed.nodes[1].content, '流程备注');
assert.equal(removed.nodes[2].relatedRegulation, 'same-regulation');
assert.equal(removed.nodes[3].relatedReviseRegulation, 'new-plan');
assert.equal(original.nodes.some((node) => node.removed || node.data.removed), false);
removed.nodes.forEach((node) => assert.equal(node.data.removed, node.removed));
});
test('deleting a leaf does not remove the entire regulation or its other occurrence', () => {
const original = exampleGraph();
const removed = setGraphBranchRemoved(original, 'grandchild', true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), ['grandchild']);
const record = { _id: 'same-regulation' };
assert.equal(getNextGraphRecordNode(record, removed.nodes, 'child', record._id).id, 'sibling');
});
test('record navigation skips removed occurrences while other occurrences remain', () => {
const removed = setGraphBranchRemoved(exampleGraph(), 'child', true);
const record = { _id: 'same-regulation' };
assert.equal(getNextGraphRecordNode(record, removed.nodes, 'child', record._id).id, 'sibling');
assert.equal(getNextGraphRecordNode(record, removed.nodes, 'sibling', record._id).id, 'sibling');
});
test('saving a deleted branch preserves its removal state, hierarchy and links for reload and recovery', () => {
const original = exampleGraph();
const saved = serializeGraph(setGraphBranchRemoved(original, 'branch', true));
const reloaded = JSON.parse(JSON.stringify(saved));
assert.deepEqual(reloaded.nodes.filter((node) => node.removed).map((node) => node.id), ['branch', 'child', 'grandchild']);
assert.equal(reloaded.nodes[3].parent, 'child');
assert.equal(reloaded.nodes[2].data.related_regulation, 'same-regulation');
assert.equal(reloaded.edges.length, original.edges.length);
const restored = setGraphBranchRemoved(reloaded, 'branch', false);
assert.equal(restored.nodes.some((node) => node.removed || node.data.removed), false);
assert.deepEqual(restored.edges, reloaded.edges);
});
test('restoring a descendant restores its parent path without restoring deleted sibling branches', () => {
const removed = setGraphBranchRemoved(exampleGraph(), 'root', true);
const restored = setGraphBranchRemoved(removed, 'grandchild', false);
assert.deepEqual(restored.nodes.filter((node) => !node.removed).map((node) => node.id), ['root', 'branch', 'child', 'grandchild']);
assert.equal(restored.nodes.at(-1).removed, true);
assert.equal(removed.nodes.every((node) => node.removed), true);
});
test('numeric IDs and legacy data.parent still identify the correct branch', () => {
const graph = { nodes: [{ id: 1 }, { id: 2, data: { parent: 1 } }, { id: 3 }], edges: [] };
assert.deepEqual([...getGraphBranchIds(graph.nodes, '1')], ['1', '2']);
const removed = setGraphBranchRemoved(graph, 1, true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), [1, 2]);
});
test('missing selections and missing nodes leave the graph intact', () => {
const graph = exampleGraph();
for (const id of [null, undefined, 'missing']) {
assert.equal(getGraphBranchIds(graph.nodes, id).size, 0);
assert.equal(setGraphBranchRemoved(graph, id, true), graph);
}
assert.equal(getGraphBranchIds([{ id: 'null' }], null).size, 0);
});
test('branch traversal and restoring an ancestor path terminate with cyclic parents', () => {
const graph = { nodes: [{ id: 'a', parent: 'b' }, { id: 'b', parent: 'a' }, { id: 'other' }], edges: [] };
const removed = setGraphBranchRemoved(graph, 'a', true);
assert.deepEqual(removed.nodes.filter((node) => node.removed).map((node) => node.id), ['a', 'b']);
const restored = setGraphBranchRemoved(removed, 'b', false);
assert.equal(restored.nodes.some((node) => node.removed), false);
});
test('only unmodified Delete or Mac backward-delete presses trigger deletion', () => {
const event = { key: 'Delete' };
assert.equal(isGraphDeleteShortcut(event), true);
assert.equal(isGraphDeleteShortcut({ key: 'Backspace' }), true);
for (const key of ['Enter', 'Escape']) assert.equal(isGraphDeleteShortcut({ key }), false);
for (const flag of ['defaultPrevented', 'repeat', 'isComposing', 'ctrlKey', 'metaKey', 'altKey', 'shiftKey']) {
assert.equal(isGraphDeleteShortcut({ ...event, [flag]: true }), false, flag);
}
});
test('editable fields or overlay targets block the shortcut while canvas targets permit it', () => {
assert.equal(isGraphDeleteShortcut({ key: 'Delete', target: { closest: () => ({}) } }), false);
assert.equal(isGraphDeleteShortcut({ key: 'Delete', target: { closest: () => null } }), true);
});
export function getGraphBranchIds(nodes, nodeId) {
if (nodeId === null || nodeId === undefined) return new Set();
const id = String(nodeId);
if (!nodes.some((node) => String(node.id) === id)) return new Set();
const childrenByParent = new Map();
nodes.forEach((node) => {
const parent = node.parent ?? node.data?.parent;
if (parent === null || parent === undefined) return;
const parentId = String(parent);
const children = childrenByParent.get(parentId) || [];
children.push(String(node.id));
childrenByParent.set(parentId, children);
});
const branchIds = new Set([id]);
const queue = [id];
for (let index = 0; index < queue.length; index += 1) {
(childrenByParent.get(queue[index]) || []).forEach((childId) => {
if (branchIds.has(childId)) return;
branchIds.add(childId);
queue.push(childId);
});
}
return branchIds;
}
export function setGraphBranchRemoved(graph, nodeId, removed) {
const affectedIds = getGraphBranchIds(graph.nodes, nodeId);
if (!affectedIds.size) return graph;
if (!removed) {
// A restored descendant must have a visible parent path, including when
// the user restores it directly from the removed-node list.
const nodesById = new Map(graph.nodes.map((node) => [String(node.id), node]));
let node = nodesById.get(String(nodeId));
while (node) {
const parentId = node.parent ?? node.data?.parent;
if (parentId === null || parentId === undefined || affectedIds.has(String(parentId))) break;
affectedIds.add(String(parentId));
node = nodesById.get(String(parentId));
}
}
return {
...graph,
nodes: graph.nodes.map((node) => affectedIds.has(String(node.id))
? { ...node, removed, data: { ...node.data, removed } }
: node),
};
}
export function isGraphDeleteShortcut(event) {
if (!['Delete', 'Backspace'].includes(event.key) || event.defaultPrevented || event.repeat || event.isComposing
|| event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
return !event.target?.closest?.('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"], [role="spinbutton"], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]');
}
......@@ -40,13 +40,17 @@
- “全部 / 待分类”列表首次点击制度记录时定位第一个关联节点,连续点击同一条记录时按图谱节点顺序循环定位(A → B → C → A);只有一个关联节点时保持定位该节点。记录 `_id/name` 的直接关联节点合并后参与循环,只有没有直接关联时才按 `metadata.origin_regulation` 查找。优先在未移除节点中循环;全部关联节点已移除时才在已移除节点中循环,并自动开启“显示已移除”。点击其他制度、选中画布或“节点 / 删除节点”列表、重新加载或切换拓扑版本后,再点击该制度从第一个关联节点重新开始。循环过程仍只高亮点击的制度记录,自动展开目标祖先、保持缩放并打开详情;快速连续点击时取消尚未执行的旧定位回调,画布最终定位到最新选中的节点。
- 当前所查看的拓扑版本没有关联节点时,点击制度记录显示 HeroUI 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。
- 画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。
- 当前版本选中节点后,可按 `Delete`(兼容 Mac 的 `⌫ / Backspace`)或点击右侧详情“节点操作 → 删除节点”。删除按节点 ID 将该节点及全部下级节点的 `removed`、`data.removed` 设为 `true`,保留节点引用、父子层级及连线;同一制度在其他分支的节点不受影响。删除后清除节点和记录选择、取消旧定位回调与拖入预览、移除被删除的流程筛选路径,并关闭“显示已移除”,让删除分支立即从画布隐藏;节点保留在支持搜索的“删除节点”列表。只有已删除关联的制度重新进入“待分类”,仍有未移除关联节点的制度保持已分类。
- 在“删除节点”列表选择节点后,右侧详情显示“已删除”,提供“恢复节点”或“恢复节点及子节点”。恢复将所选分支及其祖先的 `removed`、`data.removed` 设为 `false`,保留其他分支的删除状态;自动展开目标祖先、保持缩放定位并打开详情。删除和恢复均只修改当前内存图谱,点击“保存”后通过 `POST /record/import` 写入 `internal_regulation_graph.metadata.graph_info`;不删除制度或修订计划 Record,保存失败保留修改。
- 删除快捷键仅对当前页面已选中且未删除的节点生效;输入框、备注、搜索框、可编辑区域、选择器、菜单和弹窗内的 `Delete / Backspace` 保持原有编辑行为,带修饰键、输入法组合中或长按重复事件不触发节点删除。归档版本、加载/导入/保存期间、节点编辑弹窗打开时禁用删除和恢复;已删除节点不重复删除。点击画布空白处或详情“清除选择”会同时清除节点和记录选择,后续删除快捷键不操作之前的节点。React Flow 默认删除快捷键关闭,统一由上述逻辑处理。
- 拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
- 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
- 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
- “导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开 `.xmind/.json` 文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。
- XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按 `parent` 保留层级;非父子连线转为 XMind 关联线。制度主题使用 `《制度名称》`,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;XMind 使用向右的逻辑图布局,不承诺复制画布的手工坐标、缩放或筛选状态。
- XMind 文件在浏览器内生成,使用现有 JSZip 打包 `content.json/metadata.json/manifest.json`,下载名称为 `制度拓扑图-版本.xmind`。生成期间显示“正在导出”并禁用重复导出;无节点、重复节点 ID 或循环父子关系时显示明确错误,失败保留当前图谱。CSV 继续按原规则导出制度列表。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;“添加流程节点”位于详情的节点操作区,右键也可添加流程节点或描述。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;“添加流程节点”位于详情的节点操作区,右键也可添加流程节点或备注。
- 右键节点时,在鼠标点击位置显示“添加流程节点 / 添加备注”菜单;右键画布空白处只显示“添加流程节点”。菜单坐标以中间画布的实际定位容器为基准,不计入左侧列表宽度,不使用图谱缩放或平移后的节点坐标;菜单渲染后按实际宽高限制在画布内,靠近右侧或底部时自动向内调整,避免覆盖详情或被裁切。菜单项使用 HeroUI Button;点击外部、按 Esc、开始平移/缩放或拖动节点、窗口尺寸变化时关闭菜单,选择菜单项后按原流程打开节点编辑弹窗。
- 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类。
- “全部 / 待分类”的制度记录可拖到画布中任意未移除可见节点的右侧连接区域:区域向右延伸 220 个画布单位,上下各扩展 32 个单位;相邻区域重叠时优先选择离鼠标最近的节点。命中后高亮父节点、显示连接区域与横向连线预览,并提示“松开连接到「节点名称」”;缩放、平移及手动移动节点后仍按当前画布坐标判断。
- 松手后按落点目标建立父子节点及连线,展开祖先路径和父节点、选中新制度并打开详情;同时清除流程/状态筛选并开启现行显示,让新节点可见。待新增关联修订计划 ID,现行/待修订/待废止制度保留制度 ID,修订计划通过 `origin_regulation` 关联现行制度。
......
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