feat(fe-graph): add D3.js graph visualization — panorama, drill-down, status colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openclaw 2026-03-23 17:17:52 +00:00
parent d8071bc9f3
commit b7ebbcd777
5 changed files with 323 additions and 0 deletions

View File

@ -0,0 +1,62 @@
import api from '@/shared/api'
import type { ScanResult, GraphView, Capability, Module as DesignModule, Entity, Integration, ValueFlow, UserJourney, DataFlow, ExternalSystem, TraceabilityLink, RuntimeComponent, CapabilityDetail, ModuleDetail, EntityDetail, ImplProgress } from '@/shared/types/api'
export async function triggerScan(projectId: string): Promise<ScanResult> {
const { data } = await api.post<ScanResult>(`/projects/${projectId}/scan`)
return data
}
export async function getLatestScan(projectId: string): Promise<ScanResult> {
const { data } = await api.get<ScanResult>(`/projects/${projectId}/scan`)
return data
}
export async function getGraph(projectId: string): Promise<GraphView> {
const { data } = await api.get<GraphView>(`/projects/${projectId}/graph`)
return data
}
export async function getNodeNeighbors(projectId: string, nodeId: string): Promise<GraphView> {
const { data } = await api.get<GraphView>(`/projects/${projectId}/graph/nodes/${nodeId}/neighbors`)
return data
}
export async function listCapabilities(projectId: string): Promise<Capability[]> {
const { data } = await api.get<Capability[]>(`/projects/${projectId}/entities/capabilities`)
return data
}
export async function listModules(projectId: string): Promise<DesignModule[]> {
const { data } = await api.get<DesignModule[]>(`/projects/${projectId}/entities/modules`)
return data
}
export async function listEntities(projectId: string): Promise<Entity[]> {
const { data } = await api.get<Entity[]>(`/projects/${projectId}/entities/entities`)
return data
}
export async function getCapabilityDetail(projectId: string, capId: string): Promise<CapabilityDetail> {
const { data } = await api.get<CapabilityDetail>(`/projects/${projectId}/entities/capabilities/${capId}`)
return data
}
export async function getModuleDetail(projectId: string, modId: string): Promise<ModuleDetail> {
const { data } = await api.get<ModuleDetail>(`/projects/${projectId}/entities/modules/${modId}`)
return data
}
export async function getEntityDetail(projectId: string, entId: string): Promise<EntityDetail> {
const { data } = await api.get<EntityDetail>(`/projects/${projectId}/entities/entities/${entId}`)
return data
}
export async function evaluateImplProgress(projectId: string): Promise<ImplProgress[]> {
const { data } = await api.post<ImplProgress[]>(`/projects/${projectId}/impl-progress`)
return data
}
export async function getImplProgress(projectId: string): Promise<ImplProgress[]> {
const { data } = await api.get<ImplProgress[]>(`/projects/${projectId}/impl-progress`)
return data
}

View File

@ -0,0 +1,47 @@
<template>
<div class="graph-detail" v-if="node">
<div class="detail-header">
<h3>{{ node.label }}</h3>
<button @click="$emit('close')">&#10005;</button>
</div>
<div class="detail-body">
<div class="field"><span class="label">ID:</span> {{ node.id }}</div>
<div class="field"><span class="label">类型:</span> {{ node.type }}</div>
<div class="field"><span class="label">状态:</span> <span :style="{ color: statusColor }">{{ node.status }}</span></div>
<div class="field"><span class="label">分组:</span> {{ node.group_id }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { GraphNode } from '@/shared/types/api'
const props = defineProps<{ node: GraphNode | null }>()
defineEmits<{ close: [] }>()
const STATUS_COLORS: Record<string, string> = {
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
}
const statusColor = computed(() => STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E')
</script>
<style scoped>
.graph-detail {
position: fixed;
right: 0; top: 0; bottom: 0;
width: 320px;
background: white;
border-left: 1px solid #e0e0e0;
padding: 16px;
z-index: 100;
box-shadow: -4px 0 8px rgba(0,0,0,0.1);
}
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.detail-header h3 { font-size: 16px; }
.detail-header button { background: none; font-size: 18px; color: #666; }
.field { margin-bottom: 8px; font-size: 14px; }
.label { font-weight: 600; color: #666; }
</style>

View File

@ -0,0 +1,173 @@
<template>
<div class="graph-panorama">
<div v-if="loading" class="loading-overlay">扫描中...</div>
<div v-if="scanResult" class="scan-summary">
<div class="summary-item">文件 <strong>{{ scanResult.summary.total_files }}</strong></div>
<div class="summary-item" style="color: var(--color-ok)">OK <strong>{{ scanResult.summary.ok }}</strong></div>
<div class="summary-item" style="color: var(--color-sparse)">Sparse <strong>{{ scanResult.summary.sparse }}</strong></div>
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
</div>
<svg ref="svgRef" class="graph-svg"></svg>
<GraphDetail :node="selectedNode" @close="clearSelection" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import * as d3 from 'd3'
import { useGraphStore } from '../composables/useGraph'
import GraphDetail from './GraphDetail.vue'
import type { GraphNode, GraphEdge } from '@/shared/types/api'
const route = useRoute()
const store = useGraphStore()
const { graphView, selectedNode, scanResult, loading } = storeToRefs(store)
const { clearSelection } = store
const svgRef = ref<SVGSVGElement | null>(null)
const STATUS_COLORS: Record<string, string> = {
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
}
const EDGE_STYLES: Record<string, string> = {
traces_to: '0', depends_on: '6,3', owns: '0', integrates_with: '4,2', documents: '2,2',
}
function getNodeColor(status: string): string {
return STATUS_COLORS[status] || '#9E9E9E'
}
function drawGraph() {
if (!svgRef.value || !graphView.value) return
const svg = d3.select(svgRef.value)
svg.selectAll('*').remove()
const width = svgRef.value.clientWidth || 800
const height = svgRef.value.clientHeight || 600
svg.attr('width', width).attr('height', height)
const g = svg.append('g')
// Zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => { g.attr('transform', event.transform) })
svg.call(zoom)
const nodes = graphView.value.nodes.map(n => ({ ...n } as GraphNode & d3.SimulationNodeDatum))
const edges = graphView.value.edges.map(e => ({
...e,
source: e.source,
target: e.target,
}))
// Force simulation
const simulation = d3.forceSimulation(nodes as any)
.force('link', d3.forceLink(edges as any).id((d: any) => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30))
// Draw edges
const link = g.append('g')
.selectAll('line')
.data(edges)
.join('line')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', (d: any) => d.relation === 'owns' ? 3 : 1.5)
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
// Draw nodes
const node = g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<any, any>()
.on('start', (event, d: any) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x; d.fy = d.y
})
.on('drag', (event, d: any) => { d.fx = event.x; d.fy = event.y })
.on('end', (event, d: any) => {
if (!event.active) simulation.alphaTarget(0)
d.fx = null; d.fy = null
})
)
// Node shapes
node.each(function(this: any, d: any) {
const el = d3.select(this)
const color = getNodeColor(d.status)
if (d.type === 'module') {
el.append('rect').attr('width', 20).attr('height', 20).attr('x', -10).attr('y', -10)
.attr('fill', color).attr('rx', 3)
} else if (d.type === 'entity') {
el.append('polygon').attr('points', '0,-12 12,0 0,12 -12,0').attr('fill', color)
} else {
el.append('circle').attr('r', 10).attr('fill', color)
}
el.append('text').text(d.label).attr('x', 14).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
})
// Tooltip on hover
node.append('title').text((d: any) => `${d.id}\n类型: ${d.type}\n状态: ${d.status}`)
// Click -> select node
node.on('click', (_event: any, d: any) => { store.selectNode(d) })
// Double-click -> drill down
node.on('dblclick', (_event: any, d: any) => {
const projectId = route.params.id as string
store.loadNeighbors(projectId, d.id)
})
simulation.on('tick', () => {
link
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y)
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
})
}
onMounted(async () => {
const projectId = route.params.id as string
await store.loadGraph(projectId)
drawGraph()
})
watch(graphView, () => { drawGraph() })
</script>
<style scoped>
.graph-panorama { position: relative; height: calc(100vh - 48px); }
.graph-svg { width: 100%; height: 100%; }
.loading-overlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.8); font-size: 18px; color: #666;
}
.scan-summary {
position: absolute; top: 12px; right: 340px;
display: flex; gap: 12px;
background: white; padding: 8px 16px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
}
.summary-item { font-size: 13px; }
</style>

View File

@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { GraphView, GraphNode, ScanResult } from '@/shared/types/api'
import * as graphApi from '../api'
export const useGraphStore = defineStore('graph', () => {
const graphView = ref<GraphView | null>(null)
const selectedNode = ref<GraphNode | null>(null)
const scanResult = ref<ScanResult | null>(null)
const loading = ref(false)
async function loadGraph(projectId: string) {
loading.value = true
try {
scanResult.value = await graphApi.triggerScan(projectId)
graphView.value = await graphApi.getGraph(projectId)
} finally {
loading.value = false
}
}
function selectNode(node: GraphNode | null) {
selectedNode.value = node
}
async function loadNeighbors(projectId: string, nodeId: string) {
loading.value = true
try {
graphView.value = await graphApi.getNodeNeighbors(projectId, nodeId)
} finally {
loading.value = false
}
}
function clearSelection() {
selectedNode.value = null
}
return { graphView, selectedNode, scanResult, loading, loadGraph, selectNode, loadNeighbors, clearSelection }
})

View File

@ -0,0 +1 @@
export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api'