Commit 4a0c4abf authored by drigle's avatar drigle

fix: 增加拓扑图多层级联流程筛选

parent 64fcb2ae
import { Button, Checkbox, Popover } from '@heroui/react';
import { ChevronRight, Close } from '@/components/AppIcons';
import { useEffect, useMemo, useState } from 'react';
function collectDescendantIds(node) {
const ids = [];
const walk = (item) => {
item.children.forEach((child) => {
ids.push(child.id);
walk(child);
});
};
walk(node);
return ids;
}
export default function ProcessCascade({ tree = [], value = [], isDisabled = false, onChange }) {
const [columns, setColumns] = useState([]);
const [open, setOpen] = useState(false);
const { itemsById, pathsById } = useMemo(() => {
const items = new Map();
const paths = new Map();
const walk = (item) => {
items.set(item.id, item);
paths.set(item.id, item.path);
item.children.forEach(walk);
};
tree.forEach(walk);
return { itemsById: items, pathsById: paths };
}, [tree]);
const selectedPaths = useMemo(
() => value.map((id) => pathsById.get(String(id))).filter(Boolean),
[pathsById, value],
);
const checkedIds = useMemo(() => {
const ids = new Set();
selectedPaths.forEach((path) => {
path.forEach((node) => ids.add(node.id));
const item = itemsById.get(path[path.length - 1].id);
if (item) collectDescendantIds(item).forEach((id) => ids.add(id));
});
return ids;
}, [itemsById, selectedPaths]);
useEffect(() => {
setColumns((current) => {
const next = [];
let items = tree;
for (const id of current) {
const item = items.find((entry) => entry.id === String(id));
if (!item?.children.length) break;
next.push(item.id);
items = item.children;
}
return next;
});
}, [tree]);
useEffect(() => {
if (!open || !selectedPaths.length) return;
const path = selectedPaths[selectedPaths.length - 1];
setColumns(path.slice(0, -1).map((node) => node.id));
}, [open]);
const activeColumns = useMemo(() => {
const result = [tree];
columns.forEach((id) => {
const item = itemsById.get(String(id));
if (item?.children.length) result.push(item.children);
});
return result;
}, [columns, itemsById, tree]);
const selectedLabel = selectedPaths.length
? selectedPaths.map((path) => path[path.length - 1]?.title).join('、')
: '筛选流程';
const expandColumn = (depth, item) => {
setColumns((current) => current.slice(0, depth).concat(item.id));
};
const togglePath = (path) => {
const id = String(path[path.length - 1].id);
onChange(value.includes(id) ? value.filter((item) => item !== id) : [...value, id]);
};
return (
<span className="relative inline-flex max-w-full shrink-0">
<Popover isOpen={open} onOpenChange={setOpen}>
<Popover.Trigger>
<Button
aria-label="筛选流程"
className="h-10 w-72 justify-between gap-2 px-3"
isDisabled={isDisabled}
variant="secondary"
>
<span className={`min-w-0 flex-1 truncate text-left ${selectedPaths.length ? 'text-foreground' : 'text-muted'}`}>
{selectedLabel}
</span>
{selectedPaths.length ? <span className="shrink-0 text-xs text-muted">{selectedPaths.length}</span> : null}
</Button>
</Popover.Trigger>
{selectedPaths.length ? (
<button
aria-label="清空流程筛选"
className="absolute right-2 top-1/2 z-10 grid size-6 -translate-y-1/2 place-items-center rounded-full text-muted hover:bg-surface-secondary hover:text-foreground"
onClick={() => onChange([])}
type="button"
>
<Close size={12} />
</button>
) : null}
<Popover.Content className="overflow-hidden rounded-xl border border-separator bg-background shadow-lg" placement="bottom start">
<Popover.Dialog aria-label="筛选流程">
<div className="flex max-h-96 overflow-x-auto">
{activeColumns.map((items, depth) => (
<div
className="max-h-96 w-56 shrink-0 overflow-y-auto border-r border-separator bg-background py-1 last:border-r-0"
key={`process-column-${depth}`}
role="listbox"
aria-label={`第 ${depth + 1} 级流程`}
>
{items.map((item) => {
const checked = checkedIds.has(item.id);
const active = columns[depth] === item.id;
return (
<div
aria-checked={checked}
aria-selected={active}
className={`flex min-h-10 cursor-pointer select-none items-center gap-2 px-3 text-sm hover:bg-surface-secondary ${active ? 'bg-surface-secondary/80' : ''}`}
key={item.id}
onClick={() => item.children.length && expandColumn(depth, item)}
role="option"
title={item.title}
>
<span
className="inline-flex shrink-0"
onClick={(event) => event.stopPropagation()}
role="presentation"
>
<Checkbox
aria-label={`选择 ${item.title}`}
className="pointer-events-auto"
isSelected={checked}
onChange={() => togglePath(item.path)}
variant="secondary"
>
<Checkbox.Content className="size-4">
<Checkbox.Control className="size-4 rounded-[4px]">
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox.Content>
</Checkbox>
</span>
<span className={`min-w-0 flex-1 truncate ${checked ? 'text-foreground' : 'text-foreground/85'}`}>
{item.title}
</span>
{item.children.length ? (
<ChevronRight className={`size-4 shrink-0 transition-transform ${active ? 'text-foreground' : 'text-muted'}`} />
) : null}
</div>
);
})}
</div>
))}
</div>
</Popover.Dialog>
</Popover.Content>
</Popover>
</span>
);
}
......@@ -18,6 +18,7 @@ import {
Search,
Upload,
} from '@/components/AppIcons';
import ProcessCascade from '@/components/ProcessCascade';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal';
import GraphNode from './GraphNode';
......@@ -211,6 +212,28 @@ function xmindTreeToNodes(root, regulations, revisions) {
return { nodes, edges: nodes.filter((node) => node.parent && nodes.some((parent) => parent.id === node.parent)).map((node) => ({ id: `${node.parent}->${node.id}`, source: node.parent, target: node.id })) };
}
function buildProcessTree(nodes) {
const childrenByParent = new Map();
nodes.forEach((node) => {
const parent = node.parent ? String(node.parent) : '';
childrenByParent.set(parent, [...(childrenByParent.get(parent) || []), node]);
});
const walk = (node, path) => {
const nextPath = [...path, { id: String(node.id), title: node.title }];
return {
id: String(node.id),
path: nextPath,
title: node.title,
children: (childrenByParent.get(String(node.id)) || []).map((child) => walk(child, nextPath)),
};
};
const roots = childrenByParent.get('') || [];
return roots.flatMap((node) => {
const children = (childrenByParent.get(String(node.id)) || []).map((child) => walk(child, [{ id: String(node.id), title: node.title }]));
return children.length ? children : [{ id: String(node.id), path: [{ id: String(node.id), title: node.title }], title: node.title, children: [] }];
});
}
export default function RegulationGraph() {
const [graph, setGraph] = useState(EMPTY_GRAPH);
const [regulations, setRegulations] = useState([]);
......@@ -278,14 +301,15 @@ export default function RegulationGraph() {
if (selectedGroupIds.length) {
const selected = new Set(selectedGroupIds.map(String));
selectedGroupIds.forEach((groupId) => {
if (!nodeById.has(String(groupId))) return;
selected.add(String(groupId));
let parent = nodeById.get(String(groupId));
while (parent) {
selected.add(parent.id);
candidates.filter((node) => node.parent === parent.id).forEach((node) => selected.add(node.id));
parent = parent.parent ? nodeById.get(String(parent.parent)) : null;
}
});
const queue = [...selected];
const queue = selectedGroupIds.map(String);
while (queue.length) {
const parentId = queue.shift();
candidates.filter((node) => node.parent === parentId).forEach((node) => {
......@@ -341,7 +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 processOptions = useMemo(() => graph.nodes.filter((node) => node.type === 'groupNode').map((node) => ({ value: node.id, label: node.title })), [graph.nodes]);
const processTree = useMemo(() => buildProcessTree(graph.nodes), [graph.nodes]);
const toggleCollapsed = useCallback((nodeId) => setCollapsedIds((current) => { const next = new Set(current); const id = String(nodeId); if (next.has(id)) next.delete(id); else next.add(id); return next; }), []);
const visibleEdges = useMemo(() => graph.edges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)), [graph.edges, visibleIds]);
const flowNodes = useMemo(() => {
......@@ -460,7 +484,7 @@ export default function RegulationGraph() {
{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))} /><XinyuanAutocomplete className="w-52" aria-label="流程筛选" options={processOptions} selectedKeys={new Set(selectedGroupIds)} selectionMode="multiple" onSelectionChange={(values) => setSelectedGroupIds(values === 'all' ? processOptions.map((item) => item.value) : Array.from(values || []))} /><Checkbox isSelected={showCurrent} variant="secondary" onChange={setShowCurrent}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示现行</Label></Checkbox><Checkbox isSelected={showRemoved} variant="secondary" onChange={setShowRemoved}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示已移除</Label></Checkbox>{showCurrent ? <div className="flex items-center gap-1">{STATUS_OPTIONS.map((option) => <button className={`rounded-md px-2 py-1 text-xs ${selectedStatuses.includes(option.value) ? 'bg-accent/10 text-accent' : 'text-muted hover:bg-surface-secondary'}`} key={option.value} onClick={() => setSelectedStatuses((current) => current.includes(option.value) ? current.filter((value) => value !== option.value) : [...current, option.value])}>{option.label}</button>)}</div> : null}</div><div className="flex items-center gap-1 rounded-xl border border-separator bg-background/95 p-1 shadow-sm backdrop-blur"><Tooltip delay={0}><Button isIconOnly aria-label="恢复视图" size="sm" variant="tertiary" onPress={restoreView}><Reload size={16} /></Button><Tooltip.Content>恢复视图</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="展开全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(new Set())}><Expand size={16} /></Button><Tooltip.Content>展开全部</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="折叠全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(defaultCollapsedIds(graph.nodes))}><ArrowPathIcon size={16} /></Button><Tooltip.Content>折叠到第一层</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="全屏查看" size="sm" variant="tertiary" onPress={toggleFullscreen}><Fullscreen size={16} /></Button><Tooltip.Content>全屏查看</Tooltip.Content></Tooltip></div></div><div className="h-full min-h-0 w-full" onContextMenu={(event) => event.preventDefault()}>{visibleNodes.length ? <ReactFlowProvider><ReactFlow nodes={flowNodes} edges={visibleEdges} nodeTypes={NODE_TYPES} fitView onInit={(instance) => { flowRef.current = instance; instance.fitView({ padding: 0.22 }); }} onNodeClick={(_, node) => focusNode(nodeById.get(String(node.id)))} onNodeDoubleClick={(_, node) => { const current = nodeById.get(String(node.id)); const relatedId = current?.relatedRegulation || current?.relatedReviseRegulation; if (relatedId) history.push(`/internal_regulation/${relatedId}`); }} onNodeDragStop={(_, node) => setGraph((current) => ({ ...current, nodes: current.nodes.map((item) => item.id === node.id ? { ...item, position: node.position } : item) }))} onNodeContextMenu={(event, node) => { event.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (rect) setContextMenu({ x: event.clientX - rect.left, y: event.clientY - rect.top, nodeId: String(node.id) }); }} onPaneContextMenu={(event) => { event.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (rect) setContextMenu({ x: event.clientX - rect.left, y: event.clientY - rect.top, nodeId: null }); }} onPaneClick={() => setContextMenu(null)} proOptions={{ hideAttribution: true }}><MiniMap pannable zoomable /><Controls showInteractive={false} /><Background color="#e2e8f0" gap={24} /></ReactFlow></ReactFlowProvider> : <div className="flex h-full min-h-0 items-center justify-center text-sm text-muted">暂无可展示的拓扑节点</div>}</div>{contextMenu ? <div className="absolute z-30 w-40 rounded-lg border border-separator bg-background p-1 shadow-lg" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(event) => event.stopPropagation()}><button className="flex min-h-10 w-full items-center gap-2 rounded-md px-3 text-left text-sm hover:bg-surface-secondary" onClick={() => openAddNode(contextMenu.nodeId, 'group')}><PlusIcon size={15} />添加流程节点</button>{contextMenu.nodeId ? <button className="flex min-h-10 w-full items-center gap-2 rounded-md px-3 text-left text-sm hover:bg-surface-secondary" onClick={() => openAddNode(contextMenu.nodeId, 'description')}><Edit size={15} />添加描述</button> : null}</div> : null}<button aria-label={leftOpen ? '收起左侧节点列表' : '展开左侧节点列表'} className="absolute left-1 top-1/2 z-20 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setLeftOpen((value) => !value)}><ChevronRight className={leftOpen ? 'rotate-180' : ''} size={15} /></button></main>
<main className="relative min-h-0 min-w-0 flex-1 bg-surface-secondary/30" onDragOver={(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>
<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>
......
......@@ -32,6 +32,7 @@
- 拓扑图沿用旧版 React Flow + Dagre 的层级画布逻辑,左侧提供“全部 / 待分类 / 节点 / 删除节点”四个列表 Tab;“节点”和“删除节点”列表支持搜索和点击定位,点击隐藏层级中的节点时会先自动展开该节点的全部祖先路径,再将画布平滑移动到目标节点并打开详情。打开时默认展开根节点和第一层流程节点,第二层及更深节点按节点右侧计数按钮逐级展开。节点支持拖拽、缩放、邻接连线、展开/折叠和双击进入关联制度详情。
- 拓扑页高度锁定为当前视口可用高度,标题、工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。
- 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
- 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;右键可添加流程节点或描述。
- 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类;也支持将待处理制度拖入选中节点下。
- 保存写入 `internal_regulation_graph` 的 `graph_info.nodes/edges`,导出生成制度列表 CSV;XMind 导入、节点编辑和拖拽归类均需保存后持久化图谱。
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment