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