| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- <template>
- <div class="wiki-graph">
- <div class="wiki-graph__header">
- <div>
- <div class="wiki-graph__title">知识图谱</div>
- <div class="wiki-graph__desc">基于 Wiki 页面与关系边构建的图谱视图,可拖拽、缩放。</div>
- <div class="wiki-graph__summary">
- <div v-for="item in graphSummary" :key="item.label" class="wiki-graph-stat">
- <span class="wiki-graph-stat__label">{{ item.label }}</span>
- <span class="wiki-graph-stat__value">{{ item.value }}</span>
- </div>
- </div>
- </div>
- <div class="wiki-graph__legend">
- <div v-for="item in legendItems" :key="item.type" class="wiki-graph-legend">
- <span class="wiki-graph-legend__dot" :style="{ backgroundColor: item.color }"></span>
- <span>{{ item.label }}</span>
- </div>
- </div>
- </div>
- <div ref="graphContainerRef" class="wiki-graph__canvas" v-loading="graphLoading"></div>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
- import * as d3 from 'd3'
- import { wiki } from '@repo/api-service'
- import type { KnowledgeBaseItem } from './types'
- type WikiPageType = 'summary' | 'entity' | 'concept' | 'index' | 'log' | string
- interface WikiGraphNode {
- id: string
- title: string
- slug: string
- page_type: WikiPageType
- linkCount: number
- }
- interface WikiGraphEdge {
- id: string
- source: string
- target: string
- weight: number
- }
- interface WikiStats {
- total_links: number
- }
- interface ForceNode extends WikiGraphNode {
- fx?: number | null
- fy?: number | null
- x?: number
- y?: number
- }
- interface ForceLink {
- id: string
- source: string | ForceNode
- target: string | ForceNode
- weight: number
- }
- const props = defineProps<{
- currentBase: KnowledgeBaseItem
- }>()
- const graphContainerRef = ref<HTMLDivElement>()
- const graphLoading = ref(false)
- const selectedSlug = ref('')
- const stats = ref<WikiStats>({
- total_links: 0
- })
- const graphData = ref<{ nodes: WikiGraphNode[]; edges: WikiGraphEdge[] }>({
- nodes: [],
- edges: []
- })
- const legendItems = [
- { type: 'summary', label: '摘要', color: '#1d6ff2' },
- { type: 'entity', label: '实体', color: '#2cb67d' },
- { type: 'concept', label: '概念', color: '#f08c24' },
- { type: 'index', label: '索引', color: '#3fa7ff' },
- { type: 'log', label: '日志', color: '#eb5757' }
- ]
- const graphSummary = computed(() => {
- const duplicateCount =
- stats.value.total_links > graphData.value.edges.length
- ? stats.value.total_links - graphData.value.edges.length
- : 0
- return [
- { label: '节点数', value: graphData.value.nodes.length },
- { label: '关系数', value: graphData.value.edges.length },
- { label: '合并重复边', value: duplicateCount }
- ]
- })
- let simulation: any = null
- function getNodeColor(type: WikiPageType) {
- switch (type) {
- case 'summary':
- return '#1d6ff2'
- case 'entity':
- return '#2cb67d'
- case 'concept':
- return '#f08c24'
- case 'index':
- return '#3fa7ff'
- case 'log':
- return '#eb5757'
- default:
- return '#9aa4b2'
- }
- }
- function truncateLabel(label: string, max = 14) {
- return label.length > max ? `${label.slice(0, max)}...` : label
- }
- async function loadStats() {
- if (!props.currentBase.id) return
- const res = await wiki.postAiWikiStats({ knowledge_base_id: props.currentBase.id })
- if (res?.isSuccess) {
- stats.value = {
- total_links: res.result?.total_links || 0
- }
- }
- }
- async function loadGraph() {
- if (!props.currentBase.id) return
- graphLoading.value = true
- try {
- const res = await wiki.postAiWikiGraph({ knowledge_base_id: props.currentBase.id })
- if (res?.isSuccess) {
- const nodes = res.result?.nodes || []
- const nodeSlugSet = new Set(nodes.map((item) => item.slug).filter(Boolean))
- const edgeMap = new Map<string, WikiGraphEdge>()
- for (const item of res.result?.edges || []) {
- if (!item.source || !item.target) continue
- if (item.source === item.target) continue
- if (!nodeSlugSet.has(item.source) || !nodeSlugSet.has(item.target)) continue
- const edgeKey = `${item.source}__${item.target}`
- const current = edgeMap.get(edgeKey)
- if (current) {
- current.weight += 1
- continue
- }
- edgeMap.set(edgeKey, {
- id: item.id,
- source: item.source,
- target: item.target,
- weight: 1
- })
- }
- graphData.value = {
- nodes: nodes.map((item) => ({
- id: item.slug,
- title: item.title,
- slug: item.slug,
- page_type: item.page_type,
- linkCount: item.linkCount
- })),
- edges: Array.from(edgeMap.values())
- }
- await nextTick()
- renderGraph()
- }
- } finally {
- graphLoading.value = false
- }
- }
- function highlightSelectedNode(slug = '') {
- const root = graphContainerRef.value
- if (!root) return
- const relationSet = new Set<string>()
- if (slug) {
- for (const edge of graphData.value.edges) {
- if (edge.source === slug) relationSet.add(edge.target)
- if (edge.target === slug) relationSet.add(edge.source)
- }
- }
- root.querySelectorAll<SVGCircleElement>('.wiki-node-circle').forEach((node) => {
- const isActive = node.dataset.slug === slug
- const isRelated = relationSet.has(node.dataset.slug || '')
- node.setAttribute(
- 'stroke',
- isActive ? '#ffffff' : isRelated ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.78)'
- )
- node.setAttribute('stroke-width', isActive ? '3.5' : isRelated ? '2.2' : '1.5')
- node.setAttribute('opacity', slug && !isActive && !isRelated ? '0.35' : '1')
- })
- root.querySelectorAll<SVGLineElement>('.wiki-link-line').forEach((line) => {
- const source = line.dataset.source || ''
- const target = line.dataset.target || ''
- const isActive = !!slug && (source === slug || target === slug)
- line.setAttribute('stroke', isActive ? 'rgba(255,255,255,0.68)' : 'rgba(255,255,255,0.22)')
- line.setAttribute('opacity', slug && !isActive ? '0.18' : '1')
- })
- root.querySelectorAll<SVGTextElement>('.wiki-node-label').forEach((label) => {
- const isActive = label.dataset.slug === slug
- const isRelated = relationSet.has(label.dataset.slug || '')
- label.setAttribute('opacity', slug && !isActive && !isRelated ? '0.38' : '1')
- label.setAttribute('font-weight', isActive ? '700' : isRelated ? '600' : '500')
- })
- }
- function renderGraph() {
- const container = graphContainerRef.value
- if (!container) return
- simulation?.stop()
- container.innerHTML = ''
- const width = Math.max(container.clientWidth, 720)
- const height = Math.max(container.clientHeight, 520)
- const nodes: ForceNode[] = graphData.value.nodes.map((item) => ({ ...item }))
- const links: ForceLink[] = graphData.value.edges.map((item) => ({ ...item }))
- const svg = d3
- .select(container)
- .append('svg')
- .attr('width', width)
- .attr('height', height)
- .attr('viewBox', `0 0 ${width} ${height}`)
- const defs = svg.append('defs')
- defs
- .append('marker')
- .attr('id', 'wiki-arrow')
- .attr('viewBox', '0 -5 10 10')
- .attr('refX', 15)
- .attr('refY', 0)
- .attr('markerWidth', 4.5)
- .attr('markerHeight', 4.5)
- .attr('orient', 'auto')
- .append('path')
- .attr('d', 'M0,-5L10,0L0,5')
- .attr('fill', 'rgba(255,255,255,0.65)')
- const root = svg.append('g')
- svg.call(
- d3
- .zoom()
- .scaleExtent([0.4, 2.5])
- .on('zoom', (event: any) => {
- root.attr('transform', event.transform)
- })
- )
- const link = root
- .append('g')
- .selectAll('line')
- .data(links)
- .enter()
- .append('line')
- .attr('class', 'wiki-link-line')
- .attr('data-source', (d: ForceLink) => `${d.source}`)
- .attr('data-target', (d: ForceLink) => `${d.target}`)
- .attr('stroke', 'rgba(255,255,255,0.22)')
- .attr('stroke-width', (d: ForceLink) => 0.75 + Math.min(1.8, d.weight * 0.24))
- .attr('marker-end', 'url(#wiki-arrow)')
- const nodeGroup = root
- .append('g')
- .selectAll('g')
- .data(nodes)
- .enter()
- .append('g')
- .style('cursor', 'pointer')
- .on('click', (_event: any, datum: ForceNode) => {
- selectedSlug.value = datum.slug
- highlightSelectedNode(datum.slug)
- })
- nodeGroup
- .append('circle')
- .attr('class', 'wiki-node-circle')
- .attr('data-slug', (d: ForceNode) => d.slug)
- .attr('r', (d: ForceNode) => Math.max(8, Math.min(18, 8 + d.linkCount * 0.8)))
- .attr('fill', (d: ForceNode) => getNodeColor(d.page_type))
- .attr('stroke', 'rgba(255,255,255,0.9)')
- .attr('stroke-width', 1.5)
- nodeGroup
- .append('text')
- .attr('class', 'wiki-node-label')
- .attr('data-slug', (d: ForceNode) => d.slug)
- .text((d: ForceNode) => truncateLabel(d.title))
- .attr('fill', '#e5e7eb')
- .attr('font-size', 11)
- .attr('text-anchor', 'middle')
- .attr('font-weight', 500)
- .attr('dy', 24)
- simulation = d3
- .forceSimulation(nodes)
- .force(
- 'link',
- d3
- .forceLink(links as any)
- .id((d: any) => d.id)
- .distance((d: ForceLink) => {
- const sourceCount = (d.source as ForceNode).linkCount || 0
- const targetCount = (d.target as ForceNode).linkCount || 0
- const density = Math.max(sourceCount, targetCount)
- return 44 + Math.min(48, density * 2) - Math.min(18, d.weight * 2)
- })
- .strength((d: ForceLink) => Math.min(0.95, 0.22 + d.weight * 0.12))
- )
- .force('charge', d3.forceManyBody().strength(-160))
- .force('center', d3.forceCenter(width / 2, height / 2))
- .force(
- 'collision',
- d3.forceCollide().radius((d: any) => Math.max(16, 10 + d.linkCount * 0.8))
- )
- .force('x', d3.forceX(width / 2).strength(0.04))
- .force('y', d3.forceY(height / 2).strength(0.04))
- .on('tick', () => {
- link
- .attr('x1', (d: ForceLink) => (d.source as ForceNode).x || 0)
- .attr('y1', (d: ForceLink) => (d.source as ForceNode).y || 0)
- .attr('x2', (d: ForceLink) => (d.target as ForceNode).x || 0)
- .attr('y2', (d: ForceLink) => (d.target as ForceNode).y || 0)
- nodeGroup.attr('transform', (d: ForceNode) => `translate(${d.x || 0},${d.y || 0})`)
- })
- nodeGroup.call(
- d3
- .drag()
- .on('start', (event: any, d: ForceNode) => {
- if (!event.active) simulation?.alphaTarget(0.3).restart()
- d.fx = d.x
- d.fy = d.y
- })
- .on('drag', (event: any, d: ForceNode) => {
- d.fx = event.x
- d.fy = event.y
- })
- .on('end', (event: any, d: ForceNode) => {
- if (!event.active) simulation?.alphaTarget(0)
- d.fx = null
- d.fy = null
- })
- )
- highlightSelectedNode(selectedSlug.value)
- }
- async function loadAll() {
- if (!props.currentBase.id) return
- await loadStats()
- await loadGraph()
- }
- watch(
- () => props.currentBase.id,
- () => {
- selectedSlug.value = ''
- graphData.value = { nodes: [], edges: [] }
- stats.value = { total_links: 0 }
- if (props.currentBase.id) {
- void loadAll()
- }
- },
- { immediate: true }
- )
- onMounted(() => {
- window.addEventListener('resize', renderGraph)
- })
- onBeforeUnmount(() => {
- window.removeEventListener('resize', renderGraph)
- simulation?.stop()
- })
- </script>
- <style scoped lang="less">
- .wiki-graph {
- height: calc(100vh - 250px);
- min-height: 520px;
- padding: 12px;
- border: 1px solid var(--border-light);
- border-radius: 10px;
- background: var(--bg-base);
- box-sizing: border-box;
- }
- .wiki-graph__header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 10px;
- }
- .wiki-graph__title {
- font-size: 15px;
- font-weight: 700;
- color: var(--text-primary);
- }
- .wiki-graph__desc {
- margin-top: 4px;
- font-size: 12px;
- color: var(--text-secondary);
- }
- .wiki-graph__summary {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- margin-top: 12px;
- }
- .wiki-graph-stat {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- border: 1px solid var(--border-light);
- border-radius: 999px;
- background: var(--bg-container);
- }
- .wiki-graph-stat__label {
- font-size: 12px;
- color: var(--text-secondary);
- }
- .wiki-graph-stat__value {
- font-size: 12px;
- font-weight: 700;
- color: var(--text-primary);
- }
- .wiki-graph__legend {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- }
- .wiki-graph-legend {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: var(--text-secondary);
- }
- .wiki-graph-legend__dot {
- width: 10px;
- height: 10px;
- border-radius: 999px;
- }
- .wiki-graph__canvas {
- height: calc(100% - 78px);
- min-height: 460px;
- border-radius: 12px;
- background:
- radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 42%),
- linear-gradient(180deg, #272727 0%, #202020 100%);
- overflow: hidden;
- }
- @media (max-width: 960px) {
- .wiki-graph {
- height: auto;
- min-height: 0;
- }
- .wiki-graph__header {
- flex-direction: column;
- }
- .wiki-graph__canvas {
- height: 420px;
- min-height: 420px;
- }
- }
- </style>
|