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>
|
<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')">✕ Close</button>
|
||||||
<button @click="$emit('close')">✕</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">● {{ 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 }} · <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>
|
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user