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:
parent
d8071bc9f3
commit
b7ebbcd777
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="graph-detail" v-if="node">
|
||||
<div class="detail-header">
|
||||
<h3>{{ node.label }}</h3>
|
||||
<button @click="$emit('close')">✕</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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api'
|
||||
Loading…
Reference in New Issue
Block a user