MicroFish/frontend/src/views/Process.vue

2068 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="process-page">
<!-- Thanh điều hướng trên cùng -->
<nav class="navbar">
<div class="nav-brand" @click="goHome">MIROFISH</div>
<!-- Chỉ báo bước giữa -->
<div class="nav-center">
<div class="step-badge">STEP 01</div>
<div class="step-name">Graph Build</div>
</div>
<div class="nav-status">
<span class="status-dot" :class="statusClass"></span>
<span class="status-text">{{ statusText }}</span>
</div>
</nav>
<!-- Khu vực nội dung chính -->
<div class="main-content">
<!-- Bên trái: hiển thị đồ thị thời gian thực -->
<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">Real-time Knowledge Graph</span>
</div>
<div class="header-right">
<template v-if="graphData">
<span class="stat-item">{{ graphData.node_count || graphData.nodes?.length || 0 }} nodes</span>
<span class="stat-divider">|</span>
<span class="stat-item">{{ graphData.edge_count || graphData.edges?.length || 0 }} relations</span>
<span class="stat-divider">|</span>
</template>
<div class="action-buttons">
<button class="action-btn" @click="refreshGraph" :disabled="graphLoading" title="Refresh graph">
<span class="icon-refresh" :class="{ 'spinning': graphLoading }"></span>
</button>
<button class="action-btn" @click="toggleFullScreen" :title="isFullScreen ? 'Exit fullscreen' : 'Fullscreen'">
<span class="icon-fullscreen">{{ isFullScreen ? '↙' : '↗' }}</span>
</button>
</div>
</div>
</div>
<div class="graph-container" ref="graphContainer">
<!-- Trực quan hóa đồ thị (hiển thị ngay khi dữ liệu) -->
<div v-if="graphData" class="graph-view">
<svg ref="graphSvg" class="graph-svg"></svg>
<!-- Gợi ý khi đang xây dựng -->
<div v-if="currentPhase === 1" class="graph-building-hint">
<span class="building-dot"></span>
Updating in real time...
</div>
<!-- Bảng chi tiết nút/cạnh -->
<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>
<!-- Chi tiết nút -->
<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>
<!-- Chi tiết cạnh -->
<div v-else class="detail-content">
<!-- Hiển thị quan hệ -->
<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>
<!-- Trạng thái đang tải -->
<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">Loading graph data...</p>
</div>
<!-- Chờ xây dựng -->
<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">Waiting for ontology generation</p>
<p class="waiting-hint">Graph building will start automatically when generation finishes</p>
</div>
<!-- Đang xây dựng nhưng chưa có dữ liệu -->
<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">Building graph</p>
<p class="waiting-hint">Data will appear soon...</p>
</div>
<!-- Trạng thái lỗi -->
<div v-else-if="error" class="graph-error">
<span class="error-icon">⚠</span>
<p>{{ error }}</p>
</div>
</div>
<!-- Chú giải đồ thị -->
<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>
<!-- Bên phải: chi tiết quy trình xây dựng -->
<div class="right-panel" :class="{ 'hidden': isFullScreen }">
<div class="panel-header dark-header">
<span class="header-icon">▣</span>
<span class="header-title">Build Process</span>
</div>
<div class="process-content">
<!-- Giai đoạn 1: tạo ontology -->
<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">Ontology Generation</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">API Description</div>
<div class="detail-content">
After document upload, the LLM analyzes content and generates an ontology structure for public-opinion simulation (entity types + relation types)
</div>
</div>
<!-- Tiến độ tạo ontology -->
<div class="detail-section" v-if="ontologyProgress && currentPhase === 0">
<div class="detail-label">Generation Progress</div>
<div class="ontology-progress">
<div class="progress-spinner"></div>
<span class="progress-text">{{ ontologyProgress.message }}</span>
</div>
</div>
<!-- Thông tin ontology đã tạo -->
<div class="detail-section" v-if="projectData?.ontology">
<div class="detail-label">Generated Entity Types ({{ 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">Generated Relation Types ({{ 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 }} more relations...
</div>
</div>
</div>
<!-- Trạng thái chờ -->
<div class="detail-section waiting-state" v-if="!projectData?.ontology && currentPhase === 0 && !ontologyProgress">
<div class="waiting-hint">Waiting for ontology generation...</div>
</div>
</div>
</div>
<!-- Giai đoạn 2: xây dựng đồ thị -->
<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">Graph Building</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">API Description</div>
<div class="detail-content">
Based on the generated ontology, chunk the document and call Zep API to build the knowledge graph and extract entities/relations
</div>
</div>
<!-- Chờ ontology hoàn tất -->
<div class="detail-section waiting-state" v-if="currentPhase < 1">
<div class="waiting-hint">Waiting for ontology generation to complete...</div>
</div>
<!-- Tiến độ xây dựng -->
<div class="detail-section" v-if="buildProgress && currentPhase >= 1">
<div class="detail-label">Build Progress</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">Build Results</div>
<div class="build-result">
<div class="result-item">
<span class="result-value">{{ graphData.node_count }}</span>
<span class="result-label">Entity Nodes</span>
</div>
<div class="result-item">
<span class="result-value">{{ graphData.edge_count }}</span>
<span class="result-label">Relation Edges</span>
</div>
<div class="result-item">
<span class="result-value">{{ entityTypes.length }}</span>
<span class="result-label">Entity Types</span>
</div>
</div>
</div>
</div>
</div>
<!-- Giai đoạn 3: hoàn tất -->
<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">Build Complete</div>
<div class="phase-api">Ready for next step</div>
</div>
<span class="phase-status" :class="getPhaseStatusClass(2)">
{{ getPhaseStatusText(2) }}
</span>
</div>
</div>
<!-- Nút bước tiếp theo -->
<div class="next-step-section" v-if="currentPhase >= 2">
<button class="next-step-btn" @click="goToNextStep" :disabled="currentPhase < 2">
Go to Environment Setup
<span class="btn-arrow">→</span>
</button>
</div>
</div>
<!-- Bảng thông tin dự án -->
<div class="project-panel">
<div class="project-header">
<span class="project-icon">◇</span>
<span class="project-title">Project Info</span>
</div>
<div class="project-details" v-if="projectData">
<div class="project-item">
<span class="item-label">Project Name</span>
<span class="item-value">{{ projectData.name }}</span>
</div>
<div class="project-item">
<span class="item-label">Project 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">Graph ID</span>
<span class="item-value code">{{ projectData.graph_id }}</span>
</div>
<div class="project-item">
<span class="item-label">Simulation Requirement</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 dự án hiện tại (có thể đổi từ 'new' sang ID thực tế)
const currentProjectId = ref(route.params.projectId)
// Trạng thái
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) // Tiến độ tạo ontology
const currentPhase = ref(-1) // -1: đang tải lên, 0: tạo ontology, 1: xây dựng đồ thị, 2: hoàn tất
const selectedItem = ref(null) // Nút hoặc cạnh được chọn
const isFullScreen = ref(false)
// Tham chiếu DOM
const graphContainer = ref(null)
const graphSvg = ref(null)
// Bộ đếm thời gian polling
let pollTimer = null
// Thuộc tính tính toán
const statusClass = computed(() => {
if (error.value) return 'error'
if (currentPhase.value >= 2) return 'completed'
return 'processing'
})
const statusText = computed(() => {
if (error.value) return 'Build failed'
if (currentPhase.value >= 2) return 'Build complete'
if (currentPhase.value === 1) return 'Building graph'
if (currentPhase.value === 0) return 'Generating ontology'
return 'Initializing'
})
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)
})
// Hàm xử lý
const goHome = () => {
router.push('/')
}
const goToNextStep = () => {
// TODO: Chuyển sang bước thiết lập môi trường
alert('Environment setup is under development...')
}
const toggleFullScreen = () => {
isFullScreen.value = !isFullScreen.value
// Wait for transition to finish then re-render graph
setTimeout(() => {
renderGraph()
}, 350)
}
// Đóng bảng chi tiết
const closeDetailPanel = () => {
selectedItem.value = null
}
// Định dạng ngày giờ
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateStr
}
}
// Chọn nút
const selectNode = (nodeData, color) => {
selectedItem.value = {
type: 'node',
data: nodeData,
color: color,
entityType: nodeData.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity'
}
}
// Chọn cạnh
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 'Completed'
if (currentPhase.value === phase) {
if (phase === 1 && buildProgress.value) {
return `${buildProgress.value.progress}%`
}
return 'In progress'
}
return 'Pending'
}
// Khởi tạo - xử lý dự án mới hoặc tải dự án đã có
const initProject = async () => {
const paramProjectId = route.params.projectId
if (paramProjectId === 'new') {
// Dự án mới: lấy dữ liệu đang chờ tải lên từ store
await handleNewProject()
} else {
// Tải dự án hiện có
currentProjectId.value = paramProjectId
await loadProject()
}
}
// Xử lý dự án mới - gọi API ontology/generate
const handleNewProject = async () => {
const pending = getPendingUpload()
if (!pending.isPending || pending.files.length === 0) {
error.value = 'No pending files to upload. Please return to the home page and try again.'
loading.value = false
return
}
try {
loading.value = true
currentPhase.value = 0 // Giai đoạn tạo ontology
ontologyProgress.value = { message: 'Uploading files and analyzing documents...' }
// Tạo FormData
const formDataObj = new FormData()
pending.files.forEach(file => {
formDataObj.append('files', file)
})
formDataObj.append('simulation_requirement', pending.simulationRequirement)
// Gọi API tạo ontology
const response = await generateOntology(formDataObj)
if (response.success) {
// Xóa dữ liệu chờ upload
clearPendingUpload()
// Cập nhật ID dự án và dữ liệu
currentProjectId.value = response.data.project_id
projectData.value = response.data
// Cập nhật URL (không tải lại trang)
router.replace({
name: 'Process',
params: { projectId: response.data.project_id }
})
ontologyProgress.value = null
// Tự động bắt đầu xây dựng đồ thị
await startBuildGraph()
} else {
error.value = response.error || 'Ontology generation failed'
}
} catch (err) {
console.error('Handle new project error:', err)
error.value = 'Project initialization failed: ' + (err.message || 'Unknown error')
} finally {
loading.value = false
}
}
// Tải dữ liệu dự án hiện có
const loadProject = async () => {
try {
loading.value = true
const response = await getProject(currentProjectId.value)
if (response.success) {
projectData.value = response.data
updatePhaseByStatus(response.data.status)
// Tự động bắt đầu xây dựng đồ thị
if (response.data.status === 'ontology_generated' && !response.data.graph_id) {
await startBuildGraph()
}
// Tiếp tục polling tác vụ đang xây dựng
if (response.data.status === 'graph_building' && response.data.graph_build_task_id) {
currentPhase.value = 1
startPollingTask(response.data.graph_build_task_id)
}
// Tải đồ thị đã hoàn tất
if (response.data.status === 'graph_completed' && response.data.graph_id) {
currentPhase.value = 2
await loadGraph(response.data.graph_id)
}
} else {
error.value = response.error || 'Failed to load project'
}
} catch (err) {
console.error('Load project error:', err)
error.value = 'Failed to load project: ' + (err.message || 'Unknown error')
} 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 || 'Processing failed'
break
}
}
// Bắt đầu xây dựng đồ thị
const startBuildGraph = async () => {
try {
currentPhase.value = 1
// Thiết lập tiến độ ban đầu
buildProgress.value = {
progress: 0,
message: 'Starting graph build...'
}
const response = await buildGraph({ project_id: currentProjectId.value })
if (response.success) {
buildProgress.value.message = 'Graph build task started...'
// Lưu task_id để polling
const taskId = response.data.task_id
// Bắt đầu polling dữ liệu đồ thị (độc lập với polling trạng thái tác vụ)
startGraphPolling()
// Bắt đầu polling trạng thái tác vụ
startPollingTask(taskId)
} else {
error.value = response.error || 'Failed to start graph build'
buildProgress.value = null
}
} catch (err) {
console.error('Build graph error:', err)
error.value = 'Failed to start graph build: ' + (err.message || 'Unknown error')
buildProgress.value = null
}
}
// Bộ đếm thời gian polling dữ liệu đồ thị
let graphPollTimer = null
// Bắt đầu polling dữ liệu đồ thị
const startGraphPolling = () => {
// Lấy dữ liệu ngay lập tức một lần
fetchGraphData()
// Tự động lấy dữ liệu đồ thị mỗi 10 giây
graphPollTimer = setInterval(async () => {
await fetchGraphData()
}, 10000)
}
// Làm mới đồ thị thủ công
const refreshGraph = async () => {
graphLoading.value = true
await fetchGraphData()
graphLoading.value = false
}
// Dừng polling dữ liệu đồ thị
const stopGraphPolling = () => {
if (graphPollTimer) {
clearInterval(graphPollTimer)
graphPollTimer = null
}
}
// Lấy dữ liệu đồ thị
const fetchGraphData = async () => {
try {
// Lấy thông tin dự án trước để lấy 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
// Lấy dữ liệu đồ thị
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)
// Cập nhật render khi dữ liệu thay đổi
if (newNodeCount !== oldNodeCount || !graphData.value) {
graphData.value = newData
await nextTick()
renderGraph()
}
}
}
} catch (err) {
console.log('Graph data fetch:', err.message || 'not ready')
}
}
// Polling trạng thái tác vụ
const startPollingTask = (taskId) => {
// Thực thi truy vấn ngay một lần
pollTaskStatus(taskId)
// Sau đó polling định kỳ
pollTimer = setInterval(() => {
pollTaskStatus(taskId)
}, 2000)
}
// Truy vấn trạng thái tác vụ
const pollTaskStatus = async (taskId) => {
try {
const response = await getTaskStatus(taskId)
if (response.success) {
const task = response.data
// Cập nhật hiển thị tiến độ
buildProgress.value = {
progress: task.progress || 0,
message: task.message || 'Processing...'
}
console.log('Task status:', task.status, 'Progress:', task.progress)
if (task.status === 'completed') {
console.log('✅ Graph build completed, loading full data...')
stopPolling()
stopGraphPolling()
currentPhase.value = 2
// Cập nhật tiến độ hiển thị sang trạng thái hoàn tất
buildProgress.value = {
progress: 100,
message: 'Build complete, loading graph...'
}
// Tải lại dữ liệu dự án để lấy graph_id
const projectResponse = await getProject(currentProjectId.value)
if (projectResponse.success) {
projectData.value = projectResponse.data
// Tải đầy đủ dữ liệu đồ thị cuối cùng
if (projectResponse.data.graph_id) {
console.log('📊 Loading full graph:', projectResponse.data.graph_id)
await loadGraph(projectResponse.data.graph_id)
console.log('✅ Graph loading complete')
}
}
// Xóa hiển thị tiến độ
buildProgress.value = null
} else if (task.status === 'failed') {
stopPolling()
stopGraphPolling()
error.value = 'Graph build failed: ' + (task.error || 'Unknown error')
buildProgress.value = null
}
}
} catch (err) {
console.error('Poll task error:', err)
}
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// Tải dữ liệu đồ thị
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
}
}
// Render đồ thị (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
}
// Lấy kích thước container
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()
// Xử lý dữ liệu nút
const nodesData = graphData.value.nodes || []
const edgesData = graphData.value.edges || []
if (nodesData.length === 0) {
console.log('No nodes to render')
// Hiển thị trạng thái trống
svg.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#999')
.text('Waiting for graph data...')
return
}
// Tạo ánh xạ nút để tra cứu tên
const nodeMap = {}
nodesData.forEach(n => {
nodeMap[n.uuid] = n
})
const nodes = nodesData.map(n => ({
id: n.uuid,
name: n.name || 'Untitled',
type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity',
rawData: n // Lưu dữ liệu gốc
}))
// Tạo tập ID nút để lọc cạnh hợp lệ
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 || 'Unknown',
target_name: nodeMap[e.target_node_uuid]?.name || 'Unknown'
}
}))
console.log('Nodes:', nodes.length, 'Edges:', edges.length)
// Ánh xạ màu
const types = [...new Set(nodes.map(n => n.type))]
const colorScale = d3.scaleOrdinal()
.domain(types)
.range(['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#2D3436', '#6C5CE7'])
// Bố cục force-directed
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))
// Thêm chức năng zoom
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)
}))
// Vẽ cạnh (bao gồm đường rộng trong suốt để click)
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)
})
// Đường mảnh hiển thị
const link = linkGroup.append('line')
.attr('stroke', '#ccc')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.6)
// Đường rộng trong suốt để click
linkGroup.append('line')
.attr('stroke', 'transparent')
.attr('stroke-width', 10)
// Nhãn cạnh
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)
// Vẽ nút
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')
// Nhấn vùng trống để đóng bảng chi tiết
svg.on('click', () => {
closeDetailPanel()
})
simulation.on('tick', () => {
// Cập nhật vị trí tất cả cạnh (gồm đường hiển thị và vùng click trong suốt)
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)
// Cập nhật vị trí nhãn cạnh
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
}
}
// Theo dõi thay đổi dữ liệu đồ thị
watch(graphData, () => {
if (graphData.value) {
nextTick(() => renderGraph())
}
})
// Vòng đời
onMounted(() => {
initProject()
})
onUnmounted(() => {
stopPolling()
stopGraphPolling()
})
</script>
<style scoped>
/* Biến */
: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 */
}
/* Thanh điều hướng */
.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;
}
/* Khu vực nội dung chính */
.main-content {
display: flex;
height: calc(100vh - 56px);
position: relative;
}
/* Bảng bên trái - mặc định 50% */
.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); }
}
/* Container đồ thị */
.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;
}
/* Bảng chi tiết nút/cạnh */
.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;
}
/* Hiển thị quan hệ trong chi tiết cạnh */
.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;
}
/* Danh sách thuộc tính */
.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;
}
/* Danh sách 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;
}
/* Chú giải đồ thị */
.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;
}
/* Bảng bên phải - mặc định 50% */
.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;
}
/* Nội dung quy trình */
.process-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Các giai đoạn quy trình */
.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;
}
/* Chi tiết giai đoạn */
.phase-detail {
padding: 16px;
}
/* Nhãn thực thể */
.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;
}
/* Danh sách quan hệ */
.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;
}
/* Tiến độ tạo ontology */
.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;
}
/* Trạng thái chờ */
.waiting-state {
padding: 16px;
background: #F9F9F9;
border: 1px dashed #E0E0E0;
text-align: center;
}
.waiting-hint {
font-size: 0.85rem;
color: #999;
}
/* Thanh tiến độ */
.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;
}
/* Kết quả xây dựng */
.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;
}
/* Nút bước tiếp theo */
.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;
}
/* Bảng thông tin dự án */
.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;
}
/* Responsive */
@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>