From 23927dc64bda17fa6635e278ee99ca7808bead16 Mon Sep 17 00:00:00 2001 From: 666ghj <670939375@qq.com> Date: Wed, 10 Dec 2025 18:34:49 +0800 Subject: [PATCH] Enhance graph visualization and data polling in Process.vue - Added real-time graph data visualization with loading and waiting states to improve user feedback during graph construction. - Implemented a polling mechanism to fetch graph data every 3 seconds, ensuring the graph updates dynamically as data becomes available. - Improved error handling and rendering logic to manage different graph states effectively. - Enhanced styling for loading indicators and graph elements for better user experience. --- frontend/src/views/Process.vue | 230 ++++++++++++++++++++++++++++----- 1 file changed, 197 insertions(+), 33 deletions(-) diff --git a/frontend/src/views/Process.vue b/frontend/src/views/Process.vue index 4a9aa66..dc954ff 100644 --- a/frontend/src/views/Process.vue +++ b/frontend/src/views/Process.vue @@ -30,8 +30,18 @@
+ +
+ + +
+ + 实时更新中... +
+
+ -
+
@@ -41,7 +51,7 @@
-
+
@@ -55,13 +65,19 @@
-

等待图谱构建完成

-

完成本体生成后将自动开始构建

+

等待本体生成

+

生成完成后将自动开始构建图谱

- -
- + +
+
+
+
+
+
+

图谱构建中

+

数据即将显示...

@@ -497,7 +513,15 @@ const startBuildGraph = async () => { if (response.success) { buildProgress.value.message = '图谱构建任务已启动...' - startPollingTask(response.data.task_id) + + // 保存 task_id 用于轮询 + const taskId = response.data.task_id + + // 启动图谱数据轮询(独立于任务状态轮询) + startGraphPolling() + + // 启动任务状态轮询 + startPollingTask(taskId) } else { error.value = response.error || '启动图谱构建失败' buildProgress.value = null @@ -509,6 +533,58 @@ const startBuildGraph = async () => { } } +// 图谱数据轮询定时器 +let graphPollTimer = null + +// 启动图谱数据轮询 +const startGraphPolling = () => { + // 每 3 秒获取一次图谱数据 + graphPollTimer = setInterval(async () => { + await fetchGraphData() + }, 3000) +} + +// 停止图谱数据轮询 +const stopGraphPolling = () => { + if (graphPollTimer) { + clearInterval(graphPollTimer) + graphPollTimer = null + } +} + +// 获取图谱数据 +const fetchGraphData = async () => { + try { + // 先获取项目信息以获取 graph_id + const projectResponse = await getProject(currentProjectId.value) + + if (projectResponse.success && projectResponse.data.graph_id) { + const graphId = projectResponse.data.graph_id + projectData.value = projectResponse.data + + // 获取图谱数据 + const graphResponse = await getGraphData(graphId) + + if (graphResponse.success && graphResponse.data) { + const newData = graphResponse.data + const newNodeCount = newData.node_count || newData.nodes?.length || 0 + const oldNodeCount = graphData.value?.node_count || graphData.value?.nodes?.length || 0 + + console.log('Fetching graph data, nodes:', newNodeCount, 'edges:', newData.edge_count || newData.edges?.length || 0) + + // 数据有变化时更新渲染 + if (newNodeCount !== oldNodeCount || !graphData.value) { + graphData.value = newData + await nextTick() + renderGraph() + } + } + } + } catch (err) { + console.log('Graph data fetch:', err.message || 'not ready') + } +} + // 轮询任务状态 const startPollingTask = (taskId) => { // 立即执行一次查询 @@ -538,6 +614,7 @@ const pollTaskStatus = async (taskId) => { if (task.status === 'completed') { stopPolling() + stopGraphPolling() currentPhase.value = 2 // 重新加载项目数据获取 graph_id @@ -545,13 +622,14 @@ const pollTaskStatus = async (taskId) => { if (projectResponse.success) { projectData.value = projectResponse.data - // 加载图谱数据 + // 最终加载完整图谱数据 if (projectResponse.data.graph_id) { await loadGraph(projectResponse.data.graph_id) } } } else if (task.status === 'failed') { stopPolling() + stopGraphPolling() error.value = '图谱构建失败: ' + (task.error || '未知错误') buildProgress.value = null } @@ -588,76 +666,137 @@ const loadGraph = async (graphId) => { // 渲染图谱 (D3.js) const renderGraph = () => { - if (!graphSvg.value || !graphData.value) return + if (!graphSvg.value || !graphData.value) { + console.log('Cannot render: svg or data missing') + return + } const container = graphContainer.value - const width = container.clientWidth - const height = container.clientHeight - 60 + if (!container) { + console.log('Cannot render: container missing') + return + } + + // 获取容器尺寸 + const rect = container.getBoundingClientRect() + const width = rect.width || 800 + const height = (rect.height || 600) - 60 + + if (width <= 0 || height <= 0) { + console.log('Cannot render: invalid dimensions', width, height) + return + } + + console.log('Rendering graph:', width, 'x', height) const svg = d3.select(graphSvg.value) .attr('width', width) .attr('height', height) + .attr('viewBox', `0 0 ${width} ${height}`) svg.selectAll('*').remove() - const nodes = graphData.value.nodes.map(n => ({ + // 处理节点数据 + const nodesData = graphData.value.nodes || [] + const edgesData = graphData.value.edges || [] + + if (nodesData.length === 0) { + console.log('No nodes to render') + // 显示空状态 + svg.append('text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .attr('fill', '#999') + .text('等待图谱数据...') + return + } + + const nodes = nodesData.map(n => ({ id: n.uuid, - name: n.name, - type: n.labels?.find(l => l !== 'Entity') || 'Entity' + name: n.name || '未命名', + type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity' })) - const edges = graphData.value.edges.map(e => ({ - source: e.source_node_uuid, - target: e.target_node_uuid, - type: e.fact_type || 'RELATED_TO' - })) + // 创建节点ID集合用于过滤有效边 + const nodeIds = new Set(nodes.map(n => n.id)) + + const edges = edgesData + .filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid)) + .map(e => ({ + source: e.source_node_uuid, + target: e.target_node_uuid, + type: e.fact_type || e.name || 'RELATED_TO' + })) + + console.log('Nodes:', nodes.length, 'Edges:', edges.length) // 颜色映射 + const types = [...new Set(nodes.map(n => n.type))] const colorScale = d3.scaleOrdinal() - .domain([...new Set(nodes.map(n => n.type))]) - .range(['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C']) + .domain(types) + .range(['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#2D3436', '#6C5CE7']) // 力导向布局 const simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(edges).id(d => d.id).distance(80)) - .force('charge', d3.forceManyBody().strength(-200)) + .force('link', d3.forceLink(edges).id(d => d.id).distance(100).strength(0.5)) + .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(30)) + .force('collision', d3.forceCollide().radius(40)) + .force('x', d3.forceX(width / 2).strength(0.05)) + .force('y', d3.forceY(height / 2).strength(0.05)) + + // 添加缩放功能 + const g = svg.append('g') + + svg.call(d3.zoom() + .extent([[0, 0], [width, height]]) + .scaleExtent([0.2, 4]) + .on('zoom', (event) => { + g.attr('transform', event.transform) + })) // 绘制边 - const link = svg.append('g') + const link = g.append('g') .attr('class', 'links') .selectAll('line') .data(edges) .enter() .append('line') - .attr('stroke', '#ddd') - .attr('stroke-width', 1) + .attr('stroke', '#ccc') + .attr('stroke-width', 1.5) + .attr('stroke-opacity', 0.6) // 绘制节点 - const node = svg.append('g') + const node = g.append('g') .attr('class', 'nodes') .selectAll('g') .data(nodes) .enter() .append('g') + .style('cursor', 'pointer') .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)) node.append('circle') - .attr('r', 8) + .attr('r', 10) .attr('fill', d => colorScale(d.type)) .attr('stroke', '#fff') .attr('stroke-width', 2) node.append('text') - .attr('dx', 12) + .attr('dx', 14) .attr('dy', 4) - .text(d => d.name?.substring(0, 10) || '') - .attr('font-size', '10px') + .text(d => d.name?.substring(0, 12) || '') + .attr('font-size', '11px') .attr('fill', '#333') + .attr('font-family', 'JetBrains Mono, monospace') + + // 添加 tooltip + node.append('title') + .text(d => `${d.name}\n类型: ${d.type}`) simulation.on('tick', () => { link @@ -701,6 +840,7 @@ onMounted(() => { onUnmounted(() => { stopPolling() + stopGraphPolling() }) @@ -937,11 +1077,35 @@ onUnmounted(() => { .graph-view { width: 100%; height: 100%; + position: relative; } .graph-svg { width: 100%; height: 100%; + display: block; +} + +.graph-building-hint { + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: rgba(255, 107, 53, 0.1); + border: 1px solid #FF6B35; + font-size: 0.8rem; + color: #FF6B35; +} + +.building-dot { + width: 8px; + height: 8px; + background: #FF6B35; + border-radius: 50%; + animation: pulse 1s infinite; } .error-icon {