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:
2026-03-21 22:13:12 +08:00
parent a27736a832
commit b024a2bcb5
25 changed files with 2628 additions and 1656 deletions

View 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>