Files
OnceLove-New/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue
KOSHM-Pig 1f087599c5 feat(graphrag): 新增多轮检索流式问答与用户管理界面
- 新增多轮 GraphRAG 检索功能,支持流式进度输出(SSE)
- 新增用户管理界面,可查看所有用户图谱统计并快速导航
- 新增多 Agent 任务拆解与执行服务,支持复杂任务协作处理
- 改进 embedding 和 rerank 服务的容错机制,支持备用模型和端点
- 更新前端样式遵循 Acmetone 设计规范,优化视觉一致性
- 新增流式分析接口,支持并行处理和专家评审选项
2026-03-24 12:04:28 +08:00

2103 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="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>