feat: 修复 7 个 Gap — 图分组布局、status 映射、详情面板丰富等 #2

Open
openclaw wants to merge 11 commits from feat/v2-fix-gaps into feat/full-implementation
Showing only changes of commit aa8d495a92 - Show all commits

View File

@ -9,6 +9,12 @@
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
</div>
<div class="toolbar">
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
{{ showDocumentView ? '默认视图' : '文档视图' }}
</button>
</div>
<svg ref="svgRef" class="graph-svg"></svg>
<GraphDetail :node="selectedNode" @close="clearSelection" />
@ -31,19 +37,48 @@ const { clearSelection } = store
const svgRef = ref<SVGSVGElement | null>(null)
const showDocumentView = ref(false)
const isDrillDown = ref(false)
const drillDownNodeLabel = ref('')
const STATUS_COLORS: Record<string, string> = {
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
}
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
business: { x: 0.50, y: 0.15 },
application: { x: 0.50, y: 0.38 },
data: { x: 0.30, y: 0.65 },
technology: { x: 0.70, y: 0.65 },
'cross-layer': { x: 0.50, y: 0.85 },
}
const EDGE_COLORS: Record<string, string> = {
traces_to: '#666',
depends_on: '#999',
documents: '#42A5F5',
integrates_with: '#AB47BC',
}
const EDGE_STYLES: Record<string, string> = {
traces_to: '0', depends_on: '6,3', owns: '0', integrates_with: '4,2', documents: '2,2',
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
}
function getNodeColor(status: string): string {
return STATUS_COLORS[status] || '#9E9E9E'
}
interface SimNode extends GraphNode, d3.SimulationNodeDatum {
w?: number
h?: number
}
function toggleDocumentView() {
showDocumentView.value = !showDocumentView.value
drawGraph()
}
function drawGraph() {
if (!svgRef.value || !graphView.value) return
@ -63,34 +98,83 @@ function drawGraph() {
.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 => ({
// Filter nodes and edges based on view mode
let filteredNodes: GraphNode[]
let filteredEdges: GraphEdge[]
if (showDocumentView.value) {
filteredNodes = graphView.value.nodes.map(n => ({ ...n }))
filteredEdges = graphView.value.edges.map(e => ({ ...e }))
} else {
filteredNodes = graphView.value.nodes.filter(n => n.type !== 'document').map(n => ({ ...n }))
filteredEdges = graphView.value.edges.filter(e => e.relation !== 'documents').map(e => ({ ...e }))
}
const simNodes: SimNode[] = filteredNodes.map(n => ({ ...n } as SimNode))
// Build node map for parent lookups
const nodeMap: Record<string, SimNode> = {}
for (const n of simNodes) {
nodeMap[n.id] = n
}
// For document view, compute document container sizes
if (showDocumentView.value) {
for (const n of simNodes) {
if (n.type === 'document') {
const childCount = simNodes.filter(c => c.parent === n.id).length
n.w = Math.max(150, childCount * 60)
n.h = Math.max(100, childCount * 40)
}
}
}
const simEdges = filteredEdges.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))
// Force simulation with group-partitioned layout
const simulation = d3.forceSimulation(simNodes as any)
.force('link', d3.forceLink(simEdges as any).id((d: any) => d.id).distance(60))
.force('charge', d3.forceManyBody().strength(-150))
.force('x', d3.forceX((d: any) =>
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
).strength(0.4))
.force('y', d3.forceY((d: any) =>
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).y * height
).strength(0.4))
.force('collide', d3.forceCollide(30))
// Render group labels
const groupLabels = g.append('g').attr('class', 'group-labels')
for (const [groupId, pos] of Object.entries(GROUP_POSITIONS)) {
groupLabels.append('text')
.attr('x', pos.x * width)
.attr('y', pos.y * height - 40)
.attr('text-anchor', 'middle')
.attr('font-size', '14px')
.attr('font-weight', 'bold')
.attr('fill', '#aaa')
.attr('pointer-events', 'none')
.text(groupId)
}
// Draw edges
const link = g.append('g')
.selectAll('line')
.data(edges)
.data(simEdges)
.join('line')
.attr('stroke', '#999')
.attr('stroke', (d: any) => EDGE_COLORS[d.relation] || '#999')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', (d: any) => d.relation === 'owns' ? 3 : 1.5)
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
// Draw nodes
const node = g.append('g')
.selectAll('g')
.data(nodes)
.data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<any, any>()
@ -110,17 +194,48 @@ function drawGraph() {
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)
if (d.type === 'document') {
// Document container node (only in document view)
el.append('rect')
.attr('width', d.w || 150)
.attr('height', d.h || 100)
.attr('x', -(d.w || 150) / 2)
.attr('y', -(d.h || 100) / 2)
.attr('fill', 'rgba(66, 165, 245, 0.08)')
.attr('stroke', '#42A5F5')
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', '6,3')
.attr('rx', 8)
el.append('text').text(d.label)
.attr('x', 0)
.attr('y', -(d.h || 100) / 2 + 16)
.attr('text-anchor', 'middle')
.attr('font-size', '12px')
.attr('font-weight', 'bold')
.attr('fill', '#42A5F5')
} else if (d.type === 'capability') {
el.append('circle').attr('r', 18).attr('fill', color)
el.append('text').text(d.label).attr('x', 22).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
} else if (d.type === 'module') {
el.append('rect').attr('width', 28).attr('height', 28).attr('x', -14).attr('y', -14)
.attr('fill', color).attr('rx', 3)
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
} else if (d.type === 'entity') {
el.append('polygon').attr('points', '0,-12 12,0 0,12 -12,0').attr('fill', color)
el.append('polygon').attr('points', '0,-24 24,0 0,24 -24,0').attr('fill', color)
el.append('text').text(d.label).attr('x', 28).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
} else if (d.type === 'runtime_component') {
el.append('circle').attr('r', 14).attr('fill', color)
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
} else {
el.append('circle').attr('r', 10).attr('fill', color)
// fallback
el.append('circle').attr('r', 14).attr('fill', color)
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
}
el.append('text').text(d.label).attr('x', 14).attr('y', 4)
.attr('font-size', '11px').attr('fill', '#333')
})
// Tooltip on hover
@ -136,6 +251,20 @@ function drawGraph() {
})
simulation.on('tick', () => {
// In document view, clamp child nodes inside parent bounds
if (showDocumentView.value) {
for (const n of simNodes) {
if (n.parent) {
const p = nodeMap[n.parent]
if (p && p.w && p.h) {
const pad = 20
n.x = Math.max(p.x! - p.w / 2 + pad, Math.min(p.x! + p.w / 2 - pad, n.x!))
n.y = Math.max(p.y! - p.h / 2 + pad, Math.min(p.y! + p.h / 2 - pad, n.y!))
}
}
}
}
link
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
@ -170,4 +299,26 @@ watch(graphView, () => { drawGraph() })
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
}
.summary-item { font-size: 13px; }
.toolbar {
position: absolute; top: 12px; left: 12px;
display: flex; gap: 8px;
z-index: 10;
}
.toggle-btn {
padding: 6px 16px;
border: 1px solid #ccc;
border-radius: 6px;
background: white;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
background: #f5f5f5;
}
.toggle-btn.active {
background: #42A5F5;
color: white;
border-color: #42A5F5;
}
</style>