ForcePilot/web/src/components/MindMapSection.vue
2026-06-06 19:38:33 +08:00

705 lines
17 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="mindmap-section">
<div class="section-content">
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<a-spin size="small" />
<span>加载中...</span>
</div>
<!-- 生成中状态 -->
<div v-else-if="generating" class="generating-state">
<a-spin size="small" />
<span>AI 正在生成思维导图...</span>
</div>
<!-- 空状态 -->
<div v-else-if="!mindmapData" class="empty-state">
<div class="empty-icon">
<MapIcon :size="24" />
</div>
<p class="empty-title">暂无思维导图</p>
<p class="empty-description">从当前知识库内容生成结构化导图。</p>
<button
type="button"
class="lucide-icon-btn mindmap-primary-action"
@click="generateMindmap"
>
<Sparkles :size="14" />
<span>生成思维导图</span>
</button>
</div>
<!-- 思维导图显示 -->
<div v-else class="mindmap-container">
<div class="mindmap-toolbar">
<a-space :size="8">
<button
type="button"
class="lucide-icon-btn mindmap-toolbar-btn"
:disabled="generating"
@click="refreshMindmap"
title="重新生成"
>
<RefreshCw :size="14" :class="{ spin: generating }" />
<span class="toolbar-text">重新生成</span>
</button>
<button
type="button"
class="lucide-icon-btn mindmap-toolbar-btn"
@click="fitView"
title="适应视图"
>
<Maximize2 :size="14" />
<span class="toolbar-text">适应视图</span>
</button>
</a-space>
</div>
<div class="mindmap-svg-container">
<svg ref="mindmapSvg" class="mindmap-svg"></svg>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { RefreshCw, Map as MapIcon, Sparkles, Maximize2 } from 'lucide-vue-next'
import { mindmapApi } from '@/apis/knowledge_api'
import { Markmap } from 'markmap-view'
import { Transformer } from 'markmap-lib'
const props = defineProps({
kbId: {
type: String,
required: true
}
})
// ============================================================================
// 状态管理
// ============================================================================
const loading = ref(false)
const generating = ref(false)
const mindmapData = ref(null)
const mindmapSvg = ref(null)
let markmapInstance = null
let textMeasureContext = null
const SVG_NS = 'http://www.w3.org/2000/svg'
const MARKMAP_MAX_WIDTH = 200
const MARKMAP_PADDING_X = 8
const MARKMAP_LINE_HEIGHT = 20
const MARKMAP_TEXT_BASELINE = 16
const SAFARI_FALLBACK_FONT = '300 16px sans-serif'
const useSvgTextFallback = (() => {
if (typeof navigator === 'undefined') return false
const userAgent = navigator.userAgent || ''
const vendor = navigator.vendor || ''
const isAppleWebKit = userAgent.includes('AppleWebKit')
const isDesktopChromium =
/(Chrome|Chromium|Edg|OPR)\//.test(userAgent) && !/(CriOS|FxiOS|EdgiOS)\//.test(userAgent)
const isAppleBrowser = vendor.includes('Apple') || /(Safari|iPhone|iPad|iPod)/.test(userAgent)
return isAppleWebKit && isAppleBrowser && !isDesktopChromium
})()
// ============================================================================
// 方法
// ============================================================================
/**
* 加载思维导图
*/
const loadMindmap = async () => {
if (!props.kbId) return
try {
loading.value = true
const response = await mindmapApi.getByDatabase(props.kbId)
const mindmap = response.mindmap || null
mindmapData.value = mindmap
if (markmapInstance) {
markmapInstance.destroy()
markmapInstance = null
}
if (mindmap) {
await nextTick()
// 延迟渲染确保DOM完全更新
setTimeout(() => {
renderMindmap(mindmap)
}, 100)
}
} catch (error) {
// 如果是404错误说明还没有生成静默处理
if (
error?.message?.includes('404') ||
error?.message?.includes('不存在') ||
error?.message?.includes('还没有生成')
) {
mindmapData.value = null
} else {
console.error('加载思维导图失败:', error)
const errorMsg = error?.message || String(error)
message.error('加载思维导图失败: ' + errorMsg)
}
} finally {
loading.value = false
}
}
/**
* 生成思维导图
*/
const generateMindmap = async () => {
if (!props.kbId) return
try {
generating.value = true
const response = await mindmapApi.generateMindmap(
props.kbId,
[], // 使用所有文件
'' // 无自定义提示
)
mindmapData.value = response.mindmap
// 等待DOM更新
await nextTick()
// 再延迟一点确保SVG元素完全渲染
setTimeout(() => {
renderMindmap(response.mindmap)
message.success('思维导图生成成功!')
}, 100)
} catch (error) {
console.error('生成思维导图失败:', error)
const errorMsg = error?.message || String(error)
message.error('生成失败: ' + errorMsg)
} finally {
generating.value = false
}
}
/**
* 刷新思维导图
*/
const refreshMindmap = async () => {
await generateMindmap()
}
/**
* 将JSON转换为Markdown
*/
const jsonToMarkdown = (node, level = 0) => {
if (!node || !node.content) return ''
const indent = '#'.repeat(level + 1)
let markdown = `${indent} ${node.content}\n\n`
if (node.children && node.children.length > 0) {
for (const child of node.children) {
markdown += jsonToMarkdown(child, level + 1)
}
}
return markdown
}
const ensureSvgViewportSize = () => {
const svg = mindmapSvg.value
const container = svg?.parentElement
if (!svg || !container) return false
const { width, height } = container.getBoundingClientRect()
if (width <= 0 || height <= 0) return false
svg.setAttribute('width', `${Math.round(width)}`)
svg.setAttribute('height', `${Math.round(height)}`)
return true
}
const createSvgElement = (tagName) => document.createElementNS(SVG_NS, tagName)
const getNodeText = (html) => {
const element = document.createElement('div')
element.innerHTML = html || ''
return (element.textContent || '').replace(/\s+/g, ' ').trim()
}
const getTextMeasureContext = () => {
if (!textMeasureContext) {
const canvas = document.createElement('canvas')
textMeasureContext = canvas.getContext('2d')
}
if (textMeasureContext) {
const font = mindmapSvg.value ? getComputedStyle(mindmapSvg.value).font : ''
textMeasureContext.font = font || SAFARI_FALLBACK_FONT
}
return textMeasureContext
}
const splitTextTokens = (text) => text.match(/[A-Za-z0-9_.:/#-]+|\s+|./gu) || []
const wrapSvgText = (text, maxWidth) => {
const context = getTextMeasureContext()
const measure = (value) => (context ? context.measureText(value).width : value.length * 8)
const lines = []
let currentLine = ''
for (const token of splitTextTokens(text)) {
const normalizedToken = /^\s+$/.test(token) ? ' ' : token
if (!currentLine && normalizedToken === ' ') continue
const nextLine = `${currentLine}${normalizedToken}`
if (!currentLine || measure(nextLine) <= maxWidth) {
currentLine = nextLine
continue
}
lines.push(currentLine.trimEnd())
if (measure(normalizedToken) <= maxWidth) {
currentLine = normalizedToken.trimStart()
continue
}
currentLine = ''
for (const char of [...normalizedToken]) {
const nextCharLine = `${currentLine}${char}`
if (!currentLine || measure(nextCharLine) <= maxWidth) {
currentLine = nextCharLine
} else {
lines.push(currentLine)
currentLine = char
}
}
}
if (currentLine) {
lines.push(currentLine.trimEnd())
}
return lines.length ? lines : [text]
}
const collectVisibleNodes = (node, nodes = []) => {
if (!node?.state?.rect) return nodes
nodes.push(node)
if (!node.payload?.fold) {
for (const child of node.children || []) {
collectVisibleNodes(child, nodes)
}
}
return nodes
}
const hideOriginalMarkmapText = (contentGroup) => {
contentGroup?.querySelectorAll('.markmap-foreign').forEach((element) => {
element.setAttribute('visibility', 'hidden')
element.style.setProperty('opacity', '0', 'important')
element.style.setProperty('visibility', 'hidden', 'important')
element.style.setProperty('pointer-events', 'none', 'important')
})
}
const syncSafariTextFallback = () => {
const svg = mindmapSvg.value
const contentGroup = markmapInstance?.g?.node?.()
const data = markmapInstance?.state?.data
if (!useSvgTextFallback || !svg || !contentGroup || !data) {
svg?.classList.remove('mindmap-safari-fallback')
return
}
svg.classList.add('mindmap-safari-fallback')
hideOriginalMarkmapText(contentGroup)
contentGroup.querySelectorAll('.mindmap-safari-text-layer').forEach((element) => element.remove())
const layer = createSvgElement('g')
layer.setAttribute('class', 'mindmap-safari-text-layer')
for (const node of collectVisibleNodes(data)) {
const text = getNodeText(node.content)
const rect = node.state.rect
if (!text || rect.width <= 0 || rect.height <= 0) continue
const label = createSvgElement('g')
label.setAttribute('class', 'mindmap-safari-label')
label.setAttribute('transform', `translate(${rect.x + MARKMAP_PADDING_X},${rect.y})`)
const textElement = createSvgElement('text')
textElement.setAttribute('xml:space', 'preserve')
wrapSvgText(text, MARKMAP_MAX_WIDTH).forEach((line, index) => {
const tspan = createSvgElement('tspan')
tspan.setAttribute('x', '0')
tspan.setAttribute('y', `${MARKMAP_TEXT_BASELINE + index * MARKMAP_LINE_HEIGHT}`)
tspan.textContent = line
textElement.append(tspan)
})
label.append(textElement)
layer.append(label)
}
contentGroup.append(layer)
hideOriginalMarkmapText(contentGroup)
}
const patchSafariTextFallback = () => {
if (!useSvgTextFallback || !markmapInstance) return
const originalRenderData = markmapInstance.renderData.bind(markmapInstance)
markmapInstance.renderData = async (...args) => {
const result = await originalRenderData(...args)
syncSafariTextFallback()
setTimeout(() => {
hideOriginalMarkmapText(markmapInstance?.g?.node?.())
}, 350)
return result
}
}
/**
* 渲染思维导图
*/
const renderMindmap = async (data, retryCount = 0) => {
if (!data) return
if (!mindmapSvg.value || !ensureSvgViewportSize()) {
// 如果SVG或尺寸还没准备好最多重试3次
if (retryCount < 3) {
setTimeout(() => {
renderMindmap(data, retryCount + 1)
}, 100)
return
} else {
console.error('无法获取SVG容器渲染失败')
message.error('渲染失败无法找到SVG容器')
return
}
}
try {
// 清空之前的实例
if (markmapInstance) {
markmapInstance.destroy()
}
mindmapSvg.value.classList.remove('mindmap-safari-fallback')
// 将JSON转换为Markdown
const markdown = jsonToMarkdown(data)
// 使用Transformer转换
const transformer = new Transformer()
const { root } = transformer.transform(markdown)
// 创建Markmap实例
markmapInstance = Markmap.create(mindmapSvg.value, {
duration: 300,
maxWidth: MARKMAP_MAX_WIDTH,
nodeMinHeight: 24,
paddingX: MARKMAP_PADDING_X,
spacingVertical: 5,
spacingHorizontal: 60
})
patchSafariTextFallback()
await markmapInstance.setData(root)
await markmapInstance.fit()
// 延迟再次适应,确保布局完全稳定
setTimeout(() => {
if (markmapInstance) {
syncSafariTextFallback()
markmapInstance.fit()
}
}, 300)
} catch (error) {
console.error('渲染思维导图失败:', error)
message.error('渲染失败: ' + error.message)
}
}
/**
* 适应视图
*/
const fitView = () => {
if (markmapInstance) {
ensureSvgViewportSize()
syncSafariTextFallback()
markmapInstance.fit()
}
}
/**
* 暴露给父组件的方法
*/
defineExpose({
refreshMindmap,
generateMindmap
})
// ============================================================================
// 生命周期
// ============================================================================
// 监听数据库ID变化
watch(
() => props.kbId,
(newId) => {
if (newId) {
loadMindmap()
}
},
{ immediate: true }
)
// 监听容器大小变化,自动适应
let resizeObserver = null
onMounted(() => {
// 设置ResizeObserver监听容器大小变化
nextTick(() => {
if (mindmapSvg.value) {
const container = mindmapSvg.value.parentElement
if (container) {
resizeObserver = new ResizeObserver(() => {
if (markmapInstance) {
ensureSvgViewportSize()
syncSafariTextFallback()
markmapInstance.fit()
}
})
resizeObserver.observe(container)
}
}
})
})
// 清理
onUnmounted(() => {
if (markmapInstance) {
markmapInstance.destroy()
}
if (resizeObserver) {
resizeObserver.disconnect()
}
})
</script>
<style scoped lang="less">
.mindmap-section {
display: flex;
flex-direction: column;
background: var(--gray-0);
border: 1px solid var(--gray-150);
border-radius: 8px;
height: 100%;
min-height: 0;
overflow: hidden;
}
.section-content {
flex: 1;
min-height: 200px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.loading-state,
.generating-state,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 28px;
color: var(--gray-500);
font-size: 13px;
text-align: center;
p {
margin: 0;
}
}
.empty-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--gray-150);
border-radius: 12px;
background: var(--main-30);
color: var(--main-color);
}
.empty-title {
margin-top: 2px;
color: var(--gray-900);
font-size: 15px;
font-weight: 600;
}
.empty-description {
max-width: 280px;
color: var(--gray-500);
line-height: 1.5;
}
.mindmap-primary-action {
min-height: 32px;
margin-top: 4px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--main-600);
color: var(--main-0);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover,
&:focus-visible {
background: var(--main-700);
color: var(--main-0);
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--main-200);
}
}
.mindmap-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.mindmap-toolbar {
padding: 8px 12px;
background: var(--gray-0);
border-bottom: 1px solid var(--gray-150);
display: flex;
align-items: center;
justify-content: flex-end;
.toolbar-text {
margin-left: 4px;
font-size: 13px;
}
}
.mindmap-toolbar-btn {
min-height: 30px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--gray-600);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover,
&:focus-visible {
background: var(--gray-50);
color: var(--main-color);
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--main-100);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&:disabled:hover {
background: transparent;
color: var(--gray-600);
}
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.mindmap-svg-container {
flex: 1;
position: relative;
overflow: hidden;
background: var(--gray-0);
}
.mindmap-svg {
width: 100%;
height: 100%;
min-height: 150px;
display: block;
}
// 确保父容器有高度
:deep(.markmap) {
width: 100% !important;
height: 100% !important;
}
:deep(.mindmap-svg.mindmap-safari-fallback .markmap-foreign) {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none;
}
:deep(.mindmap-safari-text-layer) {
pointer-events: none;
}
:deep(.mindmap-safari-label) {
font: var(--markmap-font);
fill: var(--markmap-text-color);
}
</style>