马一丁

Enhance the full-screen viewing style

... ... @@ -6,377 +6,425 @@
<title>知识图谱可视化 - BettaFish</title>
<!-- Vis.js -->
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--primary-color: #4F46E5;
--primary-light: #818CF8;
--bg-color: #0F172A;
--card-bg: #1E293B;
--text-color: #F1F5F9;
--text-muted: #94A3B8;
--bg-color: #0f172a;
--sidebar-bg: #1e293b;
--card-bg: #1e293b;
--border-color: #334155;
--success-color: #10B981;
--warning-color: #F59E0B;
--error-color: #EF4444;
--primary-color: #6366f1;
--primary-hover: #4f46e5;
--text-main: #f8fafc;
--text-sec: #94a3b8;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
outline: none;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
color: var(--text-main);
height: 100vh;
overflow: hidden;
font-size: 14px;
}
/* 顶部工具栏 */
.toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
/* 布局容器 */
.app-container {
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
z-index: 1000;
height: 100vh;
width: 100vw;
}
.toolbar h1 {
font-size: 1.25rem;
font-weight: 600;
/* 侧边栏 */
.sidebar {
width: 320px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
z-index: 20;
transition: transform 0.3s ease;
box-shadow: var(--shadow-lg);
}
.toolbar h1 svg {
width: 24px;
height: 24px;
color: var(--primary-color);
.sidebar.collapsed {
transform: translateX(-320px);
position: absolute;
height: 100%;
}
.toolbar-divider {
width: 1px;
height: 30px;
background: var(--border-color);
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.btn {
.app-title {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: transparent;
color: var(--text-color);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
gap: 10px;
color: var(--text-main);
}
.btn:hover {
background: var(--primary-color);
border-color: var(--primary-color);
.app-title svg {
color: var(--primary-color);
width: 24px;
height: 24px;
}
.btn-primary {
background: var(--primary-color);
border-color: var(--primary-color);
/* 侧边栏内容区 */
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.btn svg {
width: 16px;
height: 16px;
.section {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.search-box {
flex: 1;
max-width: 400px;
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-sec);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* 搜索框 */
.search-wrapper {
position: relative;
margin-bottom: 10px;
}
.search-box input {
.search-input {
width: 100%;
padding: 8px 16px 8px 40px;
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.875rem;
padding: 10px 36px 10px 12px;
border-radius: 8px;
color: var(--text-main);
font-size: 0.9rem;
transition: all 0.2s;
}
.search-box input:focus {
outline: none;
.search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.search-box svg {
.search-icon {
position: absolute;
left: 12px;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-muted);
}
.search-group {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
max-width: 560px;
color: var(--text-sec);
pointer-events: none;
}
.search-actions {
.search-controls {
display: flex;
gap: 8px;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.search-status {
min-width: 44px;
text-align: center;
font-size: 0.8rem;
color: var(--text-muted);
color: var(--text-sec);
margin-left: auto;
}
/* 统计信息 */
.stats {
/* 过滤器 */
.filter-list {
display: flex;
gap: 16px;
margin-left: auto;
flex-direction: column;
gap: 8px;
}
.stat-item {
.filter-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
padding: 8px 12px;
background: rgba(15, 23, 42, 0.3);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
border: 1px solid transparent;
}
.stat-item .label {
color: var(--text-muted);
.filter-item:hover {
background: rgba(15, 23, 42, 0.6);
border-color: var(--border-color);
}
.stat-item .value {
font-weight: 600;
color: var(--primary-light);
.filter-item input {
display: none;
}
/* 左侧面板 */
.sidebar {
position: fixed;
top: 60px;
left: 0;
width: 300px;
bottom: 0;
background: var(--card-bg);
border-right: 1px solid var(--border-color);
overflow-y: auto;
padding: 16px;
transition: transform 0.3s;
z-index: 100;
.filter-item.active {
background: rgba(99, 102, 241, 0.1);
border-color: rgba(99, 102, 241, 0.3);
}
.sidebar.collapsed {
transform: translateX(-100%);
/* 未选中时灰显彩点 */
.filter-item:not(.active) .filter-dot {
background-color: var(--text-sec) !important;
box-shadow: none;
}
.sidebar h3 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 12px;
.filter-item:not(.active) .filter-name,
.filter-item:not(.active) .filter-count {
color: var(--text-sec);
opacity: 0.7;
}
.filter-group {
margin-bottom: 20px;
.filter-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 10px;
box-shadow: 0 0 8px currentColor;
}
.filter-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
cursor: pointer;
.filter-name {
flex: 1;
font-weight: 500;
}
.filter-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary-color);
.filter-count {
font-size: 0.75rem;
color: var(--text-sec);
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.filter-item .color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
/* 节点详情卡片 */
.node-details {
display: none;
animation: fadeIn 0.3s ease;
}
.filter-item .count {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-muted);
.detail-card {
background: rgba(15, 23, 42, 0.3);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border-color);
}
/* 节点详情 */
.node-detail {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
.detail-header {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.node-detail .detail-title {
.detail-title {
font-size: 1rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 4px;
word-break: break-all;
}
.detail-badge {
display: inline-block;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 12px;
background: var(--border-color);
color: var(--text-sec);
}
.prop-row {
margin-bottom: 8px;
color: var(--primary-light);
}
.node-detail .detail-type {
.prop-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 12px;
color: var(--text-sec);
margin-bottom: 2px;
}
.node-detail .detail-props {
font-size: 0.875rem;
.prop-value {
font-size: 0.85rem;
color: var(--text-main);
word-break: break-word;
line-height: 1.4;
}
.node-detail .prop-item {
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
/* 统计数据 */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.node-detail .prop-key {
color: var(--text-muted);
font-size: 0.75rem;
.stat-box {
background: rgba(15, 23, 42, 0.3);
padding: 12px;
border-radius: 8px;
text-align: center;
border: 1px solid var(--border-color);
}
.node-detail .prop-value {
margin-top: 2px;
word-break: break-all;
.stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-main);
}
/* 图谱容器 */
.graph-container {
position: fixed;
top: 60px;
left: 300px;
right: 0;
bottom: 0;
transition: left 0.3s;
.stat-label {
font-size: 0.75rem;
color: var(--text-sec);
margin-top: 4px;
}
.graph-container.fullwidth {
left: 0;
/* 主画布区 */
.main-content {
flex: 1;
position: relative;
background: var(--bg-color);
overflow: hidden;
}
#network {
width: 100%;
height: 100%;
background: var(--bg-color);
}
/* 加载状态 */
.loading-overlay {
/* 浮动工具栏 */
.floating-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 6px;
display: flex;
gap: 4px;
box-shadow: var(--shadow-lg);
z-index: 10;
}
.top-right {
top: 20px;
right: 20px;
}
.bottom-right {
bottom: 20px;
right: 20px;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-color);
z-index: 500;
}
.bottom-left {
bottom: 20px;
left: 20px;
/* 当侧边栏存在时,需要调整left */
left: 340px;
transition: left 0.3s ease;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
.sidebar.collapsed ~ .main-content .bottom-left {
left: 20px;
}
.top-left-toggle {
position: absolute;
top: 20px;
left: 20px;
z-index: 30;
display: none; /* 默认隐藏,折叠时显示 */
}
@keyframes spin {
to { transform: rotate(360deg); }
.sidebar.collapsed ~ .main-content .top-left-toggle {
display: block;
}
.loading-text {
margin-top: 16px;
color: var(--text-muted);
/* 按钮样式 */
.btn-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
color: var(--text-sec);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
/* 空状态 */
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--text-muted);
.btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-main);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.5;
.btn-icon.primary {
background: var(--primary-color);
color: white;
}
.btn-icon.primary:hover {
background: var(--primary-hover);
}
/* 提示信息 */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
display: none;
animation: slideIn 0.3s;
z-index: 2000;
.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
.btn-sm {
padding: 4px 12px;
height: 32px;
width: auto;
font-size: 0.8rem;
}
/* 图例 */
.legend {
position: fixed;
bottom: 20px;
left: 320px;
background: var(--card-bg);
.legend-panel {
background: rgba(30, 41, 59, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
display: flex;
gap: 16px;
z-index: 100;
transition: left 0.3s;
padding: 12px;
}
.legend.fullwidth {
left: 20px;
.legend-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-sec);
margin-bottom: 8px;
}
.legend-items {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.legend-item {
... ... @@ -384,729 +432,727 @@
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--text-main);
}
.legend-item .dot {
width: 10px;
height: 10px;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
/* 加载与空状态 */
.overlay-message {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 17, 42, 0.8);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 50;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(99, 102, 241, 0.3);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
margin-bottom: 16px;
}
.empty-icon {
width: 64px;
height: 64px;
color: var(--text-sec);
opacity: 0.5;
margin-bottom: 16px;
}
/* 全屏模式 */
.fullscreen-btn {
.toast {
position: fixed;
bottom: 20px;
right: 20px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--card-bg);
border: 1px solid var(--primary-color);
color: var(--text-main);
padding: 10px 24px;
border-radius: 50px;
box-shadow: var(--shadow-lg);
z-index: 100;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.toast.show {
transform: translateX(-50%) translateY(0);
}
/* 节点类型颜色 */
.color-topic { background-color: #EF4444; }
.color-engine { background-color: #F59E0B; }
.color-section { background-color: #10B981; }
.color-search_query { background-color: #3B82F6; }
.color-source { background-color: #8B5CF6; }
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
/* 颜色定义 */
.color-topic { color: #EF4444; background-color: #EF4444; }
.color-engine { color: #F59E0B; background-color: #F59E0B; }
.color-section { color: #10B981; background-color: #10B981; }
.color-search_query { color: #3B82F6; background-color: #3B82F6; }
.color-source { color: #8B5CF6; background-color: #8B5CF6; }
</style>
</head>
<body>
<!-- 顶部工具栏 -->
<div class="toolbar">
<h1>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="5" r="3"/>
<circle cx="5" cy="19" r="3"/>
<circle cx="19" cy="19" r="3"/>
<line x1="12" y1="8" x2="5" y2="16"/>
<line x1="12" y1="8" x2="19" y2="16"/>
</svg>
知识图谱
</h1>
<div class="toolbar-divider"></div>
<button class="btn" id="toggleSidebar" title="切换侧边栏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>
</button>
<button class="btn" id="fitBtn" title="适应视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
适应
</button>
<button class="btn" id="zoomInBtn" title="放大">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="11" y1="8" x2="11" y2="14"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
</button>
<button class="btn" id="zoomOutBtn" title="缩小">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
</button>
<button class="btn" id="manualRefreshBtn" title="手动刷新图谱">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/>
<path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/>
</svg>
刷新
</button>
<div class="search-group">
<div class="search-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" id="searchInput" placeholder="搜索节点...">
<div class="app-container">
<!-- 左侧边栏 -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="app-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<span>知识图谱</span>
</div>
<button class="btn-icon" id="toggleSidebar" title="收起侧边栏">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
</div>
<div class="search-actions">
<button class="btn" id="searchBtn" title="搜索">搜索</button>
<button class="btn" id="searchPrevBtn" title="上一个"></button>
<button class="btn" id="searchNextBtn" title="下一个"></button>
<span class="search-status" id="searchStatus">0/0</span>
<div class="sidebar-content">
<!-- 搜索区域 -->
<div class="section">
<div class="section-title">搜索节点</div>
<div class="search-wrapper">
<input type="text" id="searchInput" class="search-input" placeholder="输入关键词...">
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<div class="search-controls">
<button class="btn-icon btn-sm" id="searchPrevBtn" disabled title="上一个">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>
</button>
<button class="btn-icon btn-sm" id="searchNextBtn" disabled title="下一个">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<span class="search-status" id="searchStatus"></span>
</div>
</div>
<!-- 节点详情 -->
<div class="section node-details" id="nodeDetailPanel">
<div class="section-title">
<span>选定节点</span>
<button class="btn-icon btn-sm" onclick="network.unselectAll(); hideNodeDetail();" title="取消选择">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="detail-card">
<div class="detail-header">
<div class="detail-title" id="detailTitle"></div>
<span class="detail-badge" id="detailType"></span>
</div>
<div id="detailProps"></div>
</div>
</div>
<!-- 过滤器 -->
<div class="section">
<div class="section-title">显示节点</div>
<div class="filter-list" id="filterList">
<!-- 动态生成 -->
<label class="filter-item active">
<input type="checkbox" checked data-type="topic">
<span class="filter-dot color-topic"></span>
<span class="filter-name">主题 (Topic)</span>
<span class="filter-count" id="count-topic">0</span>
</label>
<label class="filter-item active">
<input type="checkbox" checked data-type="engine">
<span class="filter-dot color-engine"></span>
<span class="filter-name">分析引擎 (Engine)</span>
<span class="filter-count" id="count-engine">0</span>
</label>
<label class="filter-item active">
<input type="checkbox" checked data-type="section">
<span class="filter-dot color-section"></span>
<span class="filter-name">报告段落 (Section)</span>
<span class="filter-count" id="count-section">0</span>
</label>
<label class="filter-item active">
<input type="checkbox" checked data-type="search_query">
<span class="filter-dot color-search_query"></span>
<span class="filter-name">搜索词 (Query)</span>
<span class="filter-count" id="count-search_query">0</span>
</label>
<label class="filter-item active">
<input type="checkbox" checked data-type="source">
<span class="filter-dot color-source"></span>
<span class="filter-name">数据来源 (Source)</span>
<span class="filter-count" id="count-source">0</span>
</label>
</div>
</div>
<!-- 统计信息 -->
<div class="section">
<div class="section-title">图谱统计</div>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-value" id="totalNodes">0</div>
<div class="stat-label">节点总数</div>
</div>
<div class="stat-box">
<div class="stat-value" id="totalEdges">0</div>
<div class="stat-label">关系总数</div>
</div>
</div>
</div>
</div>
</div>
<div class="stats" id="statsContainer">
<div class="stat-item">
<span class="label">节点</span>
<span class="value" id="nodeCount">0</span>
</aside>
<!-- 主画布 -->
<main class="main-content">
<!-- 侧边栏展开按钮 -->
<button class="btn-icon floating-toolbar top-left-toggle" id="expandSidebar" title="展开侧边栏">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</button>
<!-- 右上角工具栏 -->
<div class="floating-toolbar top-right">
<button class="btn-icon" id="refreshBtn" title="刷新图谱">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
<button class="btn-icon" id="fullscreenBtn" title="全屏模式">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2 2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
</svg>
</button>
</div>
<div class="stat-item">
<span class="label">关系</span>
<span class="value" id="edgeCount">0</span>
<!-- 右下角缩放控制 -->
<div class="floating-toolbar bottom-right">
<button class="btn-icon" id="zoomInBtn" title="放大">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button class="btn-icon" id="zoomOutBtn" title="缩小">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<div style="height: 1px; background: var(--border-color); margin: 4px 2px;"></div>
<button class="btn-icon" id="fitBtn" title="适应视图">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- 左侧面板 -->
<div class="sidebar" id="sidebar">
<div class="filter-group">
<h3>节点类型</h3>
<label class="filter-item">
<input type="checkbox" checked data-type="topic">
<span class="color-dot color-topic"></span>
<span>主题</span>
<span class="count" id="count-topic">0</span>
</label>
<label class="filter-item">
<input type="checkbox" checked data-type="engine">
<span class="color-dot color-engine"></span>
<span>分析引擎</span>
<span class="count" id="count-engine">0</span>
</label>
<label class="filter-item">
<input type="checkbox" checked data-type="section">
<span class="color-dot color-section"></span>
<span>报告段落</span>
<span class="count" id="count-section">0</span>
</label>
<label class="filter-item">
<input type="checkbox" checked data-type="search_query">
<span class="color-dot color-search_query"></span>
<span>搜索关键词</span>
<span class="count" id="count-search_query">0</span>
</label>
<label class="filter-item">
<input type="checkbox" checked data-type="source">
<span class="color-dot color-source"></span>
<span>数据来源</span>
<span class="count" id="count-source">0</span>
</label>
</div>
<div class="node-detail" id="nodeDetail" style="display: none;">
<h3>节点详情</h3>
<div class="detail-title" id="detailTitle"></div>
<div class="detail-type" id="detailType"></div>
<div class="detail-props" id="detailProps"></div>
</div>
</div>
<!-- 左下角图例 -->
<div class="floating-toolbar bottom-left legend-panel">
<div class="legend-title">图例</div>
<div class="legend-items">
<div class="legend-item"><span class="legend-dot color-topic"></span>主题</div>
<div class="legend-item"><span class="legend-dot color-engine"></span>引擎</div>
<div class="legend-item"><span class="legend-dot color-section"></span>段落</div>
<div class="legend-item"><span class="legend-dot color-search_query"></span>搜索词</div>
<div class="legend-item"><span class="legend-dot color-source"></span>来源</div>
</div>
</div>
<!-- 图谱容器 -->
<div class="graph-container" id="graphContainer" data-report-id="{{ report_id | e if report_id else '' }}">
<div id="network"></div>
<!-- 加载状态 -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
<div class="loading-text">正在加载知识图谱...</div>
</div>
<!-- 空状态 -->
<div class="empty-state" id="emptyState" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<circle cx="12" cy="12" r="10"/>
<path d="M8 15h8"/>
<path d="M9 9h.01"/>
<path d="M15 9h.01"/>
</svg>
<h3>暂无图谱数据</h3>
<p>请先生成报告以创建知识图谱</p>
</div>
</div>
<!-- 网络图容器 -->
<div id="network" data-report-id="{{ report_id | e if report_id else '' }}"></div>
<!-- 图例 -->
<div class="legend" id="legend">
<div class="legend-item">
<span class="dot color-topic"></span>
<span>主题</span>
</div>
<div class="legend-item">
<span class="dot color-engine"></span>
<span>引擎</span>
</div>
<div class="legend-item">
<span class="dot color-section"></span>
<span>段落</span>
</div>
<div class="legend-item">
<span class="dot color-search_query"></span>
<span>搜索词</span>
</div>
<div class="legend-item">
<span class="dot color-source"></span>
<span>来源</span>
</div>
</div>
<!-- 加载遮罩 -->
<div class="overlay-message" id="loadingOverlay">
<div class="spinner"></div>
<div>正在构建知识图谱...</div>
</div>
<!-- 全屏按钮 -->
<button class="btn fullscreen-btn" id="fullscreenBtn" title="全屏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/>
</svg>
</button>
<!-- 空状态遮罩 -->
<div class="overlay-message" id="emptyState" style="display: none;">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<h3>暂无图谱数据</h3>
<p style="color: var(--text-sec); margin-top: 8px;">请生成报告以查看关联分析</p>
<button class="btn-icon primary" style="margin-top: 20px; width: auto; padding: 0 20px;" onclick="loadGraphData({fromManual: true})">
重试
</button>
</div>
</main>
</div>
<!-- 提示 -->
<div class="toast" id="toast"></div>
<!-- 消息提示 -->
<div class="toast" id="toast">
<div id="toastIcon" style="display: flex; align-items: center;"></div>
<span id="toastMsg">操作成功</span>
</div>
<script>
// 配置
const NODE_COLORS = {
topic: '#EF4444',
engine: '#F59E0B',
section: '#10B981',
search_query: '#3B82F6',
source: '#8B5CF6'
};
const NODE_SHAPES = {
topic: 'star',
engine: 'diamond',
section: 'dot',
search_query: 'triangle',
source: 'square'
// 配置常量
const NODE_CONFIG = {
topic: { color: '#EF4444', shape: 'dot', size: 30, label: '主题' },
engine: { color: '#F59E0B', shape: 'diamond', size: 25, label: '引擎' },
section: { color: '#10B981', shape: 'dot', size: 15, label: '段落' },
search_query: { color: '#3B82F6', shape: 'triangle', size: 15, label: '搜索词' },
source: { color: '#8B5CF6', shape: 'square', size: 15, label: '来源' }
};
// 全局变量
// 全局状态
let network = null;
let allNodes = [];
let allEdges = [];
let graphData = { nodes: [], edges: [] };
let reportId = null;
const graphContainer = document.getElementById('graphContainer');
if (graphContainer) {
reportId = graphContainer.dataset.reportId || null;
}
let graphReady = false;
let graphPollTimer = null;
let graphSearchResults = [];
let graphSearchIndex = -1;
let graphSearchKeyword = '';
const GRAPH_POLL_INTERVAL = 4000;
let isGraphReady = false;
let pollTimer = null;
// 搜索状态
let searchState = {
keyword: '',
results: [],
currentIndex: -1
};
// DOM 元素
const els = {
network: document.getElementById('network'),
sidebar: document.getElementById('sidebar'),
loading: document.getElementById('loadingOverlay'),
empty: document.getElementById('emptyState'),
stats: {
nodes: document.getElementById('totalNodes'),
edges: document.getElementById('totalEdges'),
counts: {}
},
search: {
input: document.getElementById('searchInput'),
prev: document.getElementById('searchPrevBtn'),
next: document.getElementById('searchNextBtn'),
status: document.getElementById('searchStatus')
},
detail: {
panel: document.getElementById('nodeDetailPanel'),
title: document.getElementById('detailTitle'),
type: document.getElementById('detailType'),
props: document.getElementById('detailProps')
}
};
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadGraphData();
startGraphPolling();
// 获取 Report ID
const container = document.getElementById('network');
if (container) reportId = container.dataset.reportId || null;
initGraph();
setupEventListeners();
startPolling();
});
// 加载图谱数据
// 初始化图谱
function initGraph() {
loadGraphData();
}
// 加载数据
async function loadGraphData(options = {}) {
const { fromPoll = false, fromManual = false, allowFallback = true } = options;
// 仅在首次或未加载成功时展示大遮罩
if (!graphReady || !fromPoll) {
showLoading(true);
}
const { fromPoll = false, fromManual = false } = options;
if (!isGraphReady && !fromPoll) showLoading(true);
try {
const fetchGraph = async (id) => {
const fetchUrl = async (id) => {
const url = id ? `/api/graph/${id}` : '/api/graph/latest';
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success || !data.graph) {
return null;
}
return data;
const res = await fetch(url, { cache: 'no-store' });
return await res.json();
};
let data = await fetchGraph(reportId);
let usedFallback = false;
const allowLatestFallback = allowFallback && !reportId;
if ((!data || !data.graph) && allowLatestFallback) {
data = await fetchGraph(null);
usedFallback = !!(data && data.graph);
}
// 尝试获取数据
let data = await fetchUrl(reportId);
if (data && data.graph) {
if (data.report_id) {
reportId = data.report_id;
}
allNodes = data.graph.nodes;
allEdges = data.graph.edges;
updateStats(data.graph.stats);
resetGraphSearchState();
renderGraph();
const input = document.getElementById('searchInput');
const currentKeyword = (input && input.value) ? input.value : '';
if (currentKeyword) {
runGraphSearch(currentKeyword);
}
showLoading(false);
showEmpty(false);
graphReady = true;
stopGraphPolling();
if (fromManual) {
showToast(usedFallback ? '未找到指定图谱,已切换至最新版本' : '已刷新最新图谱');
}
} else {
showEmpty(true);
graphReady = false;
allNodes = [];
allEdges = [];
resetGraphSearchState();
updateStats({
total_nodes: 0,
total_edges: 0,
topic: 0,
engine: 0,
section: 0,
search_query: 0,
source: 0
});
if (network) {
network.destroy();
network = null;
}
hideNodeDetail();
showLoading(false);
if (fromManual) {
showToast('未找到图谱数据');
}
// 如果指定ID没有数据,尝试获取最新
if ((!data || !data.success || !data.graph) && !reportId) {
data = await fetchUrl(null);
}
} catch (error) {
console.error('加载图谱失败:', error);
showToast('加载图谱失败: ' + error.message);
if (!graphReady) {
showEmpty(true);
if (data && data.success && data.graph) {
handleGraphSuccess(data, fromManual);
} else {
handleGraphEmpty(fromManual);
}
showLoading(false);
} catch (err) {
console.error("Graph load error:", err);
if (fromManual) showToast('加载失败: ' + err.message, 'error');
if (!isGraphReady) showEmpty(true);
} finally {
if (!isGraphReady) showLoading(false);
}
}
function startGraphPolling() {
if (graphPollTimer) return;
graphPollTimer = setInterval(() => {
loadGraphData({ fromPoll: true });
}, GRAPH_POLL_INTERVAL);
function handleGraphSuccess(data, showMessage) {
const newNodes = data.graph.nodes || [];
const newEdges = data.graph.edges || [];
// 简单的Diff检查,避免频繁重绘
if (isGraphReady && newNodes.length === graphData.nodes.length && newEdges.length === graphData.edges.length) {
if (showMessage) showToast('已是最新数据');
return;
}
graphData = { nodes: newNodes, edges: newEdges };
if (data.report_id) reportId = data.report_id;
updateStats(data.graph.stats);
renderNetwork();
// 恢复搜索状态
if (searchState.keyword) runSearch(searchState.keyword);
isGraphReady = true;
showEmpty(false);
showLoading(false);
stopPolling(); // 成功加载后停止轮询,或者可以继续轮询以获得实时更新
if (showMessage) showToast('图谱已更新');
}
function stopGraphPolling() {
if (graphPollTimer) {
clearInterval(graphPollTimer);
graphPollTimer = null;
function handleGraphEmpty(showMessage) {
if (!isGraphReady) {
showEmpty(true);
// 清空数据
graphData = { nodes: [], edges: [] };
if (network) {
network.destroy();
network = null;
}
updateStats({});
}
if (showMessage) showToast('暂无数据');
}
// 渲染图谱
function renderGraph() {
const container = document.getElementById('network');
// 处理节点
// 渲染 Vis Network
function renderNetwork() {
const visibleTypes = getVisibleTypes();
const filteredNodes = allNodes.filter(n => visibleTypes.includes(n.group));
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
const nodes = new vis.DataSet(filteredNodes.map(node => ({
id: node.id,
label: truncateLabel(node.label, 20),
title: node.title,
group: node.group,
color: {
background: NODE_COLORS[node.group] || '#6B7280',
border: NODE_COLORS[node.group] || '#6B7280',
highlight: {
background: lightenColor(NODE_COLORS[node.group] || '#6B7280'),
border: NODE_COLORS[node.group] || '#6B7280'
}
},
shape: NODE_SHAPES[node.group] || 'dot',
size: node.group === 'topic' ? 30 : (node.group === 'engine' ? 25 : 15),
font: {
color: '#F1F5F9',
size: 12
},
// 保存原始数据
_data: node
})));
// 处理边
const edges = new vis.DataSet(allEdges
.filter(e => filteredNodeIds.has(e.from) && filteredNodeIds.has(e.to))
.map(edge => ({
from: edge.from,
to: edge.to,
label: edge.label,
arrows: edge.arrows || 'to',
color: {
color: '#475569',
highlight: '#818CF8'
},
font: {
color: '#94A3B8',
size: 10,
strokeWidth: 0
},
smooth: {
type: 'continuous'
}
}))
);
const nodes = graphData.nodes
.filter(n => visibleTypes.includes(n.group))
.map(n => ({
id: n.id,
label: truncate(n.label, 20),
group: n.group,
title: n.title || n.label, // Tooltip
_raw: n, // 保存原始数据
...getNodeStyle(n.group)
}));
const nodeIds = new Set(nodes.map(n => n.id));
const edges = graphData.edges
.filter(e => nodeIds.has(e.from) && nodeIds.has(e.to))
.map(e => ({
from: e.from,
to: e.to,
arrows: 'to',
color: { color: '#475569', opacity: 0.6 },
width: 1
}));
const data = {
nodes: new vis.DataSet(nodes),
edges: new vis.DataSet(edges)
};
// 图谱配置
const options = {
nodes: {
borderWidth: 2,
shadow: true
shadow: true,
font: { color: '#f8fafc', size: 12, face: 'Inter' }
},
edges: {
width: 1,
shadow: true
smooth: { type: 'continuous', roundness: 0.5 }
},
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -100,
centralGravity: 0.01,
springLength: 150,
springConstant: 0.08,
damping: 0.5
},
stabilization: {
enabled: true,
iterations: 200
}
stabilization: { enabled: true, iterations: 100 },
barnesHut: { gravitationalConstant: -2000, springConstant: 0.04, springLength: 95 }
},
interaction: {
hover: true,
tooltipDelay: 100,
zoomView: true,
dragView: true
tooltipDelay: 200,
zoomView: true
}
};
// 创建网络
network = new vis.Network(container, { nodes, edges }, options);
// 节点点击事件
network.on('click', (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const node = allNodes.find(n => n.id === nodeId);
if (node) {
showNodeDetail(node);
if (!network) {
network = new vis.Network(els.network, data, options);
// 事件绑定
network.on('click', params => {
if (params.nodes.length) {
const nodeId = params.nodes[0];
const node = graphData.nodes.find(n => n.id === nodeId);
showNodeDetails(node);
} else {
hideNodeDetail();
}
} else {
hideNodeDetail();
}
});
// 稳定后适应视图
network.once('stabilizationIterationsDone', () => {
network.fit({ animation: true });
});
});
// 如果已有搜索关键词,重新聚焦当前匹配;否则更新状态显示
if (graphSearchKeyword) {
runGraphSearch(graphSearchKeyword);
network.on('stabilizationIterationsDone', () => {
// 初次加载完成也可以fit一下,但有时会跳动,可视情况开启
// network.fit();
});
} else {
updateGraphSearchStatus();
network.setData(data);
}
}
// 显示节点详情
function showNodeDetail(node) {
const detailPanel = document.getElementById('nodeDetail');
const titleEl = document.getElementById('detailTitle');
const typeEl = document.getElementById('detailType');
const propsEl = document.getElementById('detailProps');
titleEl.textContent = node.label;
const typeLabels = {
topic: '主题',
engine: '分析引擎',
section: '报告段落',
search_query: '搜索关键词',
source: '数据来源'
function getNodeStyle(group) {
const conf = NODE_CONFIG[group] || { color: '#94a3b8', shape: 'dot' };
return {
color: {
background: conf.color,
border: conf.color,
highlight: { background: lighten(conf.color, 20), border: conf.color },
hover: { background: lighten(conf.color, 20), border: conf.color }
},
shape: conf.shape,
size: conf.size
};
typeEl.textContent = typeLabels[node.group] || node.group;
// 显示属性
let propsHtml = '';
const props = node.properties || {};
for (const [key, value] of Object.entries(props)) {
if (value) {
propsHtml += `
<div class="prop-item">
<div class="prop-key">${key}</div>
<div class="prop-value">${truncateText(String(value), 200)}</div>
</div>
`;
}
}
propsEl.innerHTML = propsHtml || '<div class="prop-item">无附加属性</div>';
detailPanel.style.display = 'block';
}
// 隐藏节点详情
function hideNodeDetail() {
document.getElementById('nodeDetail').style.display = 'none';
}
// 交互逻辑
function setupEventListeners() {
// 侧边栏切换
document.getElementById('toggleSidebar').addEventListener('click', () => {
els.sidebar.classList.add('collapsed');
});
document.getElementById('expandSidebar').addEventListener('click', () => {
els.sidebar.classList.remove('collapsed');
});
function resetGraphSearchState() {
graphSearchResults = [];
graphSearchIndex = -1;
graphSearchKeyword = '';
updateGraphSearchStatus();
}
// 搜索
els.search.input.addEventListener('input', (e) => runSearch(e.target.value));
els.search.prev.addEventListener('click', () => navSearch(-1));
els.search.next.addEventListener('click', () => navSearch(1));
function updateGraphSearchStatus() {
const statusEl = document.getElementById('searchStatus');
const prevBtn = document.getElementById('searchPrevBtn');
const nextBtn = document.getElementById('searchNextBtn');
const hasResults = graphSearchResults.length > 0 && graphSearchIndex >= 0;
if (statusEl) {
statusEl.textContent = hasResults ? `${graphSearchIndex + 1}/${graphSearchResults.length}` : '0/0';
statusEl.style.visibility = hasResults ? 'visible' : 'hidden';
}
if (prevBtn) prevBtn.disabled = !hasResults;
if (nextBtn) nextBtn.disabled = !hasResults;
}
// 过滤器
document.querySelectorAll('.filter-item input').forEach(cb => {
cb.addEventListener('change', () => {
const parent = cb.closest('.filter-item');
if (cb.checked) parent.classList.add('active');
else parent.classList.remove('active');
renderNetwork();
});
});
function runGraphSearch(keyword) {
if (!network) return;
const term = (keyword || '').trim();
graphSearchKeyword = term;
// 工具栏
document.getElementById('refreshBtn').addEventListener('click', () => loadGraphData({fromManual: true}));
document.getElementById('zoomInBtn').addEventListener('click', () => network?.moveTo({scale: network.getScale() * 1.2, animation: true}));
document.getElementById('zoomOutBtn').addEventListener('click', () => network?.moveTo({scale: network.getScale() / 1.2, animation: true}));
document.getElementById('fitBtn').addEventListener('click', () => network?.fit({animation: true}));
document.getElementById('fullscreenBtn').addEventListener('click', toggleFullScreen);
}
if (!term) {
resetGraphSearchState();
network.selectNodes([]);
// 搜索功能
function runSearch(keyword) {
keyword = keyword.trim().toLowerCase();
searchState.keyword = keyword;
if (!keyword) {
searchState.results = [];
searchState.currentIndex = -1;
updateSearchUI();
if(network) network.unselectAll();
return;
}
const lower = term.toLowerCase();
const nodesDataset = network.body && network.body.data && network.body.data.nodes ? network.body.data.nodes.get() : [];
graphSearchResults = nodesDataset.filter(n => (n.label || '').toLowerCase().includes(lower));
graphSearchResults.sort((a, b) => {
const aLabel = (a.label || '').toLowerCase();
const bLabel = (b.label || '').toLowerCase();
if (aLabel === bLabel) {
return String(a.id).localeCompare(String(b.id), 'zh');
}
return aLabel.localeCompare(bLabel, 'zh');
});
graphSearchIndex = graphSearchResults.length ? 0 : -1;
if (!graphSearchResults.length) {
network.selectNodes([]);
hideNodeDetail();
updateGraphSearchStatus();
return;
// 在当前显示的数据集中搜索
const visibleTypes = getVisibleTypes();
searchState.results = graphData.nodes
.filter(n => visibleTypes.includes(n.group) && n.label.toLowerCase().includes(keyword))
.map(n => n.id);
searchState.currentIndex = searchState.results.length ? 0 : -1;
updateSearchUI();
if (searchState.results.length > 0) {
focusNode(searchState.results[0]);
}
focusGraphSearchIndex(graphSearchIndex);
}
function focusGraphSearchIndex(index) {
if (!network || !graphSearchResults.length) return;
const total = graphSearchResults.length;
graphSearchIndex = ((index % total) + total) % total;
const target = graphSearchResults[graphSearchIndex];
network.selectNodes([target.id]);
network.focus(target.id, { animation: true, scale: 1.4 });
showNodeDetail(target._data || target);
updateGraphSearchStatus();
function navSearch(direction) {
if (!searchState.results.length) return;
const len = searchState.results.length;
searchState.currentIndex = (searchState.currentIndex + direction + len) % len;
focusNode(searchState.results[searchState.currentIndex]);
updateSearchUI();
}
function stepGraphSearch(delta) {
if (!graphSearchResults.length) return;
focusGraphSearchIndex(graphSearchIndex + delta);
function focusNode(nodeId) {
if (!network) return;
network.selectNodes([nodeId]);
network.focus(nodeId, {
scale: 1.2,
animation: true
});
const node = graphData.nodes.find(n => n.id === nodeId);
if (node) showNodeDetails(node);
}
// 更新统计
function updateStats(stats) {
document.getElementById('nodeCount').textContent = stats.total_nodes || 0;
document.getElementById('edgeCount').textContent = stats.total_edges || 0;
function updateSearchUI() {
const { results, currentIndex } = searchState;
const hasResults = results.length > 0;
// 更新各类型计数
document.getElementById('count-topic').textContent = stats.topic || 0;
document.getElementById('count-engine').textContent = stats.engine || 0;
document.getElementById('count-section').textContent = stats.section || 0;
document.getElementById('count-search_query').textContent = stats.search_query || 0;
document.getElementById('count-source').textContent = stats.source || 0;
}
// 获取可见类型
function getVisibleTypes() {
const types = [];
document.querySelectorAll('.filter-item input[type="checkbox"]').forEach(cb => {
if (cb.checked) {
types.push(cb.dataset.type);
els.search.prev.disabled = !hasResults;
els.search.next.disabled = !hasResults;
els.search.status.textContent = hasResults
? `${currentIndex + 1} / ${results.length}`
: (searchState.keyword ? '无结果' : '');
}
// 详情面板
function showNodeDetails(node) {
if (!node) return;
els.detail.panel.style.display = 'block';
els.detail.title.textContent = node.label;
els.detail.type.textContent = (NODE_CONFIG[node.group] || {}).label || node.group;
let html = '';
const props = node.properties || {};
if (Object.keys(props).length === 0) {
html = '<div class="prop-label" style="text-align:center; padding:10px;">暂无额外属性</div>';
} else {
for (const [k, v] of Object.entries(props)) {
html += `
<div class="prop-row">
<div class="prop-label">${k}</div>
<div class="prop-value">${v}</div>
</div>
`;
}
});
return types;
}
els.detail.props.innerHTML = html;
}
// 设置事件监听
function setupEventListeners() {
// 侧边栏切换
document.getElementById('toggleSidebar').addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
const container = document.getElementById('graphContainer');
const legend = document.getElementById('legend');
sidebar.classList.toggle('collapsed');
container.classList.toggle('fullwidth');
legend.classList.toggle('fullwidth');
});
// 适应视图
document.getElementById('fitBtn').addEventListener('click', () => {
if (network) network.fit({ animation: true });
});
// 放大
document.getElementById('zoomInBtn').addEventListener('click', () => {
if (network) {
const scale = network.getScale() * 1.2;
network.moveTo({ scale, animation: true });
}
});
function hideNodeDetail() {
els.detail.panel.style.display = 'none';
}
// 缩小
document.getElementById('zoomOutBtn').addEventListener('click', () => {
if (network) {
const scale = network.getScale() / 1.2;
network.moveTo({ scale, animation: true });
}
// 辅助功能
function updateStats(stats = {}) {
els.stats.nodes.textContent = stats.total_nodes || 0;
els.stats.edges.textContent = stats.total_edges || 0;
['topic', 'engine', 'section', 'search_query', 'source'].forEach(type => {
const el = document.getElementById(`count-${type}`);
if (el) el.textContent = stats[type] || 0;
});
}
// 手动刷新
const manualRefreshBtn = document.getElementById('manualRefreshBtn');
if (manualRefreshBtn) {
manualRefreshBtn.addEventListener('click', () => {
loadGraphData({ fromManual: true });
});
}
function getVisibleTypes() {
return Array.from(document.querySelectorAll('.filter-item input:checked'))
.map(cb => cb.dataset.type);
}
// 全屏
document.getElementById('fullscreenBtn').addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
});
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => loadGraphData({fromPoll: true}), 5000);
}
// 搜索
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const searchPrevBtn = document.getElementById('searchPrevBtn');
const searchNextBtn = document.getElementById('searchNextBtn');
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
runGraphSearch(searchInput.value);
}
});
searchInput.addEventListener('input', () => {
if (!searchInput.value) {
resetGraphSearchState();
if (network) network.selectNodes([]);
}
});
}
if (searchBtn) {
searchBtn.addEventListener('click', () => runGraphSearch(searchInput ? searchInput.value : ''));
}
if (searchPrevBtn) {
searchPrevBtn.addEventListener('click', () => stepGraphSearch(-1));
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (searchNextBtn) {
searchNextBtn.addEventListener('click', () => stepGraphSearch(1));
}
// 筛选
document.querySelectorAll('.filter-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
renderGraph();
});
});
}
// 辅助函数
function showLoading(show) {
document.getElementById('loadingOverlay').style.display = show ? 'flex' : 'none';
els.loading.style.display = show ? 'flex' : 'none';
}
function showEmpty(show) {
document.getElementById('emptyState').style.display = show ? 'block' : 'none';
els.empty.style.display = show ? 'flex' : 'none';
if (show) els.loading.style.display = 'none';
}
function showToast(message) {
function showToast(msg, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
const iconContainer = document.getElementById('toastIcon');
document.getElementById('toastMsg').textContent = msg;
toast.style.borderColor = type === 'error' ? '#EF4444' : '#6366f1';
// 图标定义
const icons = {
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>',
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
};
// 如果是 error 则显示错号,否则显示对号
const isError = type === 'error';
iconContainer.innerHTML = isError ? icons.error : icons.success;
iconContainer.style.color = isError ? '#EF4444' : '#10B981';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
function truncateLabel(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
function toggleFullScreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
}
}
function truncateText(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
// Utils
function truncate(str, n) {
return (str && str.length > n) ? str.substr(0, n - 1) + '...' : str;
}
function lightenColor(color) {
// 简单的颜色变亮
const hex = color.replace('#', '');
const r = Math.min(255, parseInt(hex.slice(0, 2), 16) + 40);
const g = Math.min(255, parseInt(hex.slice(2, 4), 16) + 40);
const b = Math.min(255, parseInt(hex.slice(4, 6), 16) + 40);
return `rgb(${r}, ${g}, ${b})`;
function lighten(col, amt) {
var usePound = false;
if (col[0] == "#") {
col = col.slice(1);
usePound = true;
}
var num = parseInt(col, 16);
var r = (num >> 16) + amt;
if (r > 255) r = 255;
else if (r < 0) r = 0;
var b = ((num >> 8) & 0x00FF) + amt;
if (b > 255) b = 255;
else if (b < 0) b = 0;
var g = (num & 0x0000FF) + amt;
if (g > 255) g = 255;
else if (g < 0) g = 0;
return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16);
}
</script>
</body>
... ...