<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>
