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:
parent
e386a59336
commit
846242ae1a
|
|
@ -1,47 +1,376 @@
|
|||
<template>
|
||||
<div class="graph-detail" v-if="node">
|
||||
<div class="detail-header">
|
||||
<h3>{{ node.label }}</h3>
|
||||
<button @click="$emit('close')">✕</button>
|
||||
<button class="close-btn" @click="emit('close')">✕ Close</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-identity">
|
||||
<div class="identity-row">
|
||||
<span class="node-id">● {{ node.id }}</span>
|
||||
<button v-if="sourceFile" class="edit-btn" @click="openEditor">[Edit]</button>
|
||||
</div>
|
||||
<div class="node-meta">
|
||||
{{ node.type }} · <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">—— Attributes ——</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">—— Related Entities ——</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' ? '→' : '←' }}</span>
|
||||
{{ rel.label }}
|
||||
<span class="relation-tag">({{ rel.relation }})</span>
|
||||
</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'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
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 }>()
|
||||
defineEmits<{ close: [] }>()
|
||||
const props = defineProps<{
|
||||
node: GraphNode | null
|
||||
graphView: GraphView | null
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
||||
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
||||
const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.graph-detail {
|
||||
position: fixed;
|
||||
right: 0; top: 0; bottom: 0;
|
||||
width: 320px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 360px;
|
||||
background: white;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user