MiroFish/frontend/src/views/Process.vue

2068 lines
No EOL
51 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-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>