feat(思维导图助手):修复问题
parent
a999121ff8
commit
09f7a0bee5
|
|
@ -6,7 +6,7 @@
|
|||
<!-- 最大页数 -->
|
||||
<el-form-item :label="$t('config.maxPages')" class="form-item">
|
||||
<el-slider
|
||||
v-model="config.maxPages"
|
||||
v-model="draftConfig.maxPages"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
show-input
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
<!-- 解析后端 -->
|
||||
<el-form-item :label="$t('config.backend')" class="form-item">
|
||||
<el-select
|
||||
v-model="config.backend"
|
||||
v-model="draftConfig.backend"
|
||||
style="width: 100%"
|
||||
@change="onBackendChange"
|
||||
class="select"
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
/>
|
||||
</el-select>
|
||||
<div class="form-item-description">
|
||||
{{ getBackendDescription(config.backend) }}
|
||||
{{ getBackendDescription(draftConfig.backend) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
class="form-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="config.serverUrl"
|
||||
v-model="draftConfig.serverUrl"
|
||||
:placeholder="'http://localhost:30000'"
|
||||
class="input"
|
||||
/>
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
|
||||
<!-- 表格识别 -->
|
||||
<el-form-item class="form-item">
|
||||
<el-checkbox v-model="config.tableEnable" class="checkbox">
|
||||
<el-checkbox v-model="draftConfig.tableEnable" class="checkbox">
|
||||
{{ $t('config.tableEnable') }}
|
||||
</el-checkbox>
|
||||
<div class="form-item-description">
|
||||
|
|
@ -67,11 +67,11 @@
|
|||
|
||||
<!-- 公式识别 -->
|
||||
<el-form-item class="form-item">
|
||||
<el-checkbox v-model="config.formulaEnable" class="checkbox">
|
||||
{{ getFormulaLabel(config.backend) }}
|
||||
<el-checkbox v-model="draftConfig.formulaEnable" class="checkbox">
|
||||
{{ getFormulaLabel(draftConfig.backend) }}
|
||||
</el-checkbox>
|
||||
<div class="form-item-description">
|
||||
{{ getFormulaInfo(config.backend) }}
|
||||
{{ getFormulaInfo(draftConfig.backend) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<!-- OCR语言 -->
|
||||
<el-form-item :label="$t('config.ocrLanguage')" class="form-item">
|
||||
<el-select v-model="config.language" style="width: 100%" class="select">
|
||||
<el-select v-model="draftConfig.language" style="width: 100%" class="select">
|
||||
<el-option
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
|
||||
<!-- 强制OCR -->
|
||||
<el-form-item class="form-item">
|
||||
<el-checkbox v-model="config.forceOcr" class="checkbox">
|
||||
<el-checkbox v-model="draftConfig.forceOcr" class="checkbox">
|
||||
{{ $t('config.forceOcr') }}
|
||||
</el-checkbox>
|
||||
<div class="form-item-description">
|
||||
|
|
@ -104,12 +104,17 @@
|
|||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<div class="config-actions">
|
||||
<el-button @click="resetDraft">重置</el-button>
|
||||
<el-button type="primary" @click="confirmConfig">确认</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { DocumentConfig } from '@/composables/useDocumentProcessor'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -126,21 +131,42 @@ interface Emits {
|
|||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const config = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
|
||||
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
|
||||
|
||||
const syncDraft = (value: DocumentConfig) => {
|
||||
Object.assign(draftConfig, createDraft(value))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => syncDraft(value),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const showServerUrl = computed(() => {
|
||||
return props.modelValue.backend.includes('http-client')
|
||||
return draftConfig.backend.includes('http-client')
|
||||
})
|
||||
|
||||
const isVlmBackend = computed(() => {
|
||||
return props.modelValue.backend.startsWith('vlm')
|
||||
return draftConfig.backend.startsWith('vlm')
|
||||
})
|
||||
|
||||
const onBackendChange = (backend: string) => {
|
||||
emit('backendChange', backend)
|
||||
if (!backend.includes('http-client')) {
|
||||
draftConfig.serverUrl = props.modelValue.serverUrl
|
||||
}
|
||||
}
|
||||
|
||||
const resetDraft = () => {
|
||||
syncDraft(props.modelValue)
|
||||
}
|
||||
|
||||
const confirmConfig = () => {
|
||||
const confirmed = createDraft(draftConfig)
|
||||
emit('update:modelValue', confirmed)
|
||||
emit('backendChange', confirmed.backend)
|
||||
ElMessage.success('设置已确认')
|
||||
}
|
||||
|
||||
const getFormulaLabel = (backend: string) => {
|
||||
|
|
@ -224,6 +250,14 @@ const getBackendDescription = (backend: string) => {
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #E4E7ED;
|
||||
}
|
||||
|
||||
.slider :deep(.el-slider__runway) {
|
||||
background-color: #E4E7ED;
|
||||
}
|
||||
|
|
@ -270,4 +304,4 @@ const getBackendDescription = (backend: string) => {
|
|||
.checkbox :deep(.el-checkbox__label) {
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item>
|
||||
<el-dropdown-item command="png">PNG 格式</el-dropdown-item>
|
||||
<el-dropdown-item command="jpeg">JPEG 格式</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
|
@ -33,20 +34,25 @@
|
|||
重置视图
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mindmap-content" @wheel.prevent="handleWheel">
|
||||
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
|
||||
<div class="empty-state" v-if="!content">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">暂无思维导图内容</p>
|
||||
<p class="empty-subtext">请先上传并转换文档</p>
|
||||
</div>
|
||||
<svg ref="svgRef" class="markmap-svg" v-else></svg>
|
||||
<div class="mindmap-loading" v-if="content && isRendering">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>正在生成导图...</span>
|
||||
</div>
|
||||
<svg ref="svgRef" class="markmap-svg" v-show="content"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { Download, Refresh, ZoomIn, ZoomOut, Document, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { Download, Refresh, Document, ArrowDown, Loading } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// 导入 markmap 相关包
|
||||
import { Transformer } from 'markmap-lib'
|
||||
|
|
@ -59,14 +65,19 @@ const props = defineProps({
|
|||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'node-edit', payload: { oldText: string; newText: string }): void
|
||||
}>()
|
||||
|
||||
const svgRef = ref<SVGElement | null>(null)
|
||||
let mmInstance: any = null
|
||||
let renderTimer: number | undefined
|
||||
const transformer = new Transformer()
|
||||
const scale = ref(1)
|
||||
const isRendering = ref(false)
|
||||
|
||||
const nodeCount = computed(() => {
|
||||
if (!props.content) return 0
|
||||
// 简单计算节点数量(基于Markdown标题)
|
||||
return (props.content.match(/^#{1,6}\s+/gm) || []).length
|
||||
})
|
||||
|
||||
|
|
@ -82,60 +93,56 @@ const initMarkmap = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Initializing markmap with content:', props.content.substring(0, 100) + '...')
|
||||
|
||||
// 1. 转换数据
|
||||
const { root, features } = transformer.transform(props.content)
|
||||
console.log('Transformed root:', root)
|
||||
isRendering.value = true
|
||||
|
||||
// 2. 加载必要的资源
|
||||
try {
|
||||
const { root } = transformer.transform(props.content)
|
||||
const { styles, scripts } = transformer.getAssets()
|
||||
console.log('Styles:', styles)
|
||||
console.log('Scripts:', scripts)
|
||||
|
||||
if (styles) loadCSS(styles)
|
||||
if (scripts) loadJS(scripts)
|
||||
|
||||
// 3. 创建或更新实例
|
||||
await nextTick()
|
||||
console.log('SVG ref:', svgRef.value)
|
||||
console.log('Markmap.create:', Markmap.create)
|
||||
|
||||
const initialExpandLevel = nodeCount.value > 80 ? 2 : -1
|
||||
|
||||
if (mmInstance) {
|
||||
console.log('Updating existing instance')
|
||||
if (typeof mmInstance.setData === 'function') {
|
||||
mmInstance.setData(root)
|
||||
mmInstance.fit()
|
||||
} else {
|
||||
console.error('mmInstance does not have setData method:', mmInstance)
|
||||
// 创建新实例
|
||||
mmInstance = Markmap.create(svgRef.value, {
|
||||
autoFit: true,
|
||||
fitRatio: 0.9,
|
||||
initialExpandLevel: -1
|
||||
initialExpandLevel
|
||||
}, root)
|
||||
console.log('Created new instance after setData error:', mmInstance)
|
||||
}
|
||||
} else {
|
||||
console.log('Creating new instance')
|
||||
mmInstance = Markmap.create(svgRef.value, {
|
||||
autoFit: true,
|
||||
fitRatio: 0.9,
|
||||
initialExpandLevel: -1
|
||||
initialExpandLevel
|
||||
}, root)
|
||||
console.log('Created instance using Markmap.create:', mmInstance)
|
||||
console.log('mmInstance methods:', Object.keys(mmInstance))
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
svgRef.value?.querySelectorAll('text').forEach((text) => {
|
||||
text.setAttribute('data-editable-node', 'true')
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error initializing markmap:', error)
|
||||
mmInstance = null
|
||||
} finally {
|
||||
isRendering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听内容变化自动重绘
|
||||
watch(() => props.content, () => {
|
||||
initMarkmap()
|
||||
window.clearTimeout(renderTimer)
|
||||
renderTimer = window.setTimeout(() => {
|
||||
initMarkmap()
|
||||
}, 160)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -145,6 +152,7 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(renderTimer)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (mmInstance && typeof mmInstance.destroy === 'function') {
|
||||
mmInstance.destroy()
|
||||
|
|
@ -157,111 +165,87 @@ const handleResize = () => {
|
|||
mmInstance?.fit()
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function createExportSvg() {
|
||||
const svg = svgRef.value
|
||||
if (!svg) return null
|
||||
|
||||
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||
if (!svgCopy.getAttribute('xmlns')) {
|
||||
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
}
|
||||
|
||||
const contentGroup = svg.querySelector('g')
|
||||
const bbox = contentGroup
|
||||
? (contentGroup as SVGGElement).getBBox()
|
||||
: svg.getBBox()
|
||||
|
||||
const padding = 48
|
||||
const width = Math.ceil(Math.max(bbox.width + padding * 2, 320))
|
||||
const height = Math.ceil(Math.max(bbox.height + padding * 2, 240))
|
||||
const viewBox = `${Math.floor(bbox.x - padding)} ${Math.floor(bbox.y - padding)} ${width} ${height}`
|
||||
|
||||
svgCopy.setAttribute('viewBox', viewBox)
|
||||
svgCopy.setAttribute('width', String(width))
|
||||
svgCopy.setAttribute('height', String(height))
|
||||
svgCopy.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
|
||||
|
||||
return {
|
||||
data: new XMLSerializer().serializeToString(svgCopy),
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
// 下载思维导图
|
||||
const downloadMindMap = (format: 'svg' | 'png' = 'svg') => {
|
||||
if (!svgRef.value) return
|
||||
|
||||
// 确保思维导图已经完全渲染
|
||||
if (mmInstance) {
|
||||
mmInstance.fit()
|
||||
}
|
||||
|
||||
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
||||
const exportSvg = createExportSvg()
|
||||
if (!exportSvg) return
|
||||
|
||||
const time = new Date().getTime()
|
||||
if (format === 'svg') {
|
||||
const svg = svgRef.value
|
||||
// 复制 SVG 元素以确保获取完整结构
|
||||
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||
|
||||
// 添加必要的命名空间
|
||||
if (!svgCopy.getAttribute('xmlns')) {
|
||||
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
}
|
||||
|
||||
// 确保 SVG 大小正确
|
||||
const boundingRect = svg.getBoundingClientRect()
|
||||
svgCopy.setAttribute('width', boundingRect.width.toString())
|
||||
svgCopy.setAttribute('height', boundingRect.height.toString())
|
||||
|
||||
// 序列化 SVG
|
||||
const svgData = new XMLSerializer().serializeToString(svgCopy)
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mindmap_${new Date().getTime()}.svg`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} else if (format === 'png') {
|
||||
const svg = svgRef.value
|
||||
// 复制 SVG 元素以确保获取完整结构
|
||||
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||
|
||||
// 添加必要的命名空间
|
||||
if (!svgCopy.getAttribute('xmlns')) {
|
||||
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
}
|
||||
|
||||
// 确保思维导图已经完全渲染并适应视图
|
||||
if (mmInstance) {
|
||||
mmInstance.fit()
|
||||
}
|
||||
|
||||
// 获取 SVG 元素的实际内容大小
|
||||
const boundingRect = svg.getBoundingClientRect()
|
||||
|
||||
// 为大型思维导图设置更高的缩放因子
|
||||
const scaleFactor = 5 // 从 3 提高到 5,进一步提高分辨率
|
||||
|
||||
// 设置 SVG 大小为实际内容大小
|
||||
const svgWidth = boundingRect.width
|
||||
const svgHeight = boundingRect.height
|
||||
svgCopy.setAttribute('width', svgWidth.toString())
|
||||
svgCopy.setAttribute('height', svgHeight.toString())
|
||||
|
||||
// 序列化 SVG
|
||||
const svgData = new XMLSerializer().serializeToString(svgCopy)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) return
|
||||
|
||||
// 设置画布大小为原始大小的 scaleFactor 倍,提高分辨率
|
||||
canvas.width = svgWidth * scaleFactor
|
||||
canvas.height = svgHeight * scaleFactor
|
||||
|
||||
// 缩放画布上下文,确保绘制时保持清晰度
|
||||
ctx.scale(scaleFactor, scaleFactor)
|
||||
|
||||
// 创建一个图像对象
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
// 绘制图像到画布
|
||||
ctx.drawImage(img, 0, 0)
|
||||
|
||||
// 将画布转换为 PNG,设置质量为最高
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mindmap_${new Date().getTime()}.png`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}, 'image/png', 1.0) // 设置质量为 1.0
|
||||
}
|
||||
|
||||
// 将 SVG 数据转换为 Data URL
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData)
|
||||
downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${time}.svg`)
|
||||
return
|
||||
}
|
||||
|
||||
const maxCanvasPixels = 16_000_000
|
||||
const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height))))
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
canvas.width = Math.ceil(exportSvg.width * scaleFactor)
|
||||
canvas.height = Math.ceil(exportSvg.height * scaleFactor)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.scale(scaleFactor, scaleFactor)
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
downloadBlob(blob, `mindmap_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
|
||||
}, mimeType, 0.96)
|
||||
}
|
||||
img.onerror = () => ElMessage.error('导出图片失败')
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(exportSvg.data)
|
||||
}
|
||||
|
||||
// 处理下载命令
|
||||
const handleDownload = (command: string) => {
|
||||
if (command === 'svg' || command === 'png') {
|
||||
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
||||
downloadMindMap(command)
|
||||
}
|
||||
}
|
||||
|
|
@ -306,6 +290,27 @@ const handleWheel = (event: WheelEvent) => {
|
|||
applyZoom()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNodeClick = async (event: MouseEvent) => {
|
||||
const textNode = (event.target as Element | null)?.closest?.('text')
|
||||
const oldText = textNode?.textContent?.trim()
|
||||
if (!oldText || !props.content || oldText === '文档思维导图') return
|
||||
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('编辑节点文本', '编辑思维导图节点', {
|
||||
inputValue: oldText,
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
inputValidator: (value) => Boolean(value.trim()) || '节点文本不能为空'
|
||||
})
|
||||
const newText = String(value).trim()
|
||||
if (newText && newText !== oldText) {
|
||||
emit('node-edit', { oldText, newText })
|
||||
}
|
||||
} catch {
|
||||
// 用户取消编辑
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -392,6 +397,28 @@ const handleWheel = (event: WheelEvent) => {
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.mindmap-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #4E5969;
|
||||
background: rgba(249, 250, 252, 0.82);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
color: #165DFF;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #C9CDD4;
|
||||
|
|
@ -416,6 +443,10 @@ const handleWheel = (event: WheelEvent) => {
|
|||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.markmap-svg :deep(text[data-editable-node="true"]) {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.mindmap-header {
|
||||
|
|
|
|||
|
|
@ -2,55 +2,11 @@ import { ref, reactive } from 'vue'
|
|||
import type { Ref } from 'vue'
|
||||
import { documentApi, type ParseParams } from '@/api/document'
|
||||
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
|
||||
import { buildMindmapMarkdown } from '@/utils/mindmapMarkdown'
|
||||
|
||||
// 根据上一级标题自动补全下一级标题
|
||||
export function autoPromoteParagraphsToSubheading(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
const result: string[] = []
|
||||
let inSection = false
|
||||
let currentHeadingLevel = 0 // 记录当前标题级别
|
||||
|
||||
for (const line of lines) {
|
||||
const stripped = line.trim()
|
||||
|
||||
// 检测标题级别
|
||||
if (stripped.startsWith('#')) {
|
||||
// 计算标题级别(连续的#数量)
|
||||
const headingMatch = stripped.match(/^#+/)
|
||||
if (headingMatch) {
|
||||
currentHeadingLevel = headingMatch[0].length
|
||||
} else {
|
||||
currentHeadingLevel = 0
|
||||
}
|
||||
result.push(line)
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!stripped) {
|
||||
// 空行不需要添加标题,但也不退出标题段落模式
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过图片行
|
||||
if (stripped.startsWith('![')) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 其他特殊行(列表、代码等)都统一处理
|
||||
if (inSection && currentHeadingLevel > 0 && currentHeadingLevel < 6) {
|
||||
// 根据当前标题级别生成下一级标题
|
||||
const nextHeadingLevel = currentHeadingLevel + 1
|
||||
const headingPrefix = '#'.repeat(nextHeadingLevel)
|
||||
result.push(headingPrefix + ' ' + stripped)
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n')
|
||||
return buildMindmapMarkdown(text)
|
||||
}
|
||||
|
||||
export interface DocumentConfig {
|
||||
|
|
@ -89,6 +45,7 @@ export function useDocumentProcessor() {
|
|||
// 结果相关
|
||||
const results = ref<ProcessResult | null>(null)
|
||||
const isProcessing = ref(false)
|
||||
const processingStage = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// 后端选项
|
||||
|
|
@ -185,6 +142,7 @@ export function useDocumentProcessor() {
|
|||
uploadedFiles.value = []
|
||||
results.value = null
|
||||
error.value = null
|
||||
processingStage.value = ''
|
||||
}
|
||||
|
||||
// 演示模式:上传文件后由用户手动粘贴 Markdown 源码
|
||||
|
|
@ -206,8 +164,10 @@ export function useDocumentProcessor() {
|
|||
|
||||
isProcessing.value = true
|
||||
error.value = null
|
||||
processingStage.value = '准备提交解析任务'
|
||||
|
||||
try {
|
||||
processingStage.value = '提交文档到解析服务'
|
||||
const params: ParseParams = {
|
||||
files: uploadedFiles.value,
|
||||
output_dir: './output',
|
||||
|
|
@ -227,22 +187,25 @@ export function useDocumentProcessor() {
|
|||
params.server_url = config.serverUrl
|
||||
}
|
||||
|
||||
processingStage.value = '服务端正在解析文档'
|
||||
const response = await documentApi.parseDocument(params)
|
||||
|
||||
if (response.results) {
|
||||
processingStage.value = '生成 Markdown 和思维导图'
|
||||
const resultData = Object.values(response.results)[0]
|
||||
const mdContent = resultData.md_content || ''
|
||||
const processedSource = autoPromoteParagraphsToSubheading(mdContent)
|
||||
const mindmapContent = buildMindmapMarkdown(mdContent)
|
||||
results.value = {
|
||||
markdown: mdContent,
|
||||
source: processedSource,
|
||||
mindmap: processedSource
|
||||
source: mdContent,
|
||||
mindmap: mindmapContent
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '转换失败'
|
||||
} finally {
|
||||
processingStage.value = ''
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
|
@ -278,6 +241,7 @@ export function useDocumentProcessor() {
|
|||
results,
|
||||
isUploading,
|
||||
isProcessing,
|
||||
processingStage,
|
||||
error,
|
||||
|
||||
// 选项
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
const MAX_NODE_TEXT_LENGTH = 72
|
||||
const MAX_PARAGRAPH_SUMMARY_LENGTH = 90
|
||||
const MAX_CHILDREN_PER_HEADING = 18
|
||||
const ROOT_TITLE = '文档思维导图'
|
||||
|
||||
type BlockType = 'paragraph' | 'list' | 'table' | 'code' | 'math' | 'image'
|
||||
|
||||
interface HeadingState {
|
||||
level: number
|
||||
childCount: number
|
||||
}
|
||||
|
||||
function normalizeWhitespace(text: string) {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function stripMarkdownInline(text: string) {
|
||||
return normalizeWhitespace(text)
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[*_`~>#]/g, '')
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength = MAX_NODE_TEXT_LENGTH) {
|
||||
const normalized = normalizeWhitespace(text)
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
return `${normalized.slice(0, maxLength - 1)}...`
|
||||
}
|
||||
|
||||
function summarizeParagraph(text: string) {
|
||||
const cleaned = stripMarkdownInline(text)
|
||||
const sentence = cleaned.match(/^(.+?[。!?!?;;::]|\S.{20,}?[,,])/)
|
||||
return truncateText(sentence?.[1] || cleaned, MAX_PARAGRAPH_SUMMARY_LENGTH)
|
||||
}
|
||||
|
||||
function isHeading(line: string) {
|
||||
return /^#{1,6}\s+/.test(line.trim())
|
||||
}
|
||||
|
||||
function getHeadingLevel(line: string) {
|
||||
return line.trim().match(/^#{1,6}/)?.[0].length || 0
|
||||
}
|
||||
|
||||
function getHeadingText(line: string) {
|
||||
return stripMarkdownInline(line.trim().replace(/^#{1,6}\s+/, ''))
|
||||
}
|
||||
|
||||
function getListText(line: string) {
|
||||
return stripMarkdownInline(line.trim().replace(/^[-*+]\s+|^\d+[.)、]\s+/, ''))
|
||||
}
|
||||
|
||||
function countHeadingChildren(stack: HeadingState[], level: number) {
|
||||
while (stack.length && stack[stack.length - 1].level >= level) {
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) return true
|
||||
parent.childCount += 1
|
||||
return parent.childCount <= MAX_CHILDREN_PER_HEADING
|
||||
}
|
||||
|
||||
function pushBlock(result: string[], stack: HeadingState[], blockType: BlockType, text: string) {
|
||||
const cleaned = text.trim()
|
||||
if (!cleaned) return
|
||||
|
||||
const parentLevel = stack.length ? stack[stack.length - 1].level : 1
|
||||
const level = Math.min(parentLevel + 1, 6)
|
||||
if (!countHeadingChildren(stack, level)) return
|
||||
|
||||
const prefixMap: Record<BlockType, string> = {
|
||||
paragraph: '摘要',
|
||||
list: '要点',
|
||||
table: '表格',
|
||||
code: '代码',
|
||||
math: '公式',
|
||||
image: '图片'
|
||||
}
|
||||
|
||||
result.push(`${'#'.repeat(level)} ${prefixMap[blockType]}:${cleaned}`)
|
||||
}
|
||||
|
||||
export function buildMindmapMarkdown(markdown: string) {
|
||||
const lines = markdown.split(/\r?\n/)
|
||||
const result: string[] = [`# ${ROOT_TITLE}`]
|
||||
const stack: HeadingState[] = [{ level: 1, childCount: 0 }]
|
||||
let paragraphBuffer: string[] = []
|
||||
let inCodeBlock = false
|
||||
let codeBlockTitle = ''
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return
|
||||
const text = summarizeParagraph(paragraphBuffer.join(' '))
|
||||
pushBlock(result, stack, 'paragraph', text)
|
||||
paragraphBuffer = []
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (/^```/.test(trimmed)) {
|
||||
if (inCodeBlock) {
|
||||
pushBlock(result, stack, 'code', codeBlockTitle || '代码块')
|
||||
codeBlockTitle = ''
|
||||
} else {
|
||||
flushParagraph()
|
||||
codeBlockTitle = trimmed.replace(/^```/, '').trim()
|
||||
}
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) continue
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph()
|
||||
continue
|
||||
}
|
||||
|
||||
if (isHeading(trimmed)) {
|
||||
flushParagraph()
|
||||
const rawLevel = getHeadingLevel(trimmed)
|
||||
const level = Math.min(rawLevel + 1, 6)
|
||||
while (stack.length && stack[stack.length - 1].level >= level) {
|
||||
stack.pop()
|
||||
}
|
||||
result.push(`${'#'.repeat(level)} ${truncateText(getHeadingText(trimmed)) || '未命名章节'}`)
|
||||
stack.push({ level, childCount: 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\|.+\|$/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'table', '表格内容')
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^!\[[^\]]*]\([^)]+\)/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'image', stripMarkdownInline(trimmed) || '图片')
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\$\$.*\$\$$/.test(trimmed) || /^\$[^$].*\$$/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'math', truncateText(trimmed, 80))
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[-*+]\s+|^\d+[.)、]\s+/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'list', truncateText(getListText(trimmed)))
|
||||
continue
|
||||
}
|
||||
|
||||
paragraphBuffer.push(trimmed)
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
export function replaceFirstMindmapText(markdown: string, oldText: string, newText: string) {
|
||||
const normalizedOld = oldText.replace(/^(摘要|要点|表格|代码|公式|图片):/, '').trim()
|
||||
if (!normalizedOld || !newText.trim()) return markdown
|
||||
|
||||
const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const directMatch = new RegExp(escaped)
|
||||
if (directMatch.test(markdown)) {
|
||||
return markdown.replace(directMatch, newText.trim())
|
||||
}
|
||||
|
||||
const lines = markdown.split(/\r?\n/)
|
||||
const targetLineIndex = lines.findIndex((line) => stripMarkdownInline(line).includes(normalizedOld))
|
||||
if (targetLineIndex >= 0) {
|
||||
lines[targetLineIndex] = lines[targetLineIndex].replace(normalizedOld, newText.trim())
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<div class="upload-content" v-show="!isUploadAreaCollapsed">
|
||||
<el-icon class="upload-icon"><Folder /></el-icon>
|
||||
<div class="upload-text">文件导入</div>
|
||||
<div class="upload-hint">支持PDF、Word、PNG格式文件,上传后可手动粘贴 Markdown 源码用于演示</div>
|
||||
<div class="upload-hint">支持 PDF、Word、PNG 格式文件,可转换为 Markdown 和思维导图,也可手动粘贴 Markdown 源码</div>
|
||||
</div>
|
||||
|
||||
<div class="uploaded-files">
|
||||
|
|
@ -35,6 +35,13 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-actions" v-if="uploadedFiles.length > 0">
|
||||
<el-button type="primary" :loading="isProcessing || isUploading" @click.stop="handleProcessDocument">
|
||||
开始转换
|
||||
</el-button>
|
||||
<el-button @click.stop="clearAllFiles">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
|
|
@ -97,7 +104,7 @@
|
|||
<div class="result-content">
|
||||
<div v-if="isProcessing" class="loading-container">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<p class="loading-text">正在处理文档...</p>
|
||||
<p class="loading-text">{{ processingStage || '正在处理文档...' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!results" class="empty-state">
|
||||
|
|
@ -145,7 +152,7 @@
|
|||
|
||||
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
||||
<div class="mindmap-box">
|
||||
<MindMapRenderer :content="renderedMindmapContent" />
|
||||
<MindMapRenderer :content="renderedMindmapContent" @node-edit="handleMindmapNodeEdit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -207,20 +214,25 @@ import { ElMessage } from 'element-plus'
|
|||
import ConfigPanel from '@/components/ConfigPanel.vue'
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
||||
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
||||
import { autoPromoteParagraphsToSubheading, useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
||||
import { useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
||||
import { markdownToAstString } from '@/utils/markdownAst'
|
||||
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
||||
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
||||
|
||||
const {
|
||||
uploadedFiles,
|
||||
config,
|
||||
results,
|
||||
isUploading,
|
||||
isProcessing,
|
||||
processingStage,
|
||||
error,
|
||||
backendOptions,
|
||||
languageOptions,
|
||||
clearAll,
|
||||
initializeManualResult,
|
||||
handleFileUpload
|
||||
handleFileUpload,
|
||||
processDocument
|
||||
} = useDocumentProcessor()
|
||||
|
||||
const showSettings = ref(false)
|
||||
|
|
@ -239,7 +251,7 @@ const draftReplacementRules = ref<ReplacementRule[]>([])
|
|||
const manualMarkdownContent = computed(() => results.value?.source || '')
|
||||
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
|
||||
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
|
||||
const renderedMindmapContent = computed(() => autoPromoteParagraphsToSubheading(renderedMarkdownContent.value))
|
||||
const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value))
|
||||
const templateRenderError = computed(() => templateRenderResult.value.error)
|
||||
const activeReplacementRuleCount = computed(() => replacementRules.value.filter((rule) => rule.search.trim()).length)
|
||||
const hasActiveReplacementRules = computed(() => activeReplacementRuleCount.value > 0)
|
||||
|
|
@ -322,6 +334,19 @@ const handleBackendChange = (backend: string) => {
|
|||
console.log('Backend changed to:', backend)
|
||||
}
|
||||
|
||||
const handleProcessDocument = async () => {
|
||||
await processDocument()
|
||||
if (error.value) {
|
||||
ElMessage.error(error.value)
|
||||
return
|
||||
}
|
||||
if (results.value) {
|
||||
activeTab.value = 'markdown'
|
||||
sourceViewMode.value = 'markdown'
|
||||
ElMessage.success('转换完成')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkdownRenderModeChange = (command: 'markdown' | 'html' | 'pdf' | 'richtext') => {
|
||||
markdownRenderMode.value = command
|
||||
activeTab.value = 'markdown'
|
||||
|
|
@ -363,6 +388,18 @@ const saveReplacementRules = () => {
|
|||
ElMessage.success(replacementRules.value.length > 0 ? '替换规则已保存并生效' : '已清空替换规则')
|
||||
}
|
||||
|
||||
const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => {
|
||||
if (!results.value) return
|
||||
const updated = replaceFirstMindmapText(results.value.source || results.value.markdown, oldText, newText)
|
||||
if (updated === (results.value.source || results.value.markdown)) {
|
||||
ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑')
|
||||
return
|
||||
}
|
||||
results.value.source = updated
|
||||
results.value.markdown = updated
|
||||
ElMessage.success('节点已同步到 Markdown')
|
||||
}
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
|
@ -539,6 +576,13 @@ onUnmounted(() => {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.drag-upload-area.collapsed .uploaded-files {
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
|
|
|
|||
Loading…
Reference in New Issue