feat(思维导图助手):修复问题
parent
a999121ff8
commit
09f7a0bee5
|
|
@ -6,7 +6,7 @@
|
||||||
<!-- 最大页数 -->
|
<!-- 最大页数 -->
|
||||||
<el-form-item :label="$t('config.maxPages')" class="form-item">
|
<el-form-item :label="$t('config.maxPages')" class="form-item">
|
||||||
<el-slider
|
<el-slider
|
||||||
v-model="config.maxPages"
|
v-model="draftConfig.maxPages"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="1000"
|
:max="1000"
|
||||||
show-input
|
show-input
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<!-- 解析后端 -->
|
<!-- 解析后端 -->
|
||||||
<el-form-item :label="$t('config.backend')" class="form-item">
|
<el-form-item :label="$t('config.backend')" class="form-item">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="config.backend"
|
v-model="draftConfig.backend"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onBackendChange"
|
@change="onBackendChange"
|
||||||
class="select"
|
class="select"
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="form-item-description">
|
<div class="form-item-description">
|
||||||
{{ getBackendDescription(config.backend) }}
|
{{ getBackendDescription(draftConfig.backend) }}
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
class="form-item"
|
class="form-item"
|
||||||
>
|
>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="config.serverUrl"
|
v-model="draftConfig.serverUrl"
|
||||||
:placeholder="'http://localhost:30000'"
|
:placeholder="'http://localhost:30000'"
|
||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
|
|
||||||
<!-- 表格识别 -->
|
<!-- 表格识别 -->
|
||||||
<el-form-item class="form-item">
|
<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') }}
|
{{ $t('config.tableEnable') }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<div class="form-item-description">
|
<div class="form-item-description">
|
||||||
|
|
@ -67,11 +67,11 @@
|
||||||
|
|
||||||
<!-- 公式识别 -->
|
<!-- 公式识别 -->
|
||||||
<el-form-item class="form-item">
|
<el-form-item class="form-item">
|
||||||
<el-checkbox v-model="config.formulaEnable" class="checkbox">
|
<el-checkbox v-model="draftConfig.formulaEnable" class="checkbox">
|
||||||
{{ getFormulaLabel(config.backend) }}
|
{{ getFormulaLabel(draftConfig.backend) }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<div class="form-item-description">
|
<div class="form-item-description">
|
||||||
{{ getFormulaInfo(config.backend) }}
|
{{ getFormulaInfo(draftConfig.backend) }}
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
|
|
||||||
<!-- OCR语言 -->
|
<!-- OCR语言 -->
|
||||||
<el-form-item :label="$t('config.ocrLanguage')" class="form-item">
|
<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
|
<el-option
|
||||||
v-for="option in languageOptions"
|
v-for="option in languageOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
|
|
||||||
<!-- 强制OCR -->
|
<!-- 强制OCR -->
|
||||||
<el-form-item class="form-item">
|
<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') }}
|
{{ $t('config.forceOcr') }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<div class="form-item-description">
|
<div class="form-item-description">
|
||||||
|
|
@ -104,12 +104,17 @@
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<div class="config-actions">
|
||||||
|
<el-button @click="resetDraft">重置</el-button>
|
||||||
|
<el-button type="primary" @click="confirmConfig">确认</el-button>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import type { DocumentConfig } from '@/composables/useDocumentProcessor'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -126,21 +131,42 @@ interface Emits {
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const config = computed({
|
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
|
||||||
get: () => props.modelValue,
|
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
|
||||||
set: (value) => emit('update:modelValue', value)
|
|
||||||
})
|
const syncDraft = (value: DocumentConfig) => {
|
||||||
|
Object.assign(draftConfig, createDraft(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => syncDraft(value),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
const showServerUrl = computed(() => {
|
const showServerUrl = computed(() => {
|
||||||
return props.modelValue.backend.includes('http-client')
|
return draftConfig.backend.includes('http-client')
|
||||||
})
|
})
|
||||||
|
|
||||||
const isVlmBackend = computed(() => {
|
const isVlmBackend = computed(() => {
|
||||||
return props.modelValue.backend.startsWith('vlm')
|
return draftConfig.backend.startsWith('vlm')
|
||||||
})
|
})
|
||||||
|
|
||||||
const onBackendChange = (backend: string) => {
|
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) => {
|
const getFormulaLabel = (backend: string) => {
|
||||||
|
|
@ -224,6 +250,14 @@ const getBackendDescription = (backend: string) => {
|
||||||
line-height: 1.4;
|
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) {
|
.slider :deep(.el-slider__runway) {
|
||||||
background-color: #E4E7ED;
|
background-color: #E4E7ED;
|
||||||
}
|
}
|
||||||
|
|
@ -270,4 +304,4 @@ const getBackendDescription = (backend: string) => {
|
||||||
.checkbox :deep(.el-checkbox__label) {
|
.checkbox :deep(.el-checkbox__label) {
|
||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item>
|
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item>
|
||||||
<el-dropdown-item command="png">PNG 格式</el-dropdown-item>
|
<el-dropdown-item command="png">PNG 格式</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="jpeg">JPEG 格式</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
|
@ -33,20 +34,25 @@
|
||||||
重置视图
|
重置视图
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mindmap-content" @wheel.prevent="handleWheel">
|
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
|
||||||
<div class="empty-state" v-if="!content">
|
<div class="empty-state" v-if="!content">
|
||||||
<el-icon class="empty-icon"><Document /></el-icon>
|
<el-icon class="empty-icon"><Document /></el-icon>
|
||||||
<p class="empty-text">暂无思维导图内容</p>
|
<p class="empty-text">暂无思维导图内容</p>
|
||||||
<p class="empty-subtext">请先上传并转换文档</p>
|
<p class="empty-subtext">请先上传并转换文档</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
|
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 相关包
|
// 导入 markmap 相关包
|
||||||
import { Transformer } from 'markmap-lib'
|
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)
|
const svgRef = ref<SVGElement | null>(null)
|
||||||
let mmInstance: any = null
|
let mmInstance: any = null
|
||||||
|
let renderTimer: number | undefined
|
||||||
const transformer = new Transformer()
|
const transformer = new Transformer()
|
||||||
const scale = ref(1)
|
const scale = ref(1)
|
||||||
|
const isRendering = ref(false)
|
||||||
|
|
||||||
const nodeCount = computed(() => {
|
const nodeCount = computed(() => {
|
||||||
if (!props.content) return 0
|
if (!props.content) return 0
|
||||||
// 简单计算节点数量(基于Markdown标题)
|
|
||||||
return (props.content.match(/^#{1,6}\s+/gm) || []).length
|
return (props.content.match(/^#{1,6}\s+/gm) || []).length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -82,60 +93,56 @@ const initMarkmap = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
isRendering.value = true
|
||||||
console.log('Initializing markmap with content:', props.content.substring(0, 100) + '...')
|
|
||||||
|
|
||||||
// 1. 转换数据
|
|
||||||
const { root, features } = transformer.transform(props.content)
|
|
||||||
console.log('Transformed root:', root)
|
|
||||||
|
|
||||||
// 2. 加载必要的资源
|
try {
|
||||||
|
const { root } = transformer.transform(props.content)
|
||||||
const { styles, scripts } = transformer.getAssets()
|
const { styles, scripts } = transformer.getAssets()
|
||||||
console.log('Styles:', styles)
|
|
||||||
console.log('Scripts:', scripts)
|
|
||||||
|
|
||||||
if (styles) loadCSS(styles)
|
if (styles) loadCSS(styles)
|
||||||
if (scripts) loadJS(scripts)
|
if (scripts) loadJS(scripts)
|
||||||
|
|
||||||
// 3. 创建或更新实例
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
console.log('SVG ref:', svgRef.value)
|
|
||||||
console.log('Markmap.create:', Markmap.create)
|
const initialExpandLevel = nodeCount.value > 80 ? 2 : -1
|
||||||
|
|
||||||
if (mmInstance) {
|
if (mmInstance) {
|
||||||
console.log('Updating existing instance')
|
|
||||||
if (typeof mmInstance.setData === 'function') {
|
if (typeof mmInstance.setData === 'function') {
|
||||||
mmInstance.setData(root)
|
mmInstance.setData(root)
|
||||||
mmInstance.fit()
|
mmInstance.fit()
|
||||||
} else {
|
} else {
|
||||||
console.error('mmInstance does not have setData method:', mmInstance)
|
|
||||||
// 创建新实例
|
|
||||||
mmInstance = Markmap.create(svgRef.value, {
|
mmInstance = Markmap.create(svgRef.value, {
|
||||||
autoFit: true,
|
autoFit: true,
|
||||||
fitRatio: 0.9,
|
fitRatio: 0.9,
|
||||||
initialExpandLevel: -1
|
initialExpandLevel
|
||||||
}, root)
|
}, root)
|
||||||
console.log('Created new instance after setData error:', mmInstance)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Creating new instance')
|
|
||||||
mmInstance = Markmap.create(svgRef.value, {
|
mmInstance = Markmap.create(svgRef.value, {
|
||||||
autoFit: true,
|
autoFit: true,
|
||||||
fitRatio: 0.9,
|
fitRatio: 0.9,
|
||||||
initialExpandLevel: -1
|
initialExpandLevel
|
||||||
}, root)
|
}, 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) {
|
} catch (error) {
|
||||||
console.error('Error initializing markmap:', error)
|
console.error('Error initializing markmap:', error)
|
||||||
mmInstance = null
|
mmInstance = null
|
||||||
|
} finally {
|
||||||
|
isRendering.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听内容变化自动重绘
|
// 监听内容变化自动重绘
|
||||||
watch(() => props.content, () => {
|
watch(() => props.content, () => {
|
||||||
initMarkmap()
|
window.clearTimeout(renderTimer)
|
||||||
|
renderTimer = window.setTimeout(() => {
|
||||||
|
initMarkmap()
|
||||||
|
}, 160)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -145,6 +152,7 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.clearTimeout(renderTimer)
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
if (mmInstance && typeof mmInstance.destroy === 'function') {
|
if (mmInstance && typeof mmInstance.destroy === 'function') {
|
||||||
mmInstance.destroy()
|
mmInstance.destroy()
|
||||||
|
|
@ -157,111 +165,87 @@ const handleResize = () => {
|
||||||
mmInstance?.fit()
|
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') => {
|
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
||||||
if (!svgRef.value) return
|
const exportSvg = createExportSvg()
|
||||||
|
if (!exportSvg) return
|
||||||
// 确保思维导图已经完全渲染
|
|
||||||
if (mmInstance) {
|
const time = new Date().getTime()
|
||||||
mmInstance.fit()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
if (format === 'svg') {
|
||||||
const svg = svgRef.value
|
downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${time}.svg`)
|
||||||
// 复制 SVG 元素以确保获取完整结构
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const handleDownload = (command: string) => {
|
||||||
if (command === 'svg' || command === 'png') {
|
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
||||||
downloadMindMap(command)
|
downloadMindMap(command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -306,6 +290,27 @@ const handleWheel = (event: WheelEvent) => {
|
||||||
applyZoom()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -392,6 +397,28 @@ const handleWheel = (event: WheelEvent) => {
|
||||||
gap: 16px;
|
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 {
|
.empty-icon {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
color: #C9CDD4;
|
color: #C9CDD4;
|
||||||
|
|
@ -416,6 +443,10 @@ const handleWheel = (event: WheelEvent) => {
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markmap-svg :deep(text[data-editable-node="true"]) {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mindmap-header {
|
.mindmap-header {
|
||||||
|
|
|
||||||
|
|
@ -2,55 +2,11 @@ import { ref, reactive } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { documentApi, type ParseParams } from '@/api/document'
|
import { documentApi, type ParseParams } from '@/api/document'
|
||||||
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
|
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
|
||||||
|
import { buildMindmapMarkdown } from '@/utils/mindmapMarkdown'
|
||||||
|
|
||||||
// 根据上一级标题自动补全下一级标题
|
// 根据上一级标题自动补全下一级标题
|
||||||
export function autoPromoteParagraphsToSubheading(text: string): string {
|
export function autoPromoteParagraphsToSubheading(text: string): string {
|
||||||
const lines = text.split('\n')
|
return buildMindmapMarkdown(text)
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentConfig {
|
export interface DocumentConfig {
|
||||||
|
|
@ -89,6 +45,7 @@ export function useDocumentProcessor() {
|
||||||
// 结果相关
|
// 结果相关
|
||||||
const results = ref<ProcessResult | null>(null)
|
const results = ref<ProcessResult | null>(null)
|
||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
|
const processingStage = ref('')
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// 后端选项
|
// 后端选项
|
||||||
|
|
@ -185,6 +142,7 @@ export function useDocumentProcessor() {
|
||||||
uploadedFiles.value = []
|
uploadedFiles.value = []
|
||||||
results.value = null
|
results.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
|
processingStage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 演示模式:上传文件后由用户手动粘贴 Markdown 源码
|
// 演示模式:上传文件后由用户手动粘贴 Markdown 源码
|
||||||
|
|
@ -206,8 +164,10 @@ export function useDocumentProcessor() {
|
||||||
|
|
||||||
isProcessing.value = true
|
isProcessing.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
processingStage.value = '准备提交解析任务'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
processingStage.value = '提交文档到解析服务'
|
||||||
const params: ParseParams = {
|
const params: ParseParams = {
|
||||||
files: uploadedFiles.value,
|
files: uploadedFiles.value,
|
||||||
output_dir: './output',
|
output_dir: './output',
|
||||||
|
|
@ -227,22 +187,25 @@ export function useDocumentProcessor() {
|
||||||
params.server_url = config.serverUrl
|
params.server_url = config.serverUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processingStage.value = '服务端正在解析文档'
|
||||||
const response = await documentApi.parseDocument(params)
|
const response = await documentApi.parseDocument(params)
|
||||||
|
|
||||||
if (response.results) {
|
if (response.results) {
|
||||||
|
processingStage.value = '生成 Markdown 和思维导图'
|
||||||
const resultData = Object.values(response.results)[0]
|
const resultData = Object.values(response.results)[0]
|
||||||
const mdContent = resultData.md_content || ''
|
const mdContent = resultData.md_content || ''
|
||||||
const processedSource = autoPromoteParagraphsToSubheading(mdContent)
|
const mindmapContent = buildMindmapMarkdown(mdContent)
|
||||||
results.value = {
|
results.value = {
|
||||||
markdown: mdContent,
|
markdown: mdContent,
|
||||||
source: processedSource,
|
source: mdContent,
|
||||||
mindmap: processedSource
|
mindmap: mindmapContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message || '转换失败'
|
error.value = err.message || '转换失败'
|
||||||
} finally {
|
} finally {
|
||||||
|
processingStage.value = ''
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +241,7 @@ export function useDocumentProcessor() {
|
||||||
results,
|
results,
|
||||||
isUploading,
|
isUploading,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
processingStage,
|
||||||
error,
|
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">
|
<div class="upload-content" v-show="!isUploadAreaCollapsed">
|
||||||
<el-icon class="upload-icon"><Folder /></el-icon>
|
<el-icon class="upload-icon"><Folder /></el-icon>
|
||||||
<div class="upload-text">文件导入</div>
|
<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>
|
||||||
|
|
||||||
<div class="uploaded-files">
|
<div class="uploaded-files">
|
||||||
|
|
@ -35,6 +35,13 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div v-if="showSettings" class="settings-panel">
|
<div v-if="showSettings" class="settings-panel">
|
||||||
|
|
@ -97,7 +104,7 @@
|
||||||
<div class="result-content">
|
<div class="result-content">
|
||||||
<div v-if="isProcessing" class="loading-container">
|
<div v-if="isProcessing" class="loading-container">
|
||||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
<p class="loading-text">正在处理文档...</p>
|
<p class="loading-text">{{ processingStage || '正在处理文档...' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!results" class="empty-state">
|
<div v-else-if="!results" class="empty-state">
|
||||||
|
|
@ -145,7 +152,7 @@
|
||||||
|
|
||||||
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
||||||
<div class="mindmap-box">
|
<div class="mindmap-box">
|
||||||
<MindMapRenderer :content="renderedMindmapContent" />
|
<MindMapRenderer :content="renderedMindmapContent" @node-edit="handleMindmapNodeEdit" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,20 +214,25 @@ import { ElMessage } from 'element-plus'
|
||||||
import ConfigPanel from '@/components/ConfigPanel.vue'
|
import ConfigPanel from '@/components/ConfigPanel.vue'
|
||||||
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
||||||
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
||||||
import { autoPromoteParagraphsToSubheading, useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
import { useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
||||||
import { markdownToAstString } from '@/utils/markdownAst'
|
import { markdownToAstString } from '@/utils/markdownAst'
|
||||||
|
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
||||||
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
uploadedFiles,
|
uploadedFiles,
|
||||||
config,
|
config,
|
||||||
results,
|
results,
|
||||||
|
isUploading,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
processingStage,
|
||||||
|
error,
|
||||||
backendOptions,
|
backendOptions,
|
||||||
languageOptions,
|
languageOptions,
|
||||||
clearAll,
|
clearAll,
|
||||||
initializeManualResult,
|
initializeManualResult,
|
||||||
handleFileUpload
|
handleFileUpload,
|
||||||
|
processDocument
|
||||||
} = useDocumentProcessor()
|
} = useDocumentProcessor()
|
||||||
|
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
|
@ -239,7 +251,7 @@ const draftReplacementRules = ref<ReplacementRule[]>([])
|
||||||
const manualMarkdownContent = computed(() => results.value?.source || '')
|
const manualMarkdownContent = computed(() => results.value?.source || '')
|
||||||
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
|
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
|
||||||
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
|
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 templateRenderError = computed(() => templateRenderResult.value.error)
|
||||||
const activeReplacementRuleCount = computed(() => replacementRules.value.filter((rule) => rule.search.trim()).length)
|
const activeReplacementRuleCount = computed(() => replacementRules.value.filter((rule) => rule.search.trim()).length)
|
||||||
const hasActiveReplacementRules = computed(() => activeReplacementRuleCount.value > 0)
|
const hasActiveReplacementRules = computed(() => activeReplacementRuleCount.value > 0)
|
||||||
|
|
@ -322,6 +334,19 @@ const handleBackendChange = (backend: string) => {
|
||||||
console.log('Backend changed to:', backend)
|
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') => {
|
const handleMarkdownRenderModeChange = (command: 'markdown' | 'html' | 'pdf' | 'richtext') => {
|
||||||
markdownRenderMode.value = command
|
markdownRenderMode.value = command
|
||||||
activeTab.value = 'markdown'
|
activeTab.value = 'markdown'
|
||||||
|
|
@ -363,6 +388,18 @@ const saveReplacementRules = () => {
|
||||||
ElMessage.success(replacementRules.value.length > 0 ? '替换规则已保存并生效' : '已清空替换规则')
|
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 = () => {
|
const triggerUpload = () => {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
@ -539,6 +576,13 @@ onUnmounted(() => {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.drag-upload-area.collapsed .uploaded-files {
|
.drag-upload-area.collapsed .uploaded-files {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue