refactor(frontend): move views into app and pages structure
Reorganize the frontend around app-level routing and page modules so the runtime and feature screens share a clearer navigation and composition layout for future work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
468
frontend/src/pages/graph/index.vue
Normal file
468
frontend/src/pages/graph/index.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { graphApi } from '@/api/graph'
|
||||
import { Network, RefreshCw, Info, Hexagon } from 'lucide-vue-next'
|
||||
import type { KGNode, KGEdge } from '@/api/graph'
|
||||
|
||||
const nodes = ref<KGNode[]>([])
|
||||
const edges = ref<KGEdge[]>([])
|
||||
const stats = ref({ node_count: 0, edge_count: 0 })
|
||||
const isLoading = ref(false)
|
||||
const selectedEntity = ref<KGNode | null>(null)
|
||||
const entityContext = ref('')
|
||||
const isBuilding = ref(false)
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
person: '#f87171', concept: '#60a5fa', topic: '#a78bfa',
|
||||
task: '#fbbf24', event: '#fb923c', document: '#9ca3af', default: '#4b5563',
|
||||
}
|
||||
|
||||
function getColor(type: string) { return typeColors[type] || typeColors.default }
|
||||
|
||||
async function loadGraph() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await graphApi.get()
|
||||
nodes.value = response.data.nodes
|
||||
edges.value = response.data.edges
|
||||
stats.value = response.data.stats
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch (e) { console.error('加载图谱失败:', e) }
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
async function buildGraph() {
|
||||
isBuilding.value = true
|
||||
try {
|
||||
await graphApi.build()
|
||||
setTimeout(() => loadGraph(), 2000)
|
||||
} catch (e) { console.error('构建失败:', e) }
|
||||
isBuilding.value = false
|
||||
}
|
||||
|
||||
async function selectEntity(node: KGNode) {
|
||||
selectedEntity.value = node
|
||||
entityContext.value = 'LOADING...'
|
||||
try {
|
||||
const response = await graphApi.getEntityContext(node.name)
|
||||
entityContext.value = response.data.context
|
||||
} catch (e) { entityContext.value = 'Failed to load context' }
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value) return
|
||||
// @ts-ignore
|
||||
if (!window.echarts) return
|
||||
// @ts-ignore
|
||||
const echarts = window.echarts
|
||||
const chart = echarts.init(chartRef.value, 'dark')
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(10, 15, 26, 0.95)',
|
||||
borderColor: 'rgba(0, 245, 212, 0.2)',
|
||||
textStyle: { color: '#e8f4f8', fontFamily: 'JetBrains Mono, monospace', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
return `<b style="color:#00f5d4">${params.data.name}</b><br/><span style="color:#7eb8c9">Type: ${params.data.type || 'unknown'}</span>`
|
||||
}
|
||||
return `<span style="color:#7eb8c9">${params.data.sourceName}</span> → <span style="color:#a78bfa">${params.data.relation}</span> → <span style="color:#7eb8c9">${params.data.targetName}</span>`
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
symbolSize: (_val: unknown, params: any) => 18 + (params.data.importance || 0.5) * 40,
|
||||
roam: true,
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 10,
|
||||
color: '#7eb8c9',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (params: any) => params.data.name?.substring(0, 14) || '',
|
||||
},
|
||||
lineStyle: { color: 'rgba(0, 245, 212, 0.15)', width: 1.5 },
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: { width: 3, color: 'rgba(0, 245, 212, 0.5)' },
|
||||
},
|
||||
edgeSymbol: ['circle', 'arrow'],
|
||||
edgeSymbolSize: [4, 8],
|
||||
data: nodes.value.map(n => ({
|
||||
id: n.id, name: n.name, type: n.type,
|
||||
importance: n.importance || 0.5,
|
||||
itemStyle: {
|
||||
color: getColor(n.type),
|
||||
borderColor: getColor(n.type),
|
||||
borderWidth: 2,
|
||||
shadowColor: getColor(n.type),
|
||||
shadowBlur: 8,
|
||||
},
|
||||
})),
|
||||
links: edges.value.map(e => {
|
||||
const src = nodes.value.find(n => n.id === e.source)
|
||||
const tgt = nodes.value.find(n => n.id === e.target)
|
||||
return {
|
||||
source: e.source, target: e.target,
|
||||
sourceName: src?.name || '', targetName: tgt?.name || '',
|
||||
relation: e.relation,
|
||||
lineStyle: { color: 'rgba(0, 245, 212, 0.15)' },
|
||||
}
|
||||
}),
|
||||
force: { repulsion: 150, gravity: 0.05, edgeLength: [60, 200], layoutAnimation: true },
|
||||
}],
|
||||
}
|
||||
chart.setOption(option)
|
||||
chart.on('click', (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
const node = nodes.value.find(n => n.id === params.data.id)
|
||||
if (node) selectEntity(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGraph()
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js'
|
||||
script.onload = () => renderChart()
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-view">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon"><Hexagon :size="20" /></div>
|
||||
<div class="header-text">
|
||||
<h1>KNOWLEDGE GRAPH</h1>
|
||||
<span class="header-sub">{{ stats.node_count }} nodes · {{ stats.edge_count }} relations</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="build-btn" @click="buildGraph" :disabled="isBuilding">
|
||||
<RefreshCw :size="14" :class="{ spin: isBuilding }" />
|
||||
{{ isBuilding ? 'BUILDING...' : 'REBUILD' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Type legend -->
|
||||
<div class="type-legend">
|
||||
<div v-for="(color, type) in typeColors" :key="type" class="legend-item">
|
||||
<div class="legend-dot" :style="{ background: color, boxShadow: `0 0 6px ${color}` }"></div>
|
||||
<span>{{ type.toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main area: chart + panel -->
|
||||
<div class="main-area">
|
||||
<div class="graph-container">
|
||||
<div v-if="nodes.length === 0 && !isLoading" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<div class="e-ring r1"></div>
|
||||
<div class="e-ring r2"></div>
|
||||
<Network :size="32" />
|
||||
</div>
|
||||
<div class="empty-title">NO GRAPH DATA</div>
|
||||
<div class="empty-sub">Upload documents and rebuild the graph</div>
|
||||
</div>
|
||||
<div ref="chartRef" v-show="nodes.length > 0" class="chart-canvas"></div>
|
||||
</div>
|
||||
|
||||
<!-- Entity panel -->
|
||||
<div class="entity-panel" v-if="selectedEntity">
|
||||
<div class="panel-title">
|
||||
<Info :size="14" />
|
||||
<span>ENTITY DETAIL</span>
|
||||
<button class="close-panel" @click="selectedEntity = null">×</button>
|
||||
</div>
|
||||
<div class="entity-name" :style="{ color: getColor(selectedEntity.type) }">
|
||||
{{ selectedEntity.name }}
|
||||
</div>
|
||||
<div class="entity-type-tag" :style="{ color: getColor(selectedEntity.type), borderColor: getColor(selectedEntity.type) + '40' }">
|
||||
{{ (selectedEntity.type || 'unknown').toUpperCase() }}
|
||||
</div>
|
||||
<div class="entity-context">{{ entityContext }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relation list -->
|
||||
<div class="relations-section" v-if="edges.length > 0">
|
||||
<div class="section-label">// RELATIONS ({{ edges.length }})</div>
|
||||
<div class="relations-scroll">
|
||||
<div v-for="edge in edges" :key="edge.id" class="rel-item">
|
||||
<span class="rel-node">{{ nodes.find(n => n.id === edge.source)?.name?.slice(0, 16) || edge.source.slice(0, 8) }}</span>
|
||||
<div class="rel-arrow">
|
||||
<div class="arrow-line"></div>
|
||||
<span class="rel-type-badge">{{ edge.relation }}</span>
|
||||
</div>
|
||||
<span class="rel-node">{{ nodes.find(n => n.id === edge.target)?.name?.slice(0, 16) || edge.target.slice(0, 8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.header-icon { color: var(--accent-cyan); filter: drop-shadow(0 0 8px var(--accent-cyan)); }
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
|
||||
|
||||
.build-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.build-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 245, 212, 0.2);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
|
||||
.type-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
position: relative;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.e-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
opacity: 0.2;
|
||||
}
|
||||
.r1 { width: 60px; height: 60px; animation: spin 6s linear infinite; border-style: dashed; }
|
||||
.r2 { width: 40px; height: 40px; animation: spin 4s linear infinite reverse; }
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Entity panel */
|
||||
.entity-panel {
|
||||
width: 260px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
animation: fade-in-up 0.2s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.close-panel {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.close-panel:hover { color: var(--accent-red); }
|
||||
|
||||
.entity-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.entity-type-tag {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.entity-context {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Relations */
|
||||
.relations-section { }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.relations-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.rel-node { color: var(--accent-cyan); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
.rel-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border-mid), var(--accent-cyan), var(--border-mid));
|
||||
}
|
||||
|
||||
.rel-type-badge {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-purple);
|
||||
padding: 1px 5px;
|
||||
background: var(--accent-purple-dim);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user