feat(graph): rich detail panel with API fetch and edit button (GAP-F2, GAP-F5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openclaw 2026-03-24 08:56:03 +00:00
parent e386a59336
commit 846242ae1a

View File

@ -1,47 +1,376 @@
<template> <template>
<div class="graph-detail" v-if="node"> <div class="graph-detail" v-if="node">
<div class="detail-header"> <div class="detail-header">
<h3>{{ node.label }}</h3> <button class="close-btn" @click="emit('close')">&#10005; Close</button>
<button @click="$emit('close')">&#10005;</button>
</div> </div>
<div class="detail-body">
<div class="field"><span class="label">ID:</span> {{ node.id }}</div> <div class="detail-identity">
<div class="field"><span class="label">类型:</span> {{ node.type }}</div> <div class="identity-row">
<div class="field"><span class="label">状态:</span> <span :style="{ color: statusColor }">{{ node.status }}</span></div> <span class="node-id">&#9679; {{ node.id }}</span>
<div class="field"><span class="label">分组:</span> {{ node.group_id }}</div> <button v-if="sourceFile" class="edit-btn" @click="openEditor">[Edit]</button>
</div>
<div class="node-meta">
{{ node.type }} &middot; <span :style="{ color: statusColor }">{{ node.status }}</span>
</div>
</div>
<!-- Loading spinner -->
<div v-if="loading" class="loading-section">
<span class="spinner"></span> Loading details...
</div>
<!-- Error message -->
<div v-if="error" class="error-section">
{{ error }}
</div>
<!-- Attributes section -->
<div class="section">
<div class="section-title">&mdash;&mdash; Attributes &mdash;&mdash;</div>
<div v-for="(value, key) in displayAttributes" :key="key" class="field">
<span class="label">{{ key }}:</span> {{ formatValue(value) }}
</div>
</div>
<!-- Related Entities section -->
<div v-if="relatedEntities.length > 0" class="section">
<div class="section-title">&mdash;&mdash; Related Entities &mdash;&mdash;</div>
<div
v-for="rel in relatedEntities"
:key="rel.nodeId + rel.relation + rel.direction"
class="related-entity"
@click="onRelatedEntityClick(rel.nodeId)"
>
<span class="direction">{{ rel.direction === 'outgoing' ? '&rarr;' : '&larr;' }}</span>
{{ rel.label }}
<span class="relation-tag">({{ rel.relation }})</span>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, watch, computed } from 'vue'
import type { GraphNode } from '@/shared/types/api' import { useRouter } from 'vue-router'
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
import type { GraphNode, GraphView } from '@/shared/types/api'
const props = defineProps<{ node: GraphNode | null }>() const props = defineProps<{
defineEmits<{ close: [] }>() node: GraphNode | null
graphView: GraphView | null
projectId: string
}>()
const STATUS_COLORS: Record<string, string> = { const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E', const router = useRouter()
const SOURCE_FILES: Record<string, string> = {
capability: 'business-architecture/02-capability-map.csv',
module: 'application-architecture/02-modules.csv',
entity: 'data-architecture/01-entities.csv',
runtime_component: 'technology-architecture/01-runtime-components.csv',
} }
const statusColor = computed(() => STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E') const STATUS_COLORS: Record<string, string> = {
ok: '#4CAF50',
sparse: '#FFC107',
missing: '#F44336',
'template-residue': '#FF9800',
'placeholder-heavy': '#9C27B0',
unknown: '#9E9E9E',
}
const loading = ref(false)
const error = ref<string | null>(null)
const detailData = ref<Record<string, unknown> | null>(null)
const statusColor = computed(() =>
STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E',
)
const sourceFile = computed(() => {
if (!props.node) return null
return SOURCE_FILES[props.node.type] || null
})
const displayAttributes = computed<Record<string, unknown>>(() => {
if (detailData.value) {
return detailData.value
}
// Fallback: basic fields from node
if (!props.node) return {}
return {
id: props.node.id,
type: props.node.type,
status: props.node.status,
group_id: props.node.group_id,
}
})
interface RelatedEntity {
nodeId: string
label: string
relation: string
direction: 'outgoing' | 'incoming'
}
const relatedEntities = computed<RelatedEntity[]>(() => {
if (!props.node || !props.graphView) return []
const nodeId = props.node.id
const result: RelatedEntity[] = []
for (const edge of props.graphView.edges) {
if (edge.source === nodeId) {
const targetNode = props.graphView.nodes.find(n => n.id === edge.target)
result.push({
nodeId: edge.target,
label: targetNode ? `${targetNode.id} ${targetNode.label}` : edge.target,
relation: edge.relation,
direction: 'outgoing',
})
} else if (edge.target === nodeId) {
const sourceNode = props.graphView.nodes.find(n => n.id === edge.source)
result.push({
nodeId: edge.source,
label: sourceNode ? `${sourceNode.id} ${sourceNode.label}` : edge.source,
relation: edge.relation,
direction: 'incoming',
})
}
}
return result
})
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-'
if (Array.isArray(value)) return value.join(', ')
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function extractAttributes(data: Record<string, unknown>): Record<string, unknown> {
// The detail API returns a wrapper like { capability: {...}, modules: [...] }
// We want to extract the primary entity's fields as attributes
const attrs: Record<string, unknown> = {}
for (const [key, val] of Object.entries(data)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
// This is the primary entity object flatten its fields
for (const [innerKey, innerVal] of Object.entries(val as Record<string, unknown>)) {
attrs[innerKey] = innerVal
}
}
// Skip arrays (they are related collections, shown in related entities)
}
// If no nested objects found, just use all fields
if (Object.keys(attrs).length === 0) {
Object.assign(attrs, data)
}
return attrs
}
async function fetchDetail() {
if (!props.node || !props.projectId) return
const nodeType = props.node.type
const nodeId = props.node.id
// Only fetch detail for types that have detail APIs
if (!['capability', 'module', 'entity'].includes(nodeType)) {
detailData.value = null
return
}
loading.value = true
error.value = null
detailData.value = null
try {
let data: Record<string, unknown>
switch (nodeType) {
case 'capability':
data = await getCapabilityDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
break
case 'module':
data = await getModuleDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
break
case 'entity':
data = await getEntityDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
break
default:
return
}
detailData.value = extractAttributes(data)
} catch (err) {
error.value = 'Failed to load detail. Showing basic fields.'
detailData.value = null
} finally {
loading.value = false
}
}
watch(
() => props.node,
() => {
fetchDetail()
},
{ immediate: true },
)
function onRelatedEntityClick(nodeId: string) {
const found = props.graphView?.nodes.find(n => n.id === nodeId)
if (found) emit('selectNode', found)
}
function openEditor() {
if (!sourceFile.value) return
router.push({
path: `/projects/${props.projectId}/editor`,
query: { file: sourceFile.value },
})
}
</script> </script>
<style scoped> <style scoped>
.graph-detail { .graph-detail {
position: fixed; position: fixed;
right: 0; top: 0; bottom: 0; right: 0;
width: 320px; top: 0;
bottom: 0;
width: 360px;
background: white; background: white;
border-left: 1px solid #e0e0e0; border-left: 1px solid #e0e0e0;
padding: 16px; padding: 16px;
z-index: 100; z-index: 100;
box-shadow: -4px 0 8px rgba(0,0,0,0.1); box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
.detail-header {
margin-bottom: 16px;
}
.close-btn {
background: none;
border: none;
font-size: 14px;
color: #666;
cursor: pointer;
padding: 4px 8px;
}
.close-btn:hover {
color: #333;
}
.detail-identity {
margin-bottom: 16px;
}
.identity-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.node-id {
font-size: 16px;
font-weight: 600;
}
.edit-btn {
background: none;
border: 1px solid #1976d2;
color: #1976d2;
border-radius: 4px;
padding: 4px 12px;
font-size: 13px;
cursor: pointer;
}
.edit-btn:hover {
background: #e3f2fd;
}
.node-meta {
font-size: 13px;
color: #888;
}
.loading-section {
padding: 12px 0;
font-size: 13px;
color: #888;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #ccc;
border-top-color: #1976d2;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-section {
padding: 8px 0;
font-size: 13px;
color: #e65100;
}
.section {
margin-bottom: 16px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: #999;
margin-bottom: 8px;
}
.field {
margin-bottom: 6px;
font-size: 13px;
word-break: break-word;
}
.field .label {
font-weight: 600;
color: #666;
}
.related-entity {
padding: 6px 8px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
margin-bottom: 2px;
}
.related-entity:hover {
background: #f5f5f5;
}
.direction {
margin-right: 4px;
}
.relation-tag {
color: #999;
font-size: 12px;
margin-left: 4px;
} }
.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> </style>