MiroFish / frontend /src /views /Process.vue
Codex Deploy
Deploy MiroFish to HF Space
ebdfd3b
<template>
<div class="process-page">
<!-- 顶部导航栏 -->
<nav class="navbar">
<div class="nav-brand" @click="goHome">MIROFISH</div>
<!-- 中间步骤指示器 -->
<div class="nav-center">
<div class="step-badge">STEP 01</div>
<div class="step-name">图谱构建</div>
</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" :class="{ 'full-screen': isFullScreen }">
<div class="panel-header">
<div class="header-left">
<span class="header-deco"></span>
<span class="header-title">实时知识图谱</span>
</div>
<div class="header-right">
<template v-if="graphData">
<span class="stat-item">{{ graphData.node_count || graphData.nodes?.length || 0 }} 节点</span>
<span class="stat-divider">|</span>
<span class="stat-item">{{ graphData.edge_count || graphData.edges?.length || 0 }} 关系</span>
<span class="stat-divider">|</span>
</template>
<div class="action-buttons">
<button class="action-btn" @click="refreshGraph" :disabled="graphLoading" title="刷新图谱">
<span class="icon-refresh" :class="{ 'spinning': graphLoading }"></span>
</button>
<button class="action-btn" @click="toggleFullScreen" :title="isFullScreen ? '退出全屏' : '全屏显示'">
<span class="icon-fullscreen">{{ isFullScreen ? '↙' : '↗' }}</span>
</button>
</div>
</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 v-if="selectedItem" class="detail-panel">
<div class="detail-panel-header">
<span class="detail-title">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span>
<span v-if="selectedItem.type === 'node'" class="detail-badge" :style="{ background: selectedItem.color }">
{{ selectedItem.entityType }}
</span>
<button class="detail-close" @click="closeDetailPanel">×</button>
</div>
<!-- 节点详情 -->
<div v-if="selectedItem.type === 'node'" class="detail-content">
<div class="detail-row">
<span class="detail-label">Name:</span>
<span class="detail-value highlight">{{ selectedItem.data.name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">UUID:</span>
<span class="detail-value uuid">{{ selectedItem.data.uuid }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.created_at">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatDate(selectedItem.data.created_at) }}</span>
</div>
<!-- Properties / Attributes -->
<div class="detail-section" v-if="selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0">
<span class="detail-label">Properties:</span>
<div class="properties-list">
<div v-for="(value, key) in selectedItem.data.attributes" :key="key" class="property-item">
<span class="property-key">{{ key }}:</span>
<span class="property-value">{{ value }}</span>
</div>
</div>
</div>
<!-- Summary -->
<div class="detail-section" v-if="selectedItem.data.summary">
<span class="detail-label">Summary:</span>
<p class="detail-summary">{{ selectedItem.data.summary }}</p>
</div>
<!-- Labels -->
<div class="detail-row" v-if="selectedItem.data.labels?.length">
<span class="detail-label">Labels:</span>
<div class="detail-labels">
<span v-for="label in selectedItem.data.labels" :key="label" class="label-tag">{{ label }}</span>
</div>
</div>
</div>
<!-- 边详情 -->
<div v-else class="detail-content">
<!-- 关系展示 -->
<div class="edge-relation">
<span class="edge-source">{{ selectedItem.data.source_name || selectedItem.data.source_node_name }}</span>
<span class="edge-arrow"></span>
<span class="edge-type">{{ selectedItem.data.name || selectedItem.data.fact_type || 'RELATED_TO' }}</span>
<span class="edge-arrow"></span>
<span class="edge-target">{{ selectedItem.data.target_name || selectedItem.data.target_node_name }}</span>
</div>
<div class="detail-subtitle">Relationship</div>
<div class="detail-row">
<span class="detail-label">UUID:</span>
<span class="detail-value uuid">{{ selectedItem.data.uuid }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Label:</span>
<span class="detail-value">{{ selectedItem.data.name || selectedItem.data.fact_type || 'RELATED_TO' }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.fact_type">
<span class="detail-label">Type:</span>
<span class="detail-value">{{ selectedItem.data.fact_type }}</span>
</div>
<!-- Fact -->
<div class="detail-section" v-if="selectedItem.data.fact">
<span class="detail-label">Fact:</span>
<p class="detail-summary">{{ selectedItem.data.fact }}</p>
</div>
<!-- Episodes -->
<div class="detail-section" v-if="selectedItem.data.episodes?.length">
<span class="detail-label">Episodes:</span>
<div class="episodes-list">
<span v-for="ep in selectedItem.data.episodes" :key="ep" class="episode-tag">{{ ep }}</span>
</div>
</div>
<div class="detail-row" v-if="selectedItem.data.created_at">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatDate(selectedItem.data.created_at) }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.valid_at">
<span class="detail-label">Valid From:</span>
<span class="detail-value">{{ formatDate(selectedItem.data.valid_at) }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.invalid_at">
<span class="detail-label">Invalid At:</span>
<span class="detail-value">{{ formatDate(selectedItem.data.invalid_at) }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.expired_at">
<span class="detail-label">Expired At:</span>
<span class="detail-value">{{ formatDate(selectedItem.data.expired_at) }}</span>
</div>
</div>
</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" :class="{ 'hidden': isFullScreen }">
<div class="panel-header dark-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: 完成
const selectedItem = ref(null) // 选中的节点或边
const isFullScreen = ref(false)
// 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 toggleFullScreen = () => {
isFullScreen.value = !isFullScreen.value
// Wait for transition to finish then re-render graph
setTimeout(() => {
renderGraph()
}, 350)
}
// 关闭详情面板
const closeDetailPanel = () => {
selectedItem.value = null
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateStr
}
}
// 选中节点
const selectNode = (nodeData, color) => {
selectedItem.value = {
type: 'node',
data: nodeData,
color: color,
entityType: nodeData.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity'
}
}
// 选中边
const selectEdge = (edgeData) => {
selectedItem.value = {
type: 'edge',
data: edgeData
}
}
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 = () => {
// 立即获取一次
fetchGraphData()
// 每 10 秒自动获取一次图谱数据
graphPollTimer = setInterval(async () => {
await fetchGraphData()
}, 10000)
}
// 手动刷新图谱
const refreshGraph = async () => {
graphLoading.value = true
await fetchGraphData()
graphLoading.value = false
}
// 停止图谱数据轮询
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') {
console.log('✅ 图谱构建完成,正在加载完整数据...')
stopPolling()
stopGraphPolling()
currentPhase.value = 2
// 更新进度显示为完成状态
buildProgress.value = {
progress: 100,
message: '构建完成,正在加载图谱...'
}
// 重新加载项目数据获取 graph_id
const projectResponse = await getProject(currentProjectId.value)
if (projectResponse.success) {
projectData.value = projectResponse.data
// 最终加载完整图谱数据
if (projectResponse.data.graph_id) {
console.log('📊 加载完整图谱:', projectResponse.data.graph_id)
await loadGraph(projectResponse.data.graph_id)
console.log('✅ 图谱加载完成')
}
}
// 清除进度显示
buildProgress.value = null
} 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 nodeMap = {}
nodesData.forEach(n => {
nodeMap[n.uuid] = n
})
const nodes = nodesData.map(n => ({
id: n.uuid,
name: n.name || '未命名',
type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity',
rawData: n // 保存原始数据
}))
// 创建节点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',
rawData: {
...e,
source_name: nodeMap[e.source_node_uuid]?.name || '未知',
target_name: nodeMap[e.target_node_uuid]?.name || '未知'
}
}))
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 linkGroup = g.append('g')
.attr('class', 'links')
.selectAll('g')
.data(edges)
.enter()
.append('g')
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation()
selectEdge(d.rawData)
})
// 可见的细线
const link = linkGroup.append('line')
.attr('stroke', '#ccc')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.6)
// 透明的宽线用于点击
linkGroup.append('line')
.attr('stroke', 'transparent')
.attr('stroke-width', 10)
// 边标签
const linkLabel = g.append('g')
.attr('class', 'link-labels')
.selectAll('text')
.data(edges)
.enter()
.append('text')
.attr('font-size', '9px')
.attr('fill', '#999')
.attr('text-anchor', 'middle')
.text(d => d.type.length > 15 ? d.type.substring(0, 12) + '...' : d.type)
// 绘制节点
const node = g.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(nodes)
.enter()
.append('g')
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation()
selectNode(d.rawData, colorScale(d.type))
})
.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)
.attr('class', 'node-circle')
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')
// 点击空白处关闭详情面板
svg.on('click', () => {
closeDetailPanel()
})
simulation.on('tick', () => {
// 更新所有边的位置(包括可见线和透明点击区域)
linkGroup.selectAll('line')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
// 更新边标签位置
linkLabel
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 5)
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', 'Noto Sans SC', monospace;
overflow: hidden; /* Prevent body scroll in fullscreen */
}
/* 导航栏 */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
background: #000;
color: #fff;
z-index: 10;
position: relative;
}
.nav-brand {
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.1em;
cursor: pointer;
transition: opacity 0.2s;
}
.nav-brand:hover {
opacity: 0.8;
}
.nav-center {
display: flex;
align-items: center;
gap: 12px;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.step-badge {
background: #FF6B35;
color: #fff;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
border-radius: 2px;
}
.step-name {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: #fff;
}
.nav-status {
display: flex;
align-items: center;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #666;
margin-right: 8px;
}
.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.75rem;
color: #999;
}
/* 主内容区 */
.main-content {
display: flex;
height: calc(100vh - 56px);
position: relative;
}
/* 左侧面板 - 50% default */
.left-panel {
width: 50%;
flex: none; /* Fixed width initially */
display: flex;
flex-direction: column;
border-right: 1px solid #E0E0E0;
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
background: #fff;
z-index: 5;
}
.left-panel.full-screen {
width: 100%;
border-right: none;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid #E0E0E0;
background: #fff;
height: 50px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-deco {
color: #FF6B35;
font-size: 0.8rem;
}
.header-title {
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.75rem;
color: #666;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stat-val {
font-weight: 600;
color: #333;
}
.stat-divider {
color: #eee;
}
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
color: #666;
border-radius: 2px;
}
.action-btn:hover:not(:disabled) {
background: #F5F5F5;
color: #000;
}
.action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.icon-refresh, .icon-fullscreen {
font-size: 1rem;
line-height: 1;
}
.icon-refresh.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 图谱容器 */
.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;
}
/* 节点/边详情面板 */
.detail-panel {
position: absolute;
top: 16px;
right: 16px;
width: 320px;
max-height: calc(100% - 32px);
background: #fff;
border: 1px solid #E0E0E0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
z-index: 100;
}
.detail-panel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #FAFAFA;
border-bottom: 1px solid #E0E0E0;
}
.detail-title {
font-size: 0.9rem;
font-weight: 600;
color: #333;
}
.detail-badge {
padding: 2px 10px;
font-size: 0.75rem;
color: #fff;
border-radius: 2px;
}
.detail-close {
margin-left: auto;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 1.2rem;
color: #999;
cursor: pointer;
transition: color 0.2s;
}
.detail-close:hover {
color: #333;
}
.detail-content {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.detail-row {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.detail-label {
font-size: 0.8rem;
color: #999;
min-width: 70px;
flex-shrink: 0;
}
.detail-value {
font-size: 0.85rem;
color: #333;
word-break: break-word;
}
.detail-value.uuid {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #666;
}
.detail-section {
margin-bottom: 12px;
}
.detail-summary {
margin: 8px 0 0 0;
font-size: 0.85rem;
color: #333;
line-height: 1.6;
padding: 10px;
background: #F9F9F9;
border-left: 3px solid #FF6B35;
}
.detail-labels {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.label-tag {
padding: 2px 8px;
font-size: 0.75rem;
background: #F0F0F0;
border: 1px solid #E0E0E0;
color: #666;
}
/* 边详情关系展示 */
.edge-relation {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: #F9F9F9;
border: 1px solid #E0E0E0;
}
.edge-source,
.edge-target {
font-size: 0.85rem;
font-weight: 500;
color: #333;
}
.edge-arrow {
color: #999;
}
.edge-type {
padding: 2px 8px;
font-size: 0.75rem;
background: #FF6B35;
color: #fff;
}
.detail-value.highlight {
font-weight: 600;
color: #000;
}
.detail-subtitle {
font-size: 0.9rem;
font-weight: 600;
color: #333;
margin: 16px 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid #E0E0E0;
}
/* Properties 属性列表 */
.properties-list {
margin-top: 8px;
padding: 10px;
background: #F9F9F9;
border: 1px solid #E0E0E0;
}
.property-item {
display: flex;
margin-bottom: 6px;
font-size: 0.85rem;
}
.property-item:last-child {
margin-bottom: 0;
}
.property-key {
color: #666;
margin-right: 8px;
font-family: 'JetBrains Mono', monospace;
}
.property-value {
color: #333;
word-break: break-word;
}
/* Episodes 列表 */
.episodes-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.episode-tag {
display: block;
padding: 6px 10px;
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
background: #F0F0F0;
border: 1px solid #E0E0E0;
color: #666;
word-break: break-all;
}
.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;
}
/* 右侧面板 - 50% default */
.right-panel {
width: 50%;
flex: none;
display: flex;
flex-direction: column;
background: #fff;
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, transform 0.3s ease;
overflow: hidden;
opacity: 1;
}
.right-panel.hidden {
width: 0;
opacity: 0;
transform: translateX(20px);
pointer-events: none;
}
.right-panel .panel-header.dark-header {
background: #000;
color: #fff;
border-bottom: none;
}
.right-panel .header-icon {
color: #FF6B35;
margin-right: 8px;
}
/* 流程内容 */
.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;
}
/* 实体标签 */
.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;
}
.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 {
width: 100% !important;
border-right: none;
border-bottom: 1px solid #E0E0E0;
height: 50vh;
}
.right-panel {
width: 100% !important;
height: 50vh;
opacity: 1 !important;
transform: none !important;
}
.right-panel.hidden {
display: none;
}
}
</style>