Spaces:
Sleeping
Sleeping
| <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% ; | |
| border-right: none; | |
| border-bottom: 1px solid #E0E0E0; | |
| height: 50vh; | |
| } | |
| .right-panel { | |
| width: 100% ; | |
| height: 50vh; | |
| opacity: 1 ; | |
| transform: none ; | |
| } | |
| .right-panel.hidden { | |
| display: none; | |
| } | |
| } | |
| </style> |