- 新增多轮 GraphRAG 检索功能,支持流式进度输出(SSE) - 新增用户管理界面,可查看所有用户图谱统计并快速导航 - 新增多 Agent 任务拆解与执行服务,支持复杂任务协作处理 - 改进 embedding 和 rerank 服务的容错机制,支持备用模型和端点 - 更新前端样式遵循 Acmetone 设计规范,优化视觉一致性 - 新增流式分析接口,支持并行处理和专家评审选项
2103 lines
60 KiB
Vue
2103 lines
60 KiB
Vue
<template>
|
||
<div class="dashboard">
|
||
<aside class="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="logo-mark">
|
||
<svg width="32" height="32" viewBox="0 0 48 48" fill="none">
|
||
<circle cx="24" cy="24" r="20" stroke="#6366f1" stroke-width="2.5"/>
|
||
<circle cx="24" cy="24" r="6" fill="#6366f1"/>
|
||
<line x1="24" y1="4" x2="24" y2="18" stroke="#6366f1" stroke-width="2.5"/>
|
||
<line x1="24" y1="30" x2="24" y2="44" stroke="#6366f1" stroke-width="2.5"/>
|
||
<line x1="4" y1="24" x2="18" y2="24" stroke="#6366f1" stroke-width="2.5"/>
|
||
<line x1="30" y1="24" x2="44" y2="24" stroke="#6366f1" stroke-width="2.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="logo-text">
|
||
<span class="logo-name">GraphRAG</span>
|
||
<span class="logo-sub">管理平台</span>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="sidebar-nav">
|
||
<button
|
||
v-for="item in navItems"
|
||
:key="item.id"
|
||
class="nav-btn"
|
||
:class="{ active: activeTab === item.id }"
|
||
@click="activeTab = item.id"
|
||
>
|
||
<span class="nav-icon" v-html="item.icon"></span>
|
||
<span>{{ item.label }}</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="user-card">
|
||
<div class="user-avatar">A</div>
|
||
<div class="user-info">
|
||
<span class="user-role">管理员</span>
|
||
<span class="user-name">Admin</span>
|
||
</div>
|
||
</div>
|
||
<button class="logout-btn" @click="handleLogout">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||
<polyline points="16 17 21 12 16 7"/>
|
||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||
</svg>
|
||
退出登录
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="main-panel">
|
||
<header class="top-header">
|
||
<div class="header-left">
|
||
<h1 class="page-title">{{ currentNav.label }}</h1>
|
||
<p class="page-desc">{{ currentNav.desc }}</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<span class="current-user-badge">当前用户:{{ userId }}</span>
|
||
<button class="btn-ghost" @click="goUserList">用户列表</button>
|
||
<button class="btn-ghost" @click="refreshData">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="23 4 23 10 17 10"/>
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||
</svg>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="content-area">
|
||
<div v-if="activeTab === 'graph'" class="graph-view">
|
||
<div class="graph-toolbar">
|
||
<div class="toolbar-left">
|
||
<span class="data-badge">实时数据</span>
|
||
<span class="node-count">{{ graphStats.nodes }} 节点 · {{ graphStats.edges }} 关系</span>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<button class="btn-outline" @click="zoomIn">+ 放大</button>
|
||
<button class="btn-outline" @click="zoomOut">- 缩小</button>
|
||
<button class="btn-primary" @click="fitView">适应画布</button>
|
||
</div>
|
||
</div>
|
||
<div class="graph-canvas" ref="graphCanvas">
|
||
<div v-if="graphLoading" class="graph-loading">
|
||
<div class="spinner"></div>
|
||
<p>加载图谱数据...</p>
|
||
</div>
|
||
<svg v-show="!graphLoading" ref="svgEl" class="graph-svg"></svg>
|
||
<div v-if="graphLegendItems.length" class="graph-legend">
|
||
<span class="legend-title">Entity Types</span>
|
||
<div class="legend-items">
|
||
<div v-for="item in graphLegendItems" :key="item.type" class="legend-item">
|
||
<span class="legend-dot" :style="{ background: item.color }"></span>
|
||
<span class="legend-label">{{ item.label }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="edge-labels-toggle">
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" v-model="showEdgeLabels" @change="refreshGraph" />
|
||
<span class="slider"></span>
|
||
</label>
|
||
<span class="toggle-label">Show Edge Labels</span>
|
||
</div>
|
||
<div v-if="selectedDetail" class="detail-panel">
|
||
<div class="detail-panel-header">
|
||
<span class="detail-title">{{ selectedDetail.kind === 'node' ? '节点详情' : '关系详情' }}</span>
|
||
<button class="detail-close" @click="clearSelection">×</button>
|
||
</div>
|
||
<div class="detail-content" v-if="selectedDetail.kind === 'node'">
|
||
<div class="detail-row">
|
||
<span class="detail-label">名称</span>
|
||
<span class="detail-value">{{ selectedDetail.data.name || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">类型</span>
|
||
<span class="detail-value">{{ selectedDetail.data.type || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.type === 'person'">
|
||
<span class="detail-label">性别</span>
|
||
<span class="detail-value">{{ selectedDetail.data.gender || 'unknown' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">ID</span>
|
||
<span class="detail-value">{{ selectedDetail.data.id || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">节点大小</span>
|
||
<span class="detail-value">{{ Math.round(selectedDetail.data.radius || 0) }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.type === 'event'">
|
||
<span class="detail-label">重要度</span>
|
||
<span class="detail-value">{{ selectedDetail.data.importance ?? '-' }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.occurred_at">
|
||
<span class="detail-label">发生时间</span>
|
||
<span class="detail-value">{{ formatDateTime(selectedDetail.data.occurred_at) }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.summary">
|
||
<span class="detail-label">摘要</span>
|
||
<span class="detail-value summary-value">{{ selectedDetail.data.summary }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="detail-content" v-else>
|
||
<div class="detail-row">
|
||
<span class="detail-label">关系类型</span>
|
||
<span class="detail-value">{{ selectedDetail.data.type || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.eventCount">
|
||
<span class="detail-label">事件数</span>
|
||
<span class="detail-value">{{ selectedDetail.data.eventCount }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">起点</span>
|
||
<span class="detail-value">{{ selectedDetail.data.sourceName || selectedDetail.data.source || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">终点</span>
|
||
<span class="detail-value">{{ selectedDetail.data.targetName || selectedDetail.data.target || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.occurred_at">
|
||
<span class="detail-label">时间</span>
|
||
<span class="detail-value">{{ formatDateTime(selectedDetail.data.occurred_at) }}</span>
|
||
</div>
|
||
<div class="detail-row" v-if="selectedDetail.data.summary">
|
||
<span class="detail-label">摘要</span>
|
||
<span class="detail-value summary-value">{{ selectedDetail.data.summary }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'ingest'" class="ingest-view">
|
||
<div class="panel-grid">
|
||
<div class="panel-card">
|
||
<div class="panel-header">
|
||
<h3>文本导入</h3>
|
||
<span class="panel-tag">Ingest</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<textarea
|
||
v-model="ingestText"
|
||
class="text-area"
|
||
placeholder="在此输入文本内容,系统将自动完成分句、向量化并写入 Neo4j 与 Qdrant..."
|
||
rows="8"
|
||
></textarea>
|
||
<div class="panel-actions">
|
||
<button class="btn-primary" @click="handleIngest" :disabled="!ingestText || ingestLoading">
|
||
{{ ingestLoading ? '导入中(流式)...' : '开始导入' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-card">
|
||
<div class="panel-header">
|
||
<h3>导入记录</h3>
|
||
<span class="panel-tag info">Recent</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="log-list">
|
||
<div v-for="(log, i) in ingestLogs" :key="i" class="log-item" :class="log.type">
|
||
<span class="log-time">{{ log.time }}</span>
|
||
<span class="log-msg">{{ log.msg }}</span>
|
||
</div>
|
||
<p v-if="!ingestLogs.length" class="log-empty">暂无导入记录</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'query'" class="query-view">
|
||
<div class="panel-grid">
|
||
<div class="panel-card wide">
|
||
<div class="panel-header">
|
||
<h3>时序检索</h3>
|
||
<span class="panel-tag">Query</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="query-input-row">
|
||
<input
|
||
v-model="queryText"
|
||
class="query-input"
|
||
placeholder="输入查询内容,例如:张三和张四的关系是什么?"
|
||
@keyup.enter="handleQuery"
|
||
/>
|
||
<button class="btn-primary" @click="handleQuery" :disabled="!queryText || queryLoading">
|
||
{{ queryLoading ? '检索中...' : '检索' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="queryResult" class="query-result">
|
||
<div class="result-section">
|
||
<h4>检索结果</h4>
|
||
<p class="result-text">{{ queryResult.answer }}</p>
|
||
</div>
|
||
<div v-if="queryResult.chunks?.length" class="result-section">
|
||
<h4>相关片段</h4>
|
||
<div class="chunk-list">
|
||
<div v-for="(chunk, i) in queryResult.chunks" :key="i" class="chunk-item">
|
||
<p>{{ chunk.text || chunk.content }}</p>
|
||
<span v-if="chunk.score" class="chunk-score">相关度: {{ (chunk.score * 100).toFixed(1) }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'qa'" class="query-view">
|
||
<GraphQaPanel :user-id="userId" />
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'api'" class="api-view">
|
||
<div class="panel-grid">
|
||
<div class="panel-card wide">
|
||
<div class="panel-header">
|
||
<h3>接口调试</h3>
|
||
<span class="panel-tag">API Tester</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="api-row">
|
||
<select v-model="apiMethod" class="method-select">
|
||
<option value="GET">GET</option>
|
||
<option value="POST">POST</option>
|
||
<option value="PUT">PUT</option>
|
||
<option value="DELETE">DELETE</option>
|
||
</select>
|
||
<input v-model="apiUrl" class="api-url-input" placeholder="/graph/stats" @keyup.enter="sendApiRequest" />
|
||
<button class="btn-primary" @click="sendApiRequest" :disabled="apiLoading">
|
||
{{ apiLoading ? '发送中...' : '发送' }}
|
||
</button>
|
||
</div>
|
||
<div class="api-endpoints">
|
||
<span class="endpoint-label">快速调用:</span>
|
||
<button v-for="ep in quickEndpoints" :key="ep.path" class="endpoint-chip" @click="fillApi(ep)">
|
||
<span class="chip-method" :class="ep.method.toLowerCase()">{{ ep.method }}</span>
|
||
{{ ep.path }}
|
||
</button>
|
||
</div>
|
||
<div v-if="apiMethod === 'POST' || apiMethod === 'PUT'" class="api-body-section">
|
||
<h4 class="body-label">请求体 (JSON)</h4>
|
||
<textarea v-model="apiBody" class="text-area code-area" placeholder='{"key": "value"}' rows="8"></textarea>
|
||
</div>
|
||
<div class="api-response-section">
|
||
<div class="response-header">
|
||
<h4>响应结果</h4>
|
||
<span v-if="apiStatus" class="status-badge" :class="apiStatus >= 200 && apiStatus < 300 ? 'success' : 'error'">
|
||
{{ apiStatus }}
|
||
</span>
|
||
</div>
|
||
<pre class="response-pre">{{ apiResponse || '等待发送请求...' }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import * as d3 from 'd3'
|
||
import GraphQaPanel from '../components/GraphQaPanel.vue'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const activeTab = ref('graph')
|
||
const graphLoading = ref(false)
|
||
const ingestLoading = ref(false)
|
||
const queryLoading = ref(false)
|
||
const ingestText = ref('')
|
||
const queryText = ref('')
|
||
const queryResult = ref(null)
|
||
const ingestLogs = ref([])
|
||
const graphStats = ref({ nodes: 0, edges: 0 })
|
||
const graphCanvas = ref(null)
|
||
const svgEl = ref(null)
|
||
const selectedDetail = ref(null)
|
||
const showEdgeLabels = ref(false)
|
||
const graphLegendItems = ref([])
|
||
const apiMethod = ref('GET')
|
||
const apiUrl = ref('')
|
||
const apiBody = ref('')
|
||
const apiResponse = ref('')
|
||
const apiStatus = ref(null)
|
||
const apiLoading = ref(false)
|
||
const quickEndpoints = [
|
||
{ method: 'GET', path: '/health', body: '' },
|
||
{ method: 'GET', path: '/ready', body: '' },
|
||
{ method: 'POST', path: '/bootstrap', body: '{}' },
|
||
{ method: 'GET', path: '/graph/stats', body: '' },
|
||
{ method: 'POST', path: '/ingest', body: '{"persons":[{"id":"p1","name":"张三"}],"events":[{"id":"e1","summary":"测试事件","occurred_at":"2024-01-01T00:00:00Z","participants":["p1"],"topics":["t1"]}],"chunks":[{"id":"c1","text":"这是测试内容","payload":{"event_id":"e1"}}]}' },
|
||
{ method: 'POST', path: '/query/timeline', body: '{"a_id":"p1","b_id":"p2"}' },
|
||
{ method: 'POST', path: '/query/graphrag', body: '{"query_text":"张三的事件","top_k":5}' },
|
||
{ method: 'POST', path: '/auth/verify', body: '{"password":"your-password"}' }
|
||
]
|
||
|
||
let graphSvgSelection = null
|
||
let zoomBehavior = null
|
||
let resetSelectionStyles = null
|
||
|
||
const fillApi = (ep) => {
|
||
apiMethod.value = ep.method
|
||
apiUrl.value = ep.path
|
||
apiBody.value = ep.body
|
||
}
|
||
|
||
const sendApiRequest = async () => {
|
||
if (!apiUrl.value) return
|
||
apiLoading.value = true
|
||
apiResponse.value = ''
|
||
apiStatus.value = null
|
||
try {
|
||
let targetUrl = apiUrl.value
|
||
if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
|
||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||
targetUrl = `${apiBase}${apiUrl.value}`
|
||
}
|
||
const opts = { method: apiMethod.value, headers: { 'Content-Type': 'application/json' } }
|
||
if ((apiMethod.value === 'POST' || apiMethod.value === 'PUT') && apiBody.value) {
|
||
opts.body = apiBody.value
|
||
}
|
||
const res = await fetch(targetUrl, opts)
|
||
apiStatus.value = res.status
|
||
const text = await res.text()
|
||
try { apiResponse.value = JSON.stringify(JSON.parse(text), null, 2) }
|
||
catch { apiResponse.value = text }
|
||
} catch (err) {
|
||
apiStatus.value = 0
|
||
apiResponse.value = '请求失败: ' + err.message
|
||
} finally {
|
||
apiLoading.value = false
|
||
}
|
||
}
|
||
|
||
const navItems = [
|
||
{ id: 'graph', label: '知识图谱', desc: '可视化关系网络与时序事件', icon: '◉' },
|
||
{ id: 'ingest', label: '数据导入', desc: '导入文本并自动完成向量化', icon: '↓' },
|
||
{ id: 'query', label: '时序检索', desc: '基于 GraphRAG 的智能问答', icon: '⌕' },
|
||
{ id: 'qa', label: '图谱问答', desc: '多轮检索与终审判别可视化', icon: '◎' },
|
||
{ id: 'api', label: 'API 测试', desc: '在线调试后端接口', icon: '⚡' }
|
||
]
|
||
|
||
const currentNav = computed(() => navItems.find(n => n.id === activeTab.value) || navItems[0])
|
||
|
||
const handleLogout = () => {
|
||
localStorage.removeItem('auth_token')
|
||
router.push('/login')
|
||
}
|
||
|
||
const goUserList = () => {
|
||
router.push('/')
|
||
}
|
||
|
||
const refreshData = () => {
|
||
if (activeTab.value === 'graph') renderGraph()
|
||
}
|
||
|
||
const zoomIn = () => {
|
||
if (!graphSvgSelection || !zoomBehavior) return
|
||
graphSvgSelection.transition().call(zoomBehavior.scaleBy, 1.3)
|
||
}
|
||
|
||
const zoomOut = () => {
|
||
if (!graphSvgSelection || !zoomBehavior) return
|
||
graphSvgSelection.transition().call(zoomBehavior.scaleBy, 0.7)
|
||
}
|
||
|
||
const fitView = () => {
|
||
if (!graphSvgSelection || !zoomBehavior) return
|
||
graphSvgSelection.transition().call(zoomBehavior.transform, d3.zoomIdentity)
|
||
}
|
||
|
||
// 用户管理
|
||
const userId = ref(localStorage.getItem('currentUserId') || 'user_' + Date.now().toString(36).substr(2, 9))
|
||
localStorage.setItem('currentUserId', userId.value)
|
||
|
||
const applyRouteContext = () => {
|
||
const queryUserId = typeof route.query.userId === 'string' ? route.query.userId.trim() : ''
|
||
if (queryUserId) {
|
||
userId.value = queryUserId
|
||
localStorage.setItem('currentUserId', queryUserId)
|
||
} else {
|
||
const savedUserId = localStorage.getItem('currentUserId')
|
||
if (savedUserId) userId.value = savedUserId
|
||
}
|
||
const tab = typeof route.query.tab === 'string' ? route.query.tab.trim() : ''
|
||
if (tab && navItems.some((n) => n.id === tab)) activeTab.value = tab
|
||
}
|
||
|
||
const fetchGraphData = async () => {
|
||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||
try {
|
||
const res = await fetch(`${apiBase}/graph/stats?userId=${encodeURIComponent(userId.value)}`, {
|
||
method: 'GET',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
if (res.ok) return await res.json()
|
||
} catch (error) {
|
||
console.error('获取图谱数据失败:', error)
|
||
}
|
||
return null
|
||
}
|
||
|
||
const renderGraph = async () => {
|
||
if (!graphCanvas.value || !svgEl.value) return
|
||
graphLoading.value = true
|
||
selectedDetail.value = null
|
||
resetSelectionStyles = null
|
||
|
||
const width = graphCanvas.value.clientWidth
|
||
const height = graphCanvas.value.clientHeight
|
||
|
||
d3.select(svgEl.value).selectAll('*').remove()
|
||
|
||
const svg = d3.select(svgEl.value)
|
||
.attr('width', width)
|
||
.attr('height', height)
|
||
|
||
const data = await fetchGraphData()
|
||
const rawNodes = Array.isArray(data?.nodes) ? data.nodes : []
|
||
const rawLinks = Array.isArray(data?.links) ? data.links : []
|
||
|
||
if (!rawNodes.length) {
|
||
svg.append('text')
|
||
.attr('x', width / 2)
|
||
.attr('y', height / 2)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('font-size', 14)
|
||
.attr('fill', '#9ca3af')
|
||
.text('暂无节点数据')
|
||
graphLoading.value = false
|
||
return
|
||
}
|
||
|
||
const rawNodeMap = new Map(rawNodes.map(n => [n.id, n]))
|
||
const isEventNodeId = (id) => rawNodeMap.get(id)?.type === 'event'
|
||
const getLinkSourceId = (l) => (l.source && typeof l.source === 'object' ? l.source.id : l.source)
|
||
const getLinkTargetId = (l) => (l.target && typeof l.target === 'object' ? l.target.id : l.target)
|
||
const eventById = new Map(rawNodes.filter(n => n.type === 'event').map(n => [n.id, n]))
|
||
|
||
const participantByEvent = new Map()
|
||
const topicByEvent = new Map()
|
||
const directLinks = []
|
||
|
||
rawLinks.forEach((l) => {
|
||
const sid = getLinkSourceId(l)
|
||
const tid = getLinkTargetId(l)
|
||
const sourceType = rawNodeMap.get(sid)?.type
|
||
const targetType = rawNodeMap.get(tid)?.type
|
||
|
||
if (l.type === 'PARTICIPATES_IN') {
|
||
if (targetType === 'event') {
|
||
if (!participantByEvent.has(tid)) participantByEvent.set(tid, [])
|
||
participantByEvent.get(tid).push(sid)
|
||
} else if (sourceType === 'event') {
|
||
if (!participantByEvent.has(sid)) participantByEvent.set(sid, [])
|
||
participantByEvent.get(sid).push(tid)
|
||
}
|
||
directLinks.push({
|
||
source: sid,
|
||
target: tid,
|
||
type: l.type || 'RELATED',
|
||
summary: l.summary || '',
|
||
occurred_at: l.occurred_at || null,
|
||
isEventDerived: false
|
||
})
|
||
return
|
||
}
|
||
|
||
if (l.type === 'ABOUT') {
|
||
if (sourceType === 'event') {
|
||
if (!topicByEvent.has(sid)) topicByEvent.set(sid, [])
|
||
topicByEvent.get(sid).push(tid)
|
||
} else if (targetType === 'event') {
|
||
if (!topicByEvent.has(tid)) topicByEvent.set(tid, [])
|
||
topicByEvent.get(tid).push(sid)
|
||
}
|
||
directLinks.push({
|
||
source: sid,
|
||
target: tid,
|
||
type: l.type || 'RELATED',
|
||
summary: l.summary || '',
|
||
occurred_at: l.occurred_at || null,
|
||
isEventDerived: false
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!isEventNodeId(sid) && !isEventNodeId(tid)) {
|
||
directLinks.push({
|
||
source: sid,
|
||
target: tid,
|
||
type: l.type || 'RELATED',
|
||
summary: l.summary || '',
|
||
occurred_at: l.occurred_at || null,
|
||
isEventDerived: false
|
||
})
|
||
}
|
||
})
|
||
|
||
const displayLinkMap = new Map()
|
||
const appendDisplayLink = (source, target, payload) => {
|
||
if (!source || !target) return
|
||
const key = `${source}__${target}__${payload.type || 'RELATED'}`
|
||
const existed = displayLinkMap.get(key)
|
||
if (!existed) {
|
||
displayLinkMap.set(key, {
|
||
source,
|
||
target,
|
||
type: payload.type || 'RELATED',
|
||
summary: payload.summary || '',
|
||
occurred_at: payload.occurred_at || null,
|
||
eventCount: payload.eventCount || 0,
|
||
isEventDerived: !!payload.isEventDerived,
|
||
eventNames: payload.eventNames ? [...payload.eventNames] : []
|
||
})
|
||
return
|
||
}
|
||
existed.eventCount += payload.eventCount || 0
|
||
if (!existed.summary && payload.summary) existed.summary = payload.summary
|
||
if (!existed.occurred_at && payload.occurred_at) existed.occurred_at = payload.occurred_at
|
||
if (payload.eventNames?.length) {
|
||
const merged = new Set([...(existed.eventNames || []), ...payload.eventNames])
|
||
existed.eventNames = [...merged].slice(0, 4)
|
||
}
|
||
}
|
||
|
||
directLinks.forEach((l) => appendDisplayLink(l.source, l.target, l))
|
||
|
||
participantByEvent.forEach((participants, eventId) => {
|
||
const uniqueParticipants = [...new Set(participants)].filter(id => rawNodeMap.has(id))
|
||
const topics = [...new Set(topicByEvent.get(eventId) || [])].filter(id => rawNodeMap.has(id))
|
||
const eventNode = eventById.get(eventId)
|
||
const eventName = eventNode?.name || eventNode?.summary || '事件'
|
||
const eventSummary = eventNode?.summary || eventNode?.name || ''
|
||
const eventTime = eventNode?.occurred_at || null
|
||
|
||
if (uniqueParticipants.length >= 2) {
|
||
for (let i = 0; i < uniqueParticipants.length; i++) {
|
||
for (let j = i + 1; j < uniqueParticipants.length; j++) {
|
||
appendDisplayLink(uniqueParticipants[i], uniqueParticipants[j], {
|
||
type: 'EVENT',
|
||
summary: eventSummary,
|
||
occurred_at: eventTime,
|
||
eventCount: 1,
|
||
isEventDerived: true,
|
||
eventNames: [eventName]
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
uniqueParticipants.forEach((pid) => {
|
||
topics.forEach((tid) => {
|
||
appendDisplayLink(pid, tid, {
|
||
type: 'EVENT_TOPIC',
|
||
summary: eventSummary,
|
||
occurred_at: eventTime,
|
||
eventCount: 1,
|
||
isEventDerived: true,
|
||
eventNames: [eventName]
|
||
})
|
||
})
|
||
})
|
||
})
|
||
|
||
const nodes = rawNodes
|
||
const links = [...displayLinkMap.values()].filter(l => rawNodeMap.has(l.source) && rawNodeMap.has(l.target))
|
||
graphStats.value = { nodes: nodes.length, edges: links.length }
|
||
|
||
const presetColorMap = { person: '#6366f1', event: '#10b981', topic: '#f59e0b', organization: '#06b6d4' }
|
||
const personGenderColorMap = { male: '#3b82f6', female: '#ec4899', unknown: '#6366f1' }
|
||
const dynamicPalette = ['#3b82f6', '#8b5cf6', '#06b6d4', '#14b8a6', '#22c55e', '#f59e0b', '#f97316', '#ef4444', '#ec4899', '#6366f1']
|
||
const typeList = [...new Set(nodes.map(n => n.type).filter(Boolean))]
|
||
const normalizeGender = (value) => {
|
||
const raw = String(value || '').trim().toLowerCase()
|
||
if (['male', 'm', 'man', 'boy', '男', '男性', '男生'].includes(raw)) return 'male'
|
||
if (['female', 'f', 'woman', 'girl', '女', '女性', '女生'].includes(raw)) return 'female'
|
||
return 'unknown'
|
||
}
|
||
const getTypeColor = (type) => {
|
||
if (presetColorMap[type]) return presetColorMap[type]
|
||
let hash = 0
|
||
for (let i = 0; i < type.length; i += 1) hash = ((hash << 5) - hash) + type.charCodeAt(i)
|
||
return dynamicPalette[Math.abs(hash) % dynamicPalette.length]
|
||
}
|
||
const toTypeLabel = (type) => (type || 'entity')
|
||
.replace(/[_-]+/g, ' ')
|
||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.replace(/\b\w/g, c => c.toUpperCase())
|
||
const colorMap = Object.fromEntries(typeList.map(type => [type, getTypeColor(type)]))
|
||
const color = (d) => {
|
||
if (d.type === 'person') return personGenderColorMap[normalizeGender(d.gender)] || personGenderColorMap.unknown
|
||
return colorMap[d.type] || '#6366f1'
|
||
}
|
||
const getNodeId = (value) => (value && typeof value === 'object' ? value.id : value)
|
||
const getDefaultNodeStroke = () => '#fff'
|
||
const getDefaultNodeStrokeWidth = () => 2.5
|
||
const parseImportance = (value) => {
|
||
if (typeof value === 'number') return value
|
||
if (value && typeof value === 'object' && typeof value.toNumber === 'function') return value.toNumber()
|
||
const num = Number(value)
|
||
return Number.isFinite(num) ? num : 5
|
||
}
|
||
const clamp = (val, min, max) => Math.max(min, Math.min(max, val))
|
||
const getExtent = (values) => {
|
||
if (!values.length) return [0, 1]
|
||
const min = Math.min(...values)
|
||
const max = Math.max(...values)
|
||
return min === max ? [min, min + 1] : [min, max]
|
||
}
|
||
|
||
const degreeMap = new Map()
|
||
nodes.forEach((n) => degreeMap.set(n.id, 0))
|
||
links.forEach((l) => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
degreeMap.set(sid, (degreeMap.get(sid) || 0) + 1)
|
||
degreeMap.set(tid, (degreeMap.get(tid) || 0) + 1)
|
||
})
|
||
|
||
const topicCountMap = new Map()
|
||
links.forEach((l) => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
if (rawNodeMap.get(sid)?.type === 'topic') topicCountMap.set(sid, (topicCountMap.get(sid) || 0) + 1)
|
||
if (rawNodeMap.get(tid)?.type === 'topic') topicCountMap.set(tid, (topicCountMap.get(tid) || 0) + 1)
|
||
})
|
||
|
||
nodes.forEach((n) => {
|
||
n.degree = degreeMap.get(n.id) || 0
|
||
n.importance = null
|
||
n.topicCount = n.type === 'topic' ? (topicCountMap.get(n.id) || 0) : 0
|
||
})
|
||
|
||
const personExtent = getExtent(nodes.filter(n => n.type === 'person').map(n => n.degree))
|
||
const topicExtent = getExtent(nodes.filter(n => n.type === 'topic').map(n => n.topicCount))
|
||
const normalize = (value, extent) => (value - extent[0]) / (extent[1] - extent[0] || 1)
|
||
const getRadius = (node) => {
|
||
if (node.type === 'person') return clamp(22 + normalize(node.degree, personExtent) * 20, 22, 42)
|
||
if (node.type === 'organization') return clamp(16 + normalize(node.degree, personExtent) * 12, 16, 28)
|
||
if (node.type === 'topic') return clamp(14 + normalize(node.topicCount, topicExtent) * 10, 14, 24)
|
||
return 18
|
||
}
|
||
nodes.forEach((n) => {
|
||
n.radius = getRadius(n)
|
||
})
|
||
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
|
||
const getNodeDataFromRef = (value) => {
|
||
if (value && typeof value === 'object' && value.id) return value
|
||
return nodeMap.get(value)
|
||
}
|
||
const isEventLink = (edge) => edge.type === 'EVENT' || edge.type === 'EVENT_TOPIC' || edge.isEventDerived
|
||
const getEdgeLabelText = (edge) => {
|
||
if (edge.eventNames?.length) {
|
||
if (edge.eventNames.length > 1) return `事件×${edge.eventNames.length}`
|
||
const text = edge.eventNames[0] || '事件'
|
||
return text.length > 8 ? `${text.slice(0, 8)}…` : text
|
||
}
|
||
if (edge.type === 'EVENT') return '事件关联'
|
||
if (edge.type === 'EVENT_TOPIC') return '事件主题'
|
||
const base = edge.type || ''
|
||
return base.length > 8 ? `${base.slice(0, 8)}…` : base
|
||
}
|
||
const linkPairGroups = new Map()
|
||
links.forEach((l) => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
const key = sid < tid ? `${sid}__${tid}` : `${tid}__${sid}`
|
||
if (!linkPairGroups.has(key)) linkPairGroups.set(key, [])
|
||
linkPairGroups.get(key).push(l)
|
||
})
|
||
linkPairGroups.forEach((arr) => {
|
||
const total = arr.length
|
||
arr.forEach((l, index) => {
|
||
l.pairTotal = total
|
||
const offset = index - (total - 1) / 2
|
||
l.curvature = total === 1 ? 0 : offset * 0.35
|
||
l.isSelfLoop = getNodeId(l.source) === getNodeId(l.target)
|
||
})
|
||
})
|
||
const getLinkPath = (d) => {
|
||
const sx = d.source.x
|
||
const sy = d.source.y
|
||
const tx = d.target.x
|
||
const ty = d.target.y
|
||
if (d.isSelfLoop) {
|
||
const r = (d.source.radius || 20) + 18
|
||
return `M ${sx} ${sy} C ${sx + r} ${sy - r}, ${sx + r} ${sy + r}, ${sx} ${sy + 0.1}`
|
||
}
|
||
if (!d.curvature) return `M${sx},${sy} L${tx},${ty}`
|
||
const dx = tx - sx
|
||
const dy = ty - sy
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||
const offsetRatio = 0.22 + (d.pairTotal || 1) * 0.04
|
||
const baseOffset = Math.max(28, dist * offsetRatio)
|
||
const offsetX = -dy / dist * d.curvature * baseOffset
|
||
const offsetY = dx / dist * d.curvature * baseOffset
|
||
const cx = (sx + tx) / 2 + offsetX
|
||
const cy = (sy + ty) / 2 + offsetY
|
||
return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`
|
||
}
|
||
const getLinkMidpoint = (d) => {
|
||
const sx = d.source.x
|
||
const sy = d.source.y
|
||
const tx = d.target.x
|
||
const ty = d.target.y
|
||
if (d.isSelfLoop) return { x: sx + 48, y: sy }
|
||
if (!d.curvature) return { x: (sx + tx) / 2, y: (sy + ty) / 2 }
|
||
const dx = tx - sx
|
||
const dy = ty - sy
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||
const offsetRatio = 0.22 + (d.pairTotal || 1) * 0.04
|
||
const baseOffset = Math.max(28, dist * offsetRatio)
|
||
const offsetX = -dy / dist * d.curvature * baseOffset
|
||
const offsetY = dx / dist * d.curvature * baseOffset
|
||
const cx = (sx + tx) / 2 + offsetX
|
||
const cy = (sy + ty) / 2 + offsetY
|
||
return { x: 0.25 * sx + 0.5 * cx + 0.25 * tx, y: 0.25 * sy + 0.5 * cy + 0.25 * ty }
|
||
}
|
||
|
||
const g = svg.append('g')
|
||
|
||
const personGenders = [...new Set(nodes.filter(n => n.type === 'person').map(n => normalizeGender(n.gender)))]
|
||
const personLegend = personGenders.map((gender) => ({
|
||
type: `person:${gender}`,
|
||
color: personGenderColorMap[gender] || personGenderColorMap.unknown,
|
||
label: gender === 'male' ? 'Person (Male)' : (gender === 'female' ? 'Person (Female)' : 'Person (Unknown)')
|
||
}))
|
||
const typeLegend = typeList
|
||
.filter(type => type !== 'person')
|
||
.map(type => ({ type, color: colorMap[type] || '#999', label: toTypeLabel(type) }))
|
||
graphLegendItems.value = [...personLegend, ...typeLegend]
|
||
|
||
zoomBehavior = d3.zoom().scaleExtent([0.2, 3]).on('zoom', (event) => {
|
||
g.attr('transform', event.transform)
|
||
})
|
||
svg.call(zoomBehavior)
|
||
graphSvgSelection = svg
|
||
|
||
const simulation = d3.forceSimulation(nodes)
|
||
.force('link', d3.forceLink(links).id(d => d.id).distance(d => 690 + ((d.source.radius || 20) + (d.target.radius || 20)) * 3.6))
|
||
.force('charge', d3.forceManyBody().strength(-650))
|
||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||
.force('collision', d3.forceCollide().radius(d => (d.radius || 20) + 20))
|
||
|
||
const link = g.append('g')
|
||
.selectAll('path')
|
||
.data(links)
|
||
.join('path')
|
||
.attr('fill', 'none')
|
||
.attr('stroke', '#c0c0c0')
|
||
.attr('stroke-width', 1.5)
|
||
.attr('stroke-linecap', 'round')
|
||
.style('cursor', 'pointer')
|
||
|
||
const linkLabelBg = g.append('g')
|
||
.selectAll('rect')
|
||
.data(links)
|
||
.join('rect')
|
||
.attr('fill', 'rgba(255,255,255,0.95)')
|
||
.attr('rx', 3)
|
||
.attr('ry', 3)
|
||
.style('pointer-events', 'all')
|
||
.style('display', showEdgeLabels.value ? 'block' : 'none')
|
||
|
||
const linkLabel = g.append('g')
|
||
.selectAll('text')
|
||
.data(links)
|
||
.join('text')
|
||
.attr('text-anchor', 'middle')
|
||
.attr('font-size', 10)
|
||
.attr('fill', '#666')
|
||
.attr('opacity', 0.86)
|
||
.attr('font-weight', 500)
|
||
.attr('stroke', '#ffffff')
|
||
.attr('stroke-width', 3)
|
||
.attr('paint-order', 'stroke')
|
||
.style('pointer-events', 'all')
|
||
.style('display', showEdgeLabels.value ? 'block' : 'none')
|
||
.text(d => getEdgeLabelText(d))
|
||
|
||
const nodeGroup = g.append('g')
|
||
.selectAll('g')
|
||
.data(nodes)
|
||
.join('g')
|
||
.call(d3.drag()
|
||
.on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y })
|
||
.on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null })
|
||
)
|
||
|
||
const nodeCircle = nodeGroup.append('circle')
|
||
.attr('r', d => d.radius)
|
||
.attr('fill', d => color(d))
|
||
.attr('stroke', d => getDefaultNodeStroke(d))
|
||
.attr('stroke-width', d => getDefaultNodeStrokeWidth(d))
|
||
.attr('opacity', d => d.type === 'person' ? 1 : (d.type === 'organization' ? 0.82 : 0.72))
|
||
.style('cursor', 'pointer')
|
||
.on('mouseenter', (event, d) => {
|
||
if (selectedDetail.value?.kind === 'node' && selectedDetail.value?.data?.id === d.id) return
|
||
d3.select(event.currentTarget).attr('stroke', '#333').attr('stroke-width', 3)
|
||
})
|
||
.on('mouseleave', (event, d) => {
|
||
if (selectedDetail.value?.kind === 'node' && selectedDetail.value?.data?.id === d.id) return
|
||
d3.select(event.currentTarget).attr('stroke', getDefaultNodeStroke(d)).attr('stroke-width', getDefaultNodeStrokeWidth(d))
|
||
})
|
||
|
||
const nodeText = nodeGroup.append('text')
|
||
.attr('dx', d => d.radius + 4)
|
||
.attr('dy', 4)
|
||
.attr('text-anchor', 'start')
|
||
.attr('font-size', 11)
|
||
.attr('fill', '#333')
|
||
.attr('font-weight', 500)
|
||
.style('pointer-events', 'none')
|
||
.style('font-family', 'system-ui, sans-serif')
|
||
.text(d => {
|
||
if (d.type === 'event') {
|
||
const eventText = d.name || d.summary || '事件'
|
||
return eventText.length > 8 ? `${eventText.slice(0, 8)}…` : eventText
|
||
}
|
||
return d.name?.length > 8 ? `${d.name.slice(0, 8)}…` : d.name
|
||
})
|
||
|
||
resetSelectionStyles = () => {
|
||
nodeCircle
|
||
.attr('stroke', d => getDefaultNodeStroke(d))
|
||
.attr('stroke-width', d => getDefaultNodeStrokeWidth(d))
|
||
.attr('opacity', d => d.type === 'person' ? 1 : (d.type === 'organization' ? 0.82 : 0.72))
|
||
nodeText.attr('opacity', 1)
|
||
link
|
||
.attr('stroke', '#c0c0c0')
|
||
.attr('stroke-width', 1.5)
|
||
.attr('opacity', 1)
|
||
linkLabelBg
|
||
.attr('fill', 'rgba(255,255,255,0.95)')
|
||
.style('display', showEdgeLabels.value ? 'block' : 'none')
|
||
linkLabel
|
||
.attr('opacity', 0.86)
|
||
.attr('fill', '#666')
|
||
.style('display', showEdgeLabels.value ? 'block' : 'none')
|
||
}
|
||
|
||
const highlightNode = (nodeData) => {
|
||
const focusIds = new Set([nodeData.id])
|
||
link.each((l) => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
if (sid === nodeData.id || tid === nodeData.id) {
|
||
focusIds.add(sid)
|
||
focusIds.add(tid)
|
||
}
|
||
})
|
||
nodeCircle
|
||
.attr('opacity', n => focusIds.has(n.id) ? 1 : 0.25)
|
||
.attr('stroke', n => n.id === nodeData.id ? '#e11d48' : getDefaultNodeStroke(n))
|
||
.attr('stroke-width', n => n.id === nodeData.id ? 4 : getDefaultNodeStrokeWidth(n))
|
||
nodeText.attr('opacity', n => focusIds.has(n.id) ? 1 : 0.25)
|
||
link
|
||
.attr('opacity', l => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
return sid === nodeData.id || tid === nodeData.id ? 1 : 0.12
|
||
})
|
||
.attr('stroke', l => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
return sid === nodeData.id || tid === nodeData.id ? '#e11d48' : '#c0c0c0'
|
||
})
|
||
.attr('stroke-width', l => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
return sid === nodeData.id || tid === nodeData.id ? 2.5 : 1.5
|
||
})
|
||
linkLabel.attr('opacity', l => {
|
||
const sid = getNodeId(l.source)
|
||
const tid = getNodeId(l.target)
|
||
if (sid === nodeData.id || tid === nodeData.id) return 1
|
||
return 0.86
|
||
})
|
||
}
|
||
|
||
const highlightEdge = (edgeData) => {
|
||
const sid = getNodeId(edgeData.source)
|
||
const tid = getNodeId(edgeData.target)
|
||
nodeCircle
|
||
.attr('opacity', n => n.id === sid || n.id === tid ? 1 : 0.25)
|
||
.attr('stroke', n => n.id === sid || n.id === tid ? '#e11d48' : getDefaultNodeStroke(n))
|
||
.attr('stroke-width', n => n.id === sid || n.id === tid ? 3.5 : getDefaultNodeStrokeWidth(n))
|
||
nodeText.attr('opacity', n => n.id === sid || n.id === tid ? 1 : 0.25)
|
||
link
|
||
.attr('opacity', l => l === edgeData ? 1 : 0.12)
|
||
.attr('stroke', l => l === edgeData ? '#3498db' : '#c0c0c0')
|
||
.attr('stroke-width', l => l === edgeData ? 2.8 : 1.5)
|
||
linkLabelBg.attr('fill', l => l === edgeData ? 'rgba(52, 152, 219, 0.1)' : 'rgba(255,255,255,0.95)')
|
||
linkLabel.attr('opacity', l => {
|
||
if (l === edgeData) return 1
|
||
return 0.86
|
||
}).attr('fill', l => l === edgeData ? '#3498db' : '#666')
|
||
}
|
||
|
||
nodeGroup.on('click', (event, d) => {
|
||
event.stopPropagation()
|
||
highlightNode(d)
|
||
selectedDetail.value = { kind: 'node', data: d }
|
||
})
|
||
|
||
link.on('click', (event, d) => {
|
||
event.stopPropagation()
|
||
highlightEdge(d)
|
||
selectedDetail.value = {
|
||
kind: 'edge',
|
||
data: {
|
||
type: d.type,
|
||
source: getNodeId(d.source),
|
||
target: getNodeId(d.target),
|
||
sourceName: d.source?.name,
|
||
targetName: d.target?.name,
|
||
summary: d.summary || '',
|
||
occurred_at: d.occurred_at || null,
|
||
eventCount: d.eventCount || 0
|
||
}
|
||
}
|
||
})
|
||
|
||
linkLabelBg.on('click', (event, d) => {
|
||
event.stopPropagation()
|
||
highlightEdge(d)
|
||
selectedDetail.value = {
|
||
kind: 'edge',
|
||
data: {
|
||
type: d.type,
|
||
source: getNodeId(d.source),
|
||
target: getNodeId(d.target),
|
||
sourceName: d.source?.name,
|
||
targetName: d.target?.name,
|
||
summary: d.summary || '',
|
||
occurred_at: d.occurred_at || null,
|
||
eventCount: d.eventCount || 0
|
||
}
|
||
}
|
||
})
|
||
|
||
linkLabel.on('click', (event, d) => {
|
||
event.stopPropagation()
|
||
highlightEdge(d)
|
||
selectedDetail.value = {
|
||
kind: 'edge',
|
||
data: {
|
||
type: d.type,
|
||
source: getNodeId(d.source),
|
||
target: getNodeId(d.target),
|
||
sourceName: d.source?.name,
|
||
targetName: d.target?.name,
|
||
summary: d.summary || '',
|
||
occurred_at: d.occurred_at || null,
|
||
eventCount: d.eventCount || 0
|
||
}
|
||
}
|
||
})
|
||
|
||
svg.on('click', () => {
|
||
selectedDetail.value = null
|
||
resetSelectionStyles?.()
|
||
})
|
||
|
||
simulation.on('tick', () => {
|
||
link
|
||
.attr('d', d => getLinkPath(d))
|
||
linkLabel
|
||
.each(function (d) {
|
||
const mid = getLinkMidpoint(d)
|
||
d3.select(this).attr('x', mid.x).attr('y', mid.y)
|
||
})
|
||
linkLabelBg.each(function (d, i) {
|
||
const mid = getLinkMidpoint(d)
|
||
const textEl = linkLabel.nodes()[i]
|
||
const bbox = textEl.getBBox()
|
||
d3.select(this)
|
||
.attr('x', mid.x - bbox.width / 2 - 4)
|
||
.attr('y', mid.y - bbox.height / 2 - 2)
|
||
.attr('width', bbox.width + 8)
|
||
.attr('height', bbox.height + 4)
|
||
})
|
||
nodeGroup.attr('transform', d => `translate(${d.x},${d.y})`)
|
||
})
|
||
|
||
graphLoading.value = false
|
||
}
|
||
|
||
const formatDateTime = (value) => {
|
||
if (!value) return '-'
|
||
if (typeof value === 'string' || value instanceof Date || typeof value === 'number') {
|
||
const date = new Date(value)
|
||
if (!Number.isNaN(date.getTime())) return date.toLocaleString()
|
||
}
|
||
if (value && typeof value.toString === 'function') return value.toString()
|
||
return String(value)
|
||
}
|
||
|
||
const clearSelection = () => {
|
||
selectedDetail.value = null
|
||
resetSelectionStyles?.()
|
||
}
|
||
|
||
const formatIngestProgress = (event) => {
|
||
const stage = event?.stage || ''
|
||
if (stage === 'chunking') return `并行分块: ${event.chunk_count || 0} 块 (并发 ${event.parallelism || 1})`
|
||
if (stage === 'chunk_start') return `开始分析分块 ${event.chunk_index || 0}/${event.chunk_count || 0}`
|
||
if (stage === 'chunk_done') return `完成分块 ${event.chunk_index || 0}/${event.chunk_count || 0}`
|
||
if (stage === 'merge_done') return '分块结果合并完成'
|
||
if (stage === 'expert_review_start') return '专家总查开始'
|
||
if (stage === 'expert_review_done') return '专家总查完成'
|
||
if (stage === 'analysis_ready') return `分析完成: ${event.persons || 0} 人物, ${event.organizations || 0} 组织, ${event.events || 0} 事件, ${event.topics || 0} 主题`
|
||
if (stage === 'done') return '入库完成'
|
||
if (!stage) return '处理中...'
|
||
return `阶段: ${stage}`
|
||
}
|
||
|
||
const handleIngest = async () => {
|
||
if (!ingestText.value) return
|
||
ingestLoading.value = true
|
||
const fallbackApiBase = typeof window !== 'undefined'
|
||
? `${window.location.protocol}//${window.location.hostname}:3000`
|
||
: 'http://localhost:3000'
|
||
const apiBase = import.meta.env.VITE_API_BASE_URL || fallbackApiBase
|
||
const logAt = (msg, type = 'info') => {
|
||
ingestLogs.value.unshift({
|
||
time: new Date().toLocaleTimeString(),
|
||
msg,
|
||
type
|
||
})
|
||
}
|
||
try {
|
||
logAt('开始提交导入请求...', 'info')
|
||
const payload = {
|
||
text: ingestText.value,
|
||
userId: userId.value,
|
||
parallelism: 3,
|
||
expertReview: true
|
||
}
|
||
const res = await fetch(`${apiBase}/analyze/stream`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
if (!res.ok) {
|
||
const text = await res.text()
|
||
throw new Error(text || `请求失败(${res.status})`)
|
||
}
|
||
if (!res.body) {
|
||
throw new Error('浏览器不支持流式读取')
|
||
}
|
||
|
||
const reader = res.body.getReader()
|
||
const decoder = new TextDecoder('utf-8')
|
||
let buffer = ''
|
||
let doneResult = null
|
||
let streamError = null
|
||
logAt('已建立流式连接,开始分析...', 'info')
|
||
const parseSseBlock = (block) => {
|
||
if (!block.trim()) return
|
||
const eventLine = block.split('\n').find(line => line.startsWith('event:'))
|
||
const dataLine = block.split('\n').find(line => line.startsWith('data:'))
|
||
const eventName = eventLine ? eventLine.slice(6).trim() : 'message'
|
||
const payloadText = dataLine ? dataLine.slice(5).trim() : '{}'
|
||
let payload = {}
|
||
try {
|
||
payload = JSON.parse(payloadText)
|
||
} catch {
|
||
payload = { raw: payloadText }
|
||
}
|
||
if (eventName === 'progress') {
|
||
logAt(formatIngestProgress(payload), 'info')
|
||
} else if (eventName === 'done') {
|
||
doneResult = payload
|
||
} else if (eventName === 'error') {
|
||
streamError = payload?.message || '流式分析失败'
|
||
}
|
||
}
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const blocks = buffer.split('\n\n')
|
||
buffer = blocks.pop() || ''
|
||
for (const block of blocks) parseSseBlock(block)
|
||
}
|
||
if (buffer.trim()) parseSseBlock(buffer)
|
||
|
||
if (streamError) {
|
||
throw new Error(streamError)
|
||
}
|
||
if (!doneResult?.ok) {
|
||
throw new Error(doneResult?.error || doneResult?.message || '分析失败')
|
||
}
|
||
logAt(`分析成功: ${doneResult.stats?.created?.persons || 0} 人, ${doneResult.stats?.created?.organizations || 0} 组织, ${doneResult.stats?.created?.events || 0} 事件, ${doneResult.stats?.created?.topics || 0} 主题`, 'success')
|
||
ingestText.value = ''
|
||
renderGraph()
|
||
} catch (e) {
|
||
if (String(e?.message || '').toLowerCase().includes('failed to fetch')) {
|
||
try {
|
||
logAt('流式连接失败,自动降级为普通导入...', 'info')
|
||
const fallbackRes = await fetch(`${apiBase}/analyze`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: ingestText.value,
|
||
userId: userId.value
|
||
})
|
||
})
|
||
const fallbackData = await fallbackRes.json()
|
||
if (!fallbackData?.ok) throw new Error(fallbackData?.error || fallbackData?.message || `请求失败(${fallbackRes.status})`)
|
||
logAt(`分析成功: ${fallbackData.stats?.created?.persons || 0} 人, ${fallbackData.stats?.created?.organizations || 0} 组织, ${fallbackData.stats?.created?.events || 0} 事件, ${fallbackData.stats?.created?.topics || 0} 主题`, 'success')
|
||
ingestText.value = ''
|
||
renderGraph()
|
||
return
|
||
} catch (fallbackError) {
|
||
logAt(`分析失败: ${fallbackError.message}`, 'error')
|
||
}
|
||
} else {
|
||
logAt(`分析失败: ${e.message}`, 'error')
|
||
}
|
||
} finally {
|
||
ingestLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleQuery = async () => {
|
||
if (!queryText.value) return
|
||
queryLoading.value = true
|
||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||
try {
|
||
const res = await fetch(`${apiBase}/query/graphrag`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query: queryText.value })
|
||
})
|
||
queryResult.value = await res.json()
|
||
} catch (e) {
|
||
queryResult.value = { answer: `检索失败: ${e.message}`, chunks: [] }
|
||
} finally {
|
||
queryLoading.value = false
|
||
}
|
||
}
|
||
|
||
let resizeObserver
|
||
let graphRendered = false
|
||
onMounted(() => {
|
||
applyRouteContext()
|
||
renderGraph()
|
||
graphRendered = true
|
||
if (graphCanvas.value) {
|
||
resizeObserver = new ResizeObserver(() => {
|
||
if (graphRendered) renderGraph()
|
||
})
|
||
resizeObserver.observe(graphCanvas.value)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
resizeObserver?.disconnect()
|
||
})
|
||
|
||
watch(() => route.query, () => {
|
||
const prevUserId = userId.value
|
||
const prevTab = activeTab.value
|
||
applyRouteContext()
|
||
if (activeTab.value === 'graph' && (prevUserId !== userId.value || prevTab !== activeTab.value)) {
|
||
renderGraph()
|
||
}
|
||
}, { deep: true })
|
||
</script>
|
||
|
||
<style scoped>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
.dashboard {
|
||
display: flex;
|
||
height: 100vh;
|
||
background: #ffffff;
|
||
color: #111111;
|
||
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
|
||
}
|
||
|
||
.sidebar {
|
||
width: 256px;
|
||
background: linear-gradient(180deg, #111111 0%, #1a1a1a 100%);
|
||
border-right: 1px solid #2f2f2f;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
color: #f5f5f5;
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 22px 18px;
|
||
border-bottom: 1px solid #2f2f2f;
|
||
}
|
||
|
||
.logo-mark {
|
||
flex-shrink: 0;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 1px solid #4a4a4a;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: #0e0e0e;
|
||
}
|
||
|
||
.logo-text { display: flex; flex-direction: column; }
|
||
.logo-name {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.8px;
|
||
}
|
||
.logo-sub {
|
||
font-size: 12px;
|
||
color: #b4b4b4;
|
||
letter-spacing: 0.6px;
|
||
}
|
||
|
||
.sidebar-nav {
|
||
flex: 1;
|
||
padding: 14px 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.nav-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 11px 12px;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #d3d3d3;
|
||
transition: all 0.2s ease;
|
||
text-align: left;
|
||
width: 100%;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.nav-btn:hover {
|
||
border-color: #3c3c3c;
|
||
background: #1d1d1d;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.nav-btn.active {
|
||
border-color: #686868;
|
||
background: #ffffff;
|
||
color: #111111;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.nav-icon {
|
||
width: 18px;
|
||
text-align: center;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding: 14px 10px;
|
||
border-top: 1px solid #2f2f2f;
|
||
}
|
||
|
||
.user-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
padding: 10px;
|
||
border: 1px solid #333333;
|
||
border-radius: 8px;
|
||
background: #151515;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 8px;
|
||
border: 1px solid #5b5b5b;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ffffff;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
background: #111111;
|
||
}
|
||
|
||
.user-info { display: flex; flex-direction: column; }
|
||
.user-role { font-size: 11px; color: #9f9f9f; letter-spacing: 0.5px; }
|
||
.user-name { font-size: 13px; font-weight: 600; color: #ffffff; }
|
||
|
||
.logout-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 9px 12px;
|
||
background: transparent;
|
||
border: 1px solid #3f3f3f;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #d6d6d6;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.logout-btn:hover {
|
||
background: #ffffff;
|
||
color: #111111;
|
||
border-color: #ffffff;
|
||
}
|
||
|
||
.main-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.top-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 22px 28px;
|
||
border-bottom: 1px solid #dddddd;
|
||
background: #ffffff;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.5px;
|
||
color: #111111;
|
||
}
|
||
|
||
.page-desc {
|
||
margin-top: 4px;
|
||
font-size: 13px;
|
||
color: #666666;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.current-user-badge {
|
||
padding: 7px 11px;
|
||
border-radius: 999px;
|
||
border: 1px solid #d8d8d8;
|
||
background: #f9f9f9;
|
||
color: #333333;
|
||
font-size: 12px;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
|
||
.btn-ghost,
|
||
.btn-outline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
background: #ffffff;
|
||
border: 1px solid #cccccc;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #333333;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.btn-ghost:hover,
|
||
.btn-outline:hover {
|
||
border-color: #111111;
|
||
color: #111111;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-primary {
|
||
padding: 8px 14px;
|
||
background: #111111;
|
||
color: #ffffff;
|
||
border: 1px solid #111111;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.4px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
background: #000000;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
background: #666666;
|
||
border-color: #666666;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.content-area {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
padding: 20px 24px 24px;
|
||
}
|
||
|
||
.graph-view {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.graph-toolbar,
|
||
.panel-card {
|
||
background: #ffffff;
|
||
border: 1px solid #dddddd;
|
||
border-radius: 10px;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.graph-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.data-badge {
|
||
border: 1px solid #111111;
|
||
color: #111111;
|
||
background: #ffffff;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
padding: 3px 9px;
|
||
border-radius: 999px;
|
||
letter-spacing: 0.7px;
|
||
}
|
||
|
||
.node-count {
|
||
font-size: 13px;
|
||
color: #555555;
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.graph-canvas {
|
||
flex: 1;
|
||
background:
|
||
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px) 0 0 / 24px 24px,
|
||
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px) 0 0 / 24px 24px,
|
||
#fafafa;
|
||
border: 1px solid #dcdcdc;
|
||
border-radius: 10px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.graph-loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.spinner {
|
||
width: 30px;
|
||
height: 30px;
|
||
border: 2px solid #d6d6d6;
|
||
border-top-color: #111111;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.graph-loading p {
|
||
font-size: 13px;
|
||
color: #666666;
|
||
}
|
||
|
||
.graph-svg { width: 100%; height: 100%; display: block; }
|
||
|
||
.graph-legend,
|
||
.edge-labels-toggle,
|
||
.detail-panel {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border: 1px solid #d5d5d5;
|
||
border-radius: 10px;
|
||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.graph-legend {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 16px;
|
||
padding: 10px 12px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.legend-title {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-size: 11px;
|
||
color: #111111;
|
||
letter-spacing: 0.9px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.legend-items {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 12px;
|
||
max-width: 320px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #444444;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 999px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.legend-label { white-space: nowrap; }
|
||
|
||
.edge-labels-toggle {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.toggle-switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 40px;
|
||
height: 22px;
|
||
}
|
||
|
||
.toggle-switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.slider {
|
||
position: absolute;
|
||
inset: 0;
|
||
cursor: pointer;
|
||
background-color: #d2d2d2;
|
||
border-radius: 22px;
|
||
transition: 0.25s;
|
||
}
|
||
|
||
.slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 16px;
|
||
width: 16px;
|
||
left: 3px;
|
||
bottom: 3px;
|
||
background-color: #ffffff;
|
||
border-radius: 999px;
|
||
transition: 0.25s;
|
||
}
|
||
|
||
input:checked + .slider {
|
||
background-color: #111111;
|
||
}
|
||
|
||
input:checked + .slider:before {
|
||
transform: translateX(18px);
|
||
}
|
||
|
||
.toggle-label {
|
||
font-size: 12px;
|
||
color: #555555;
|
||
}
|
||
|
||
.detail-panel {
|
||
position: absolute;
|
||
top: 56px;
|
||
right: 14px;
|
||
width: 320px;
|
||
max-height: calc(100% - 84px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 20;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid #e1e1e1;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 14px;
|
||
color: #111111;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.detail-close {
|
||
border: 1px solid #d0d0d0;
|
||
background: #ffffff;
|
||
color: #555555;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.detail-close:hover {
|
||
border-color: #111111;
|
||
color: #111111;
|
||
}
|
||
|
||
.detail-content {
|
||
padding: 14px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.detail-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.detail-label {
|
||
min-width: 72px;
|
||
color: #666666;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.detail-value {
|
||
flex: 1;
|
||
color: #111111;
|
||
font-size: 13px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.summary-value { line-height: 1.55; }
|
||
|
||
.panel-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 16px;
|
||
height: 100%;
|
||
}
|
||
|
||
.panel-grid .wide { grid-column: 1 / -1; }
|
||
|
||
.panel-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid #e1e1e1;
|
||
}
|
||
|
||
.panel-header h3 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.panel-tag {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
border: 1px solid #111111;
|
||
color: #111111;
|
||
background: #ffffff;
|
||
letter-spacing: 0.7px;
|
||
}
|
||
|
||
.panel-tag.info {
|
||
border-color: #666666;
|
||
color: #333333;
|
||
}
|
||
|
||
.panel-body {
|
||
flex: 1;
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.text-area,
|
||
.query-input,
|
||
.method-select,
|
||
.api-url-input {
|
||
width: 100%;
|
||
background: #ffffff;
|
||
border: 1px solid #cccccc;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
outline: none;
|
||
color: #111111;
|
||
font-size: 13px;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
.text-area:focus,
|
||
.query-input:focus,
|
||
.method-select:focus,
|
||
.api-url-input:focus {
|
||
border-color: #111111;
|
||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.text-area { resize: none; }
|
||
.panel-actions { display: flex; justify-content: flex-end; }
|
||
|
||
.log-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
overflow-y: auto;
|
||
max-height: 300px;
|
||
}
|
||
|
||
.log-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 9px 10px;
|
||
border-radius: 8px;
|
||
border: 1px solid #dcdcdc;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.log-item.info {
|
||
background: #f7f7f7;
|
||
color: #444444;
|
||
}
|
||
|
||
.log-item.success {
|
||
background: #f4f4f4;
|
||
color: #111111;
|
||
}
|
||
|
||
.log-item.error {
|
||
background: #ffffff;
|
||
color: #222222;
|
||
border-color: #bcbcbc;
|
||
}
|
||
|
||
.log-time {
|
||
color: #888888;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.log-msg { flex: 1; }
|
||
|
||
.log-empty {
|
||
color: #888888;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
padding: 16px;
|
||
}
|
||
|
||
.query-input-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.query-result {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.result-section { display: flex; flex-direction: column; gap: 8px; }
|
||
|
||
.result-section h4 {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #111111;
|
||
}
|
||
|
||
.result-text {
|
||
font-size: 14px;
|
||
color: #222222;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.chunk-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chunk-item {
|
||
padding: 10px 12px;
|
||
border: 1px solid #dddddd;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
color: #333333;
|
||
background: #fdfdfd;
|
||
}
|
||
|
||
.chunk-score {
|
||
display: block;
|
||
margin-top: 5px;
|
||
color: #666666;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.api-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.method-select {
|
||
min-width: 90px;
|
||
}
|
||
|
||
.api-endpoints {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.endpoint-label {
|
||
font-size: 12px;
|
||
color: #666666;
|
||
}
|
||
|
||
.endpoint-chip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 9px;
|
||
border: 1px solid #cccccc;
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: #333333;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.endpoint-chip:hover {
|
||
border-color: #111111;
|
||
color: #111111;
|
||
}
|
||
|
||
.chip-method {
|
||
font-weight: 700;
|
||
font-size: 10px;
|
||
padding: 1px 3px;
|
||
border: 1px solid #bbbbbb;
|
||
border-radius: 4px;
|
||
color: #555555;
|
||
}
|
||
|
||
.chip-method.get,
|
||
.chip-method.post,
|
||
.chip-method.put,
|
||
.chip-method.delete {
|
||
color: inherit;
|
||
}
|
||
|
||
.api-body-section { margin-bottom: 10px; }
|
||
|
||
.body-label {
|
||
font-size: 12px;
|
||
color: #666666;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.code-area,
|
||
.response-pre {
|
||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
}
|
||
|
||
.api-response-section {
|
||
background: #fafafa;
|
||
border: 1px solid #dddddd;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
}
|
||
|
||
.response-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.response-header h4 {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.status-badge {
|
||
font-size: 11px;
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
border: 1px solid #bcbcbc;
|
||
color: #444444;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.status-badge.success,
|
||
.status-badge.error {
|
||
color: #111111;
|
||
border-color: #888888;
|
||
}
|
||
|
||
.response-pre {
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
color: #333333;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.top-header {
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.content-area {
|
||
padding: 16px;
|
||
}
|
||
|
||
.panel-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.panel-grid .wide {
|
||
grid-column: 1;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.dashboard {
|
||
flex-direction: column;
|
||
height: auto;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 100%;
|
||
border-right: none;
|
||
border-bottom: 1px solid #2f2f2f;
|
||
}
|
||
|
||
.sidebar-nav {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 6px;
|
||
}
|
||
|
||
.nav-btn {
|
||
justify-content: center;
|
||
}
|
||
|
||
.nav-btn span:last-child {
|
||
display: none;
|
||
}
|
||
|
||
.content-area {
|
||
overflow: auto;
|
||
}
|
||
|
||
.graph-view {
|
||
min-height: 640px;
|
||
}
|
||
|
||
.query-input-row,
|
||
.api-row {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
</style>
|