feat(思维导图助手):修复问题

master
panyy 2026-06-11 11:26:43 +08:00
parent a999121ff8
commit 09f7a0bee5
5 changed files with 452 additions and 198 deletions

View File

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

View File

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

View File

@ -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,
// 选项

View File

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

View File

@ -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">支持PDFWordPNG格式文件上传后可手动粘贴 Markdown 源码用于演示</div>
<div class="upload-hint">支持 PDFWordPNG 格式文件可转换为 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%;