Files
microfish/frontend/src/views/Process.vue
666ghj 23927dc64b Enhance graph visualization and data polling in Process.vue
- Added real-time graph data visualization with loading and waiting states to improve user feedback during graph construction.
- Implemented a polling mechanism to fetch graph data every 3 seconds, ensuring the graph updates dynamically as data becomes available.
- Improved error handling and rendering logic to manage different graph states effectively.
- Enhanced styling for loading indicators and graph elements for better user experience.
2025-12-10 18:34:49 +08:00

1549 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="process-page">
<!-- 顶部导航栏 -->
<nav class="navbar">
<div class="nav-brand" @click="goHome">MIROFISH</div>
<div class="nav-step">
<span class="step-badge">STEP 01</span>
<span class="step-name">图谱构建</span>
</div>
<div class="nav-status">
<span class="status-dot" :class="statusClass"></span>
<span class="status-text">{{ statusText }}</span>
</div>
</nav>
<!-- 主内容区 -->
<div class="main-content">
<!-- 左侧: 实时图谱展示 -->
<div class="left-panel">
<div class="panel-header">
<div class="header-left">
<span class="header-icon"></span>
<span class="header-title">实时知识图谱</span>
</div>
<div class="header-right" v-if="graphData">
<span class="stat-item">{{ graphData.node_count }} 节点</span>
<span class="stat-divider">|</span>
<span class="stat-item">{{ graphData.edge_count }} 关系</span>
</div>
</div>
<div class="graph-container" ref="graphContainer">
<!-- 图谱可视化只要有数据就显示 -->
<div v-if="graphData" class="graph-view">
<svg ref="graphSvg" class="graph-svg"></svg>
<!-- 构建中提示 -->
<div v-if="currentPhase === 1" class="graph-building-hint">
<span class="building-dot"></span>
实时更新中...
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="graphLoading" class="graph-loading">
<div class="loading-animation">
<div class="loading-ring"></div>
<div class="loading-ring"></div>
<div class="loading-ring"></div>
</div>
<p class="loading-text">图谱数据加载中...</p>
</div>
<!-- 等待构建 -->
<div v-else-if="currentPhase < 1" class="graph-waiting">
<div class="waiting-icon">
<svg viewBox="0 0 100 100" class="network-icon">
<circle cx="50" cy="20" r="8" fill="none" stroke="#000" stroke-width="1.5"/>
<circle cx="20" cy="60" r="8" fill="none" stroke="#000" stroke-width="1.5"/>
<circle cx="80" cy="60" r="8" fill="none" stroke="#000" stroke-width="1.5"/>
<circle cx="50" cy="80" r="8" fill="none" stroke="#000" stroke-width="1.5"/>
<line x1="50" y1="28" x2="25" y2="54" stroke="#000" stroke-width="1"/>
<line x1="50" y1="28" x2="75" y2="54" stroke="#000" stroke-width="1"/>
<line x1="28" y1="60" x2="72" y2="60" stroke="#000" stroke-width="1" stroke-dasharray="4"/>
<line x1="50" y1="72" x2="26" y2="66" stroke="#000" stroke-width="1"/>
<line x1="50" y1="72" x2="74" y2="66" stroke="#000" stroke-width="1"/>
</svg>
</div>
<p class="waiting-text">等待本体生成</p>
<p class="waiting-hint">生成完成后将自动开始构建图谱</p>
</div>
<!-- 构建中但还没有数据 -->
<div v-else-if="currentPhase === 1 && !graphData" class="graph-waiting">
<div class="loading-animation">
<div class="loading-ring"></div>
<div class="loading-ring"></div>
<div class="loading-ring"></div>
</div>
<p class="waiting-text">图谱构建中</p>
<p class="waiting-hint">数据即将显示...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="graph-error">
<span class="error-icon"></span>
<p>{{ error }}</p>
</div>
</div>
<!-- 图谱图例 -->
<div v-if="graphData" class="graph-legend">
<div class="legend-item" v-for="type in entityTypes" :key="type.name">
<span class="legend-dot" :style="{ background: type.color }"></span>
<span class="legend-label">{{ type.name }}</span>
<span class="legend-count">{{ type.count }}</span>
</div>
</div>
</div>
<!-- 右侧: 构建流程详情 -->
<div class="right-panel">
<div class="panel-header">
<span class="header-icon"></span>
<span class="header-title">构建流程</span>
</div>
<div class="process-content">
<!-- 阶段1: 本体生成 -->
<div class="process-phase" :class="{ 'active': currentPhase === 0, 'completed': currentPhase > 0 }">
<div class="phase-header">
<span class="phase-num">01</span>
<div class="phase-info">
<div class="phase-title">本体生成</div>
<div class="phase-api">/api/graph/ontology/generate</div>
</div>
<span class="phase-status" :class="getPhaseStatusClass(0)">
{{ getPhaseStatusText(0) }}
</span>
</div>
<div class="phase-detail">
<div class="detail-section">
<div class="detail-label">接口说明</div>
<div class="detail-content">
上传文档后LLM分析文档内容自动生成适合舆论模拟的本体结构实体类型 + 关系类型
</div>
</div>
<!-- 本体生成进度 -->
<div class="detail-section" v-if="ontologyProgress && currentPhase === 0">
<div class="detail-label">生成进度</div>
<div class="ontology-progress">
<div class="progress-spinner"></div>
<span class="progress-text">{{ ontologyProgress.message }}</span>
</div>
</div>
<!-- 已生成的本体信息 -->
<div class="detail-section" v-if="projectData?.ontology">
<div class="detail-label">生成的实体类型 ({{ projectData.ontology.entity_types?.length || 0 }})</div>
<div class="entity-tags">
<span
v-for="entity in projectData.ontology.entity_types"
:key="entity.name"
class="entity-tag"
>
{{ entity.name }}
</span>
</div>
</div>
<div class="detail-section" v-if="projectData?.ontology">
<div class="detail-label">生成的关系类型 ({{ projectData.ontology.relation_types?.length || 0 }})</div>
<div class="relation-list">
<div
v-for="(rel, idx) in projectData.ontology.relation_types?.slice(0, 5) || []"
:key="idx"
class="relation-item"
>
<span class="rel-source">{{ rel.source_type }}</span>
<span class="rel-arrow"></span>
<span class="rel-name">{{ rel.name }}</span>
<span class="rel-arrow"></span>
<span class="rel-target">{{ rel.target_type }}</span>
</div>
<div v-if="(projectData.ontology.relation_types?.length || 0) > 5" class="relation-more">
+{{ projectData.ontology.relation_types.length - 5 }} 更多关系...
</div>
</div>
</div>
<!-- 等待状态 -->
<div class="detail-section waiting-state" v-if="!projectData?.ontology && currentPhase === 0 && !ontologyProgress">
<div class="waiting-hint">等待本体生成...</div>
</div>
</div>
</div>
<!-- 阶段2: 图谱构建 -->
<div class="process-phase" :class="{ 'active': currentPhase === 1, 'completed': currentPhase > 1 }">
<div class="phase-header">
<span class="phase-num">02</span>
<div class="phase-info">
<div class="phase-title">图谱构建</div>
<div class="phase-api">/api/graph/build</div>
</div>
<span class="phase-status" :class="getPhaseStatusClass(1)">
{{ getPhaseStatusText(1) }}
</span>
</div>
<div class="phase-detail">
<div class="detail-section">
<div class="detail-label">接口说明</div>
<div class="detail-content">
基于生成的本体将文档分块后调用 Zep API 构建知识图谱提取实体和关系
</div>
</div>
<!-- 等待本体完成 -->
<div class="detail-section waiting-state" v-if="currentPhase < 1">
<div class="waiting-hint">等待本体生成完成...</div>
</div>
<!-- 构建进度 -->
<div class="detail-section" v-if="buildProgress && currentPhase >= 1">
<div class="detail-label">构建进度</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: buildProgress.progress + '%' }"></div>
</div>
<div class="progress-info">
<span class="progress-message">{{ buildProgress.message }}</span>
<span class="progress-percent">{{ buildProgress.progress }}%</span>
</div>
</div>
<div class="detail-section" v-if="graphData">
<div class="detail-label">构建结果</div>
<div class="build-result">
<div class="result-item">
<span class="result-value">{{ graphData.node_count }}</span>
<span class="result-label">实体节点</span>
</div>
<div class="result-item">
<span class="result-value">{{ graphData.edge_count }}</span>
<span class="result-label">关系边</span>
</div>
<div class="result-item">
<span class="result-value">{{ entityTypes.length }}</span>
<span class="result-label">实体类型</span>
</div>
</div>
</div>
</div>
</div>
<!-- 阶段3: 完成 -->
<div class="process-phase" :class="{ 'active': currentPhase === 2, 'completed': currentPhase > 2 }">
<div class="phase-header">
<span class="phase-num">03</span>
<div class="phase-info">
<div class="phase-title">构建完成</div>
<div class="phase-api">准备进入下一步骤</div>
</div>
<span class="phase-status" :class="getPhaseStatusClass(2)">
{{ getPhaseStatusText(2) }}
</span>
</div>
</div>
<!-- 下一步按钮 -->
<div class="next-step-section" v-if="currentPhase >= 2">
<button class="next-step-btn" @click="goToNextStep" :disabled="currentPhase < 2">
进入环境搭建
<span class="btn-arrow"></span>
</button>
</div>
</div>
<!-- 项目信息面板 -->
<div class="project-panel">
<div class="project-header">
<span class="project-icon"></span>
<span class="project-title">项目信息</span>
</div>
<div class="project-details" v-if="projectData">
<div class="project-item">
<span class="item-label">项目名称</span>
<span class="item-value">{{ projectData.name }}</span>
</div>
<div class="project-item">
<span class="item-label">项目ID</span>
<span class="item-value code">{{ projectData.project_id }}</span>
</div>
<div class="project-item" v-if="projectData.graph_id">
<span class="item-label">图谱ID</span>
<span class="item-value code">{{ projectData.graph_id }}</span>
</div>
<div class="project-item">
<span class="item-label">模拟需求</span>
<span class="item-value">{{ projectData.simulation_requirement || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'
import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
import * as d3 from 'd3'
const route = useRoute()
const router = useRouter()
// 当前项目ID可能从'new'变为实际ID
const currentProjectId = ref(route.params.projectId)
// 状态
const loading = ref(true)
const graphLoading = ref(false)
const error = ref('')
const projectData = ref(null)
const graphData = ref(null)
const buildProgress = ref(null)
const ontologyProgress = ref(null) // 本体生成进度
const currentPhase = ref(-1) // -1: 上传中, 0: 本体生成中, 1: 图谱构建, 2: 完成
// DOM引用
const graphContainer = ref(null)
const graphSvg = ref(null)
// 轮询定时器
let pollTimer = null
// 计算属性
const statusClass = computed(() => {
if (error.value) return 'error'
if (currentPhase.value >= 2) return 'completed'
return 'processing'
})
const statusText = computed(() => {
if (error.value) return '构建失败'
if (currentPhase.value >= 2) return '构建完成'
if (currentPhase.value === 1) return '图谱构建中'
if (currentPhase.value === 0) return '本体生成中'
return '初始化中'
})
const entityTypes = computed(() => {
if (!graphData.value?.nodes) return []
const typeMap = {}
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C']
graphData.value.nodes.forEach(node => {
const type = node.labels?.find(l => l !== 'Entity') || 'Entity'
if (!typeMap[type]) {
typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }
}
typeMap[type].count++
})
return Object.values(typeMap)
})
// 方法
const goHome = () => {
router.push('/')
}
const goToNextStep = () => {
// TODO: 进入环境搭建步骤
alert('环境搭建功能开发中...')
}
const getPhaseStatusClass = (phase) => {
if (currentPhase.value > phase) return 'completed'
if (currentPhase.value === phase) return 'active'
return 'pending'
}
const getPhaseStatusText = (phase) => {
if (currentPhase.value > phase) return '已完成'
if (currentPhase.value === phase) {
if (phase === 1 && buildProgress.value) {
return `${buildProgress.value.progress}%`
}
return '进行中'
}
return '等待中'
}
// 初始化 - 处理新建项目或加载已有项目
const initProject = async () => {
const paramProjectId = route.params.projectId
if (paramProjectId === 'new') {
// 新建项目:从 store 获取待上传的数据
await handleNewProject()
} else {
// 加载已有项目
currentProjectId.value = paramProjectId
await loadProject()
}
}
// 处理新建项目 - 调用 ontology/generate API
const handleNewProject = async () => {
const pending = getPendingUpload()
if (!pending.isPending || pending.files.length === 0) {
error.value = '没有待上传的文件,请返回首页重新操作'
loading.value = false
return
}
try {
loading.value = true
currentPhase.value = 0 // 本体生成阶段
ontologyProgress.value = { message: '正在上传文件并分析文档...' }
// 构建 FormData
const formDataObj = new FormData()
pending.files.forEach(file => {
formDataObj.append('files', file)
})
formDataObj.append('simulation_requirement', pending.simulationRequirement)
// 调用本体生成 API
const response = await generateOntology(formDataObj)
if (response.success) {
// 清除待上传数据
clearPendingUpload()
// 更新项目ID和数据
currentProjectId.value = response.data.project_id
projectData.value = response.data
// 更新URL不刷新页面
router.replace({
name: 'Process',
params: { projectId: response.data.project_id }
})
ontologyProgress.value = null
// 自动开始图谱构建
await startBuildGraph()
} else {
error.value = response.error || '本体生成失败'
}
} catch (err) {
console.error('Handle new project error:', err)
error.value = '项目初始化失败: ' + (err.message || '未知错误')
} finally {
loading.value = false
}
}
// 加载已有项目数据
const loadProject = async () => {
try {
loading.value = true
const response = await getProject(currentProjectId.value)
if (response.success) {
projectData.value = response.data
updatePhaseByStatus(response.data.status)
// 自动开始图谱构建
if (response.data.status === 'ontology_generated' && !response.data.graph_id) {
await startBuildGraph()
}
// 继续轮询构建中的任务
if (response.data.status === 'graph_building' && response.data.graph_build_task_id) {
currentPhase.value = 1
startPollingTask(response.data.graph_build_task_id)
}
// 加载已完成的图谱
if (response.data.status === 'graph_completed' && response.data.graph_id) {
currentPhase.value = 2
await loadGraph(response.data.graph_id)
}
} else {
error.value = response.error || '加载项目失败'
}
} catch (err) {
console.error('Load project error:', err)
error.value = '加载项目失败: ' + (err.message || '未知错误')
} finally {
loading.value = false
}
}
const updatePhaseByStatus = (status) => {
switch (status) {
case 'created':
case 'ontology_generated':
currentPhase.value = 0
break
case 'graph_building':
currentPhase.value = 1
break
case 'graph_completed':
currentPhase.value = 2
break
case 'failed':
error.value = projectData.value?.error || '处理失败'
break
}
}
// 开始构建图谱
const startBuildGraph = async () => {
try {
currentPhase.value = 1
// 设置初始进度
buildProgress.value = {
progress: 0,
message: '正在启动图谱构建...'
}
const response = await buildGraph({ project_id: currentProjectId.value })
if (response.success) {
buildProgress.value.message = '图谱构建任务已启动...'
// 保存 task_id 用于轮询
const taskId = response.data.task_id
// 启动图谱数据轮询(独立于任务状态轮询)
startGraphPolling()
// 启动任务状态轮询
startPollingTask(taskId)
} else {
error.value = response.error || '启动图谱构建失败'
buildProgress.value = null
}
} catch (err) {
console.error('Build graph error:', err)
error.value = '启动图谱构建失败: ' + (err.message || '未知错误')
buildProgress.value = null
}
}
// 图谱数据轮询定时器
let graphPollTimer = null
// 启动图谱数据轮询
const startGraphPolling = () => {
// 每 3 秒获取一次图谱数据
graphPollTimer = setInterval(async () => {
await fetchGraphData()
}, 3000)
}
// 停止图谱数据轮询
const stopGraphPolling = () => {
if (graphPollTimer) {
clearInterval(graphPollTimer)
graphPollTimer = null
}
}
// 获取图谱数据
const fetchGraphData = async () => {
try {
// 先获取项目信息以获取 graph_id
const projectResponse = await getProject(currentProjectId.value)
if (projectResponse.success && projectResponse.data.graph_id) {
const graphId = projectResponse.data.graph_id
projectData.value = projectResponse.data
// 获取图谱数据
const graphResponse = await getGraphData(graphId)
if (graphResponse.success && graphResponse.data) {
const newData = graphResponse.data
const newNodeCount = newData.node_count || newData.nodes?.length || 0
const oldNodeCount = graphData.value?.node_count || graphData.value?.nodes?.length || 0
console.log('Fetching graph data, nodes:', newNodeCount, 'edges:', newData.edge_count || newData.edges?.length || 0)
// 数据有变化时更新渲染
if (newNodeCount !== oldNodeCount || !graphData.value) {
graphData.value = newData
await nextTick()
renderGraph()
}
}
}
} catch (err) {
console.log('Graph data fetch:', err.message || 'not ready')
}
}
// 轮询任务状态
const startPollingTask = (taskId) => {
// 立即执行一次查询
pollTaskStatus(taskId)
// 然后定时轮询
pollTimer = setInterval(() => {
pollTaskStatus(taskId)
}, 2000)
}
// 查询任务状态
const pollTaskStatus = async (taskId) => {
try {
const response = await getTaskStatus(taskId)
if (response.success) {
const task = response.data
// 更新进度显示
buildProgress.value = {
progress: task.progress || 0,
message: task.message || '处理中...'
}
console.log('Task status:', task.status, 'Progress:', task.progress)
if (task.status === 'completed') {
stopPolling()
stopGraphPolling()
currentPhase.value = 2
// 重新加载项目数据获取 graph_id
const projectResponse = await getProject(currentProjectId.value)
if (projectResponse.success) {
projectData.value = projectResponse.data
// 最终加载完整图谱数据
if (projectResponse.data.graph_id) {
await loadGraph(projectResponse.data.graph_id)
}
}
} else if (task.status === 'failed') {
stopPolling()
stopGraphPolling()
error.value = '图谱构建失败: ' + (task.error || '未知错误')
buildProgress.value = null
}
}
} catch (err) {
console.error('Poll task error:', err)
}
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// 加载图谱数据
const loadGraph = async (graphId) => {
try {
graphLoading.value = true
const response = await getGraphData(graphId)
if (response.success) {
graphData.value = response.data
await nextTick()
renderGraph()
}
} catch (err) {
console.error('Load graph error:', err)
} finally {
graphLoading.value = false
}
}
// 渲染图谱 (D3.js)
const renderGraph = () => {
if (!graphSvg.value || !graphData.value) {
console.log('Cannot render: svg or data missing')
return
}
const container = graphContainer.value
if (!container) {
console.log('Cannot render: container missing')
return
}
// 获取容器尺寸
const rect = container.getBoundingClientRect()
const width = rect.width || 800
const height = (rect.height || 600) - 60
if (width <= 0 || height <= 0) {
console.log('Cannot render: invalid dimensions', width, height)
return
}
console.log('Rendering graph:', width, 'x', height)
const svg = d3.select(graphSvg.value)
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
svg.selectAll('*').remove()
// 处理节点数据
const nodesData = graphData.value.nodes || []
const edgesData = graphData.value.edges || []
if (nodesData.length === 0) {
console.log('No nodes to render')
// 显示空状态
svg.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#999')
.text('等待图谱数据...')
return
}
const nodes = nodesData.map(n => ({
id: n.uuid,
name: n.name || '未命名',
type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity'
}))
// 创建节点ID集合用于过滤有效边
const nodeIds = new Set(nodes.map(n => n.id))
const edges = edgesData
.filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid))
.map(e => ({
source: e.source_node_uuid,
target: e.target_node_uuid,
type: e.fact_type || e.name || 'RELATED_TO'
}))
console.log('Nodes:', nodes.length, 'Edges:', edges.length)
// 颜色映射
const types = [...new Set(nodes.map(n => n.type))]
const colorScale = d3.scaleOrdinal()
.domain(types)
.range(['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#2D3436', '#6C5CE7'])
// 力导向布局
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges).id(d => d.id).distance(100).strength(0.5))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(40))
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05))
// 添加缩放功能
const g = svg.append('g')
svg.call(d3.zoom()
.extent([[0, 0], [width, height]])
.scaleExtent([0.2, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform)
}))
// 绘制边
const link = g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(edges)
.enter()
.append('line')
.attr('stroke', '#ccc')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.6)
// 绘制节点
const node = g.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(nodes)
.enter()
.append('g')
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
node.append('circle')
.attr('r', 10)
.attr('fill', d => colorScale(d.type))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
node.append('text')
.attr('dx', 14)
.attr('dy', 4)
.text(d => d.name?.substring(0, 12) || '')
.attr('font-size', '11px')
.attr('fill', '#333')
.attr('font-family', 'JetBrains Mono, monospace')
// 添加 tooltip
node.append('title')
.text(d => `${d.name}\n类型: ${d.type}`)
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
node.attr('transform', d => `translate(${d.x},${d.y})`)
})
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart()
event.subject.fx = event.subject.x
event.subject.fy = event.subject.y
}
function dragged(event) {
event.subject.fx = event.x
event.subject.fy = event.y
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0)
event.subject.fx = null
event.subject.fy = null
}
}
// 监听图谱数据变化
watch(graphData, () => {
if (graphData.value) {
nextTick(() => renderGraph())
}
})
// 生命周期
onMounted(() => {
initProject()
})
onUnmounted(() => {
stopPolling()
stopGraphPolling()
})
</script>
<style scoped>
/* 变量 */
:root {
--black: #000000;
--white: #FFFFFF;
--orange: #FF6B35;
--gray-light: #F5F5F5;
--gray-border: #E0E0E0;
--gray-text: #666666;
}
.process-page {
min-height: 100vh;
background: var(--white);
font-family: 'JetBrains Mono', 'Space Grotesk', monospace;
}
/* 导航栏 */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
height: 60px;
background: #000;
color: #fff;
}
.nav-brand {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.15em;
cursor: pointer;
transition: opacity 0.2s;
}
.nav-brand:hover {
opacity: 0.8;
}
.nav-step {
display: flex;
align-items: center;
gap: 12px;
}
.step-badge {
background: #FF6B35;
color: #fff;
padding: 4px 12px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.step-name {
font-size: 0.9rem;
letter-spacing: 0.05em;
}
.nav-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.processing {
background: #FF6B35;
animation: pulse 1.5s infinite;
}
.status-dot.completed {
background: #1A936F;
}
.status-dot.error {
background: #C5283D;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
font-size: 0.85rem;
color: #999;
}
/* 主内容区 */
.main-content {
display: flex;
height: calc(100vh - 60px);
}
/* 左侧面板 */
.left-panel {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #E0E0E0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #E0E0E0;
background: #FAFAFA;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-icon {
font-size: 1rem;
color: #FF6B35;
}
.header-title {
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.8rem;
color: #666;
}
.stat-divider {
color: #ddd;
}
/* 图谱容器 */
.graph-container {
flex: 1;
position: relative;
overflow: hidden;
}
.graph-loading,
.graph-waiting,
.graph-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.loading-animation {
position: relative;
width: 80px;
height: 80px;
margin: 0 auto 20px;
}
.loading-ring {
position: absolute;
border: 2px solid transparent;
border-radius: 50%;
animation: ring-rotate 1.5s linear infinite;
}
.loading-ring:nth-child(1) {
width: 80px;
height: 80px;
border-top-color: #000;
}
.loading-ring:nth-child(2) {
width: 60px;
height: 60px;
top: 10px;
left: 10px;
border-right-color: #FF6B35;
animation-delay: 0.2s;
}
.loading-ring:nth-child(3) {
width: 40px;
height: 40px;
top: 20px;
left: 20px;
border-bottom-color: #666;
animation-delay: 0.4s;
}
@keyframes ring-rotate {
to { transform: rotate(360deg); }
}
.loading-text,
.waiting-text {
font-size: 0.9rem;
color: #333;
margin: 0 0 8px;
}
.waiting-hint {
font-size: 0.8rem;
color: #999;
margin: 0;
}
.waiting-icon {
margin-bottom: 20px;
}
.network-icon {
width: 100px;
height: 100px;
opacity: 0.6;
}
.graph-view {
width: 100%;
height: 100%;
position: relative;
}
.graph-svg {
width: 100%;
height: 100%;
display: block;
}
.graph-building-hint {
position: absolute;
bottom: 16px;
left: 16px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255, 107, 53, 0.1);
border: 1px solid #FF6B35;
font-size: 0.8rem;
color: #FF6B35;
}
.building-dot {
width: 8px;
height: 8px;
background: #FF6B35;
border-radius: 50%;
animation: pulse 1s infinite;
}
.error-icon {
font-size: 2rem;
display: block;
margin-bottom: 10px;
}
/* 图谱图例 */
.graph-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 12px 24px;
border-top: 1px solid #E0E0E0;
background: #FAFAFA;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-label {
color: #333;
}
.legend-count {
color: #999;
}
/* 右侧面板 */
.right-panel {
width: 480px;
display: flex;
flex-direction: column;
background: #fff;
}
.right-panel .panel-header {
background: #000;
color: #fff;
border-bottom: none;
}
.right-panel .header-icon {
color: #FF6B35;
}
/* 流程内容 */
.process-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* 流程阶段 */
.process-phase {
margin-bottom: 24px;
border: 1px solid #E0E0E0;
opacity: 0.5;
transition: all 0.3s;
}
.process-phase.active,
.process-phase.completed {
opacity: 1;
}
.process-phase.active {
border-color: #FF6B35;
}
.process-phase.completed {
border-color: #1A936F;
}
.phase-header {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: #FAFAFA;
border-bottom: 1px solid #E0E0E0;
}
.process-phase.active .phase-header {
background: #FFF5F2;
}
.process-phase.completed .phase-header {
background: #F2FAF6;
}
.phase-num {
font-size: 1.5rem;
font-weight: 700;
color: #ddd;
line-height: 1;
}
.process-phase.active .phase-num {
color: #FF6B35;
}
.process-phase.completed .phase-num {
color: #1A936F;
}
.phase-info {
flex: 1;
}
.phase-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 4px;
}
.phase-api {
font-size: 0.75rem;
color: #999;
font-family: 'JetBrains Mono', monospace;
}
.phase-status {
font-size: 0.75rem;
padding: 4px 10px;
background: #eee;
color: #666;
}
.phase-status.active {
background: #FF6B35;
color: #fff;
}
.phase-status.completed {
background: #1A936F;
color: #fff;
}
/* 阶段详情 */
.phase-detail {
padding: 16px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 0.75rem;
color: #999;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-content {
font-size: 0.85rem;
color: #333;
line-height: 1.6;
}
/* 实体标签 */
.entity-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.entity-tag {
font-size: 0.75rem;
padding: 4px 10px;
background: #F5F5F5;
border: 1px solid #E0E0E0;
color: #333;
}
/* 关系列表 */
.relation-list {
font-size: 0.8rem;
}
.relation-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px dashed #eee;
}
.relation-item:last-child {
border-bottom: none;
}
.rel-source,
.rel-target {
color: #333;
}
.rel-arrow {
color: #ccc;
}
.rel-name {
color: #FF6B35;
font-weight: 500;
}
.relation-more {
padding-top: 8px;
color: #999;
font-size: 0.75rem;
}
/* 本体生成进度 */
.ontology-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #FFF5F2;
border: 1px solid #FFE0D6;
}
.progress-spinner {
width: 20px;
height: 20px;
border: 2px solid #FFE0D6;
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-text {
font-size: 0.85rem;
color: #333;
}
/* 等待状态 */
.waiting-state {
padding: 16px;
background: #F9F9F9;
border: 1px dashed #E0E0E0;
text-align: center;
}
.waiting-hint {
font-size: 0.85rem;
color: #999;
}
/* 进度条 */
.progress-bar {
height: 6px;
background: #E0E0E0;
margin-bottom: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #FF6B35;
transition: width 0.3s;
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
}
.progress-message {
color: #666;
}
.progress-percent {
color: #FF6B35;
font-weight: 600;
}
/* 构建结果 */
.build-result {
display: flex;
gap: 16px;
}
.result-item {
flex: 1;
text-align: center;
padding: 12px;
background: #F5F5F5;
}
.result-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #000;
margin-bottom: 4px;
}
.result-label {
font-size: 0.7rem;
color: #999;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* 下一步按钮 */
.next-step-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #E0E0E0;
}
.next-step-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
background: #000;
color: #fff;
border: none;
font-size: 1rem;
font-weight: 500;
letter-spacing: 0.05em;
cursor: pointer;
transition: all 0.2s;
}
.next-step-btn:hover:not(:disabled) {
background: #FF6B35;
}
.next-step-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-arrow {
font-size: 1.2rem;
}
/* 项目信息面板 */
.project-panel {
border-top: 1px solid #E0E0E0;
background: #FAFAFA;
}
.project-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
border-bottom: 1px solid #E0E0E0;
}
.project-icon {
color: #FF6B35;
}
.project-title {
font-size: 0.85rem;
font-weight: 600;
}
.project-details {
padding: 16px 24px;
}
.project-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px dashed #E0E0E0;
font-size: 0.8rem;
}
.project-item:last-child {
border-bottom: none;
}
.item-label {
color: #999;
flex-shrink: 0;
}
.item-value {
color: #333;
text-align: right;
max-width: 60%;
word-break: break-all;
}
.item-value.code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #666;
}
/* 响应式 */
@media (max-width: 1024px) {
.main-content {
flex-direction: column;
}
.left-panel {
border-right: none;
border-bottom: 1px solid #E0E0E0;
height: 50vh;
}
.right-panel {
width: 100%;
height: 50vh;
}
}
</style>