UnisMindMap/web_ui/src/views/DocumentProcessor.vue

975 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="document-processor">
<header class="top-header">
<h2 class="ellipsis" title="思维导图">思维导图</h2>
<el-button type="text" @click="toggleSettings" class="settings-button">
设置
</el-button>
</header>
<div class="main-content">
<div class="upload-section">
<div
class="drag-upload-area"
:class="{ 'drag-over': isDragging, collapsed: isUploadAreaCollapsed }"
@drop="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@click="triggerUpload"
>
<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>
<div class="uploaded-files">
<div class="file-item" v-for="(file, index) in uploadedFiles" :key="index">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ file.name }}</span>
<el-button
type="text"
:icon="Delete"
@click.stop="removeFile(index)"
class="remove-button"
/>
</div>
</div>
</div>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">
<h3 class="settings-title">设置</h3>
<el-button type="text" @click="toggleSettings" class="close-button">
<el-icon><Close /></el-icon>
</el-button>
</div>
<ConfigPanel
v-model="config"
:backend-options="backendOptions"
:language-options="languageOptions"
@backend-change="handleBackendChange"
/>
</div>
</div>
<div class="result-section">
<el-tabs v-model="activeTab" class="result-tabs">
<el-tab-pane name="markdown">
<template #label>
<el-dropdown trigger="hover" @command="handleMarkdownRenderModeChange">
<span class="source-tab-label">
{{ markdownRenderModeLabel }}
<el-icon class="source-tab-arrow"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="markdown">Markdown渲染</el-dropdown-item>
<el-dropdown-item command="html">HTML渲染</el-dropdown-item>
<el-dropdown-item command="pdf">PDF渲染</el-dropdown-item>
<el-dropdown-item command="richtext">富文本格式渲染</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
<el-tab-pane name="source">
<template #label>
<el-dropdown trigger="hover" @command="handleSourceViewModeChange">
<span class="source-tab-label">
{{ sourceViewMode === 'markdown' ? 'Markdown 源码' : 'AST抽象语法树' }}
<el-icon class="source-tab-arrow"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="markdown">Markdown 源码</el-dropdown-item>
<el-dropdown-item command="ast">AST抽象语法树</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
<el-tab-pane label="思维导图" name="mindmap" />
</el-tabs>
<div class="result-content">
<div v-if="isProcessing" class="loading-container">
<el-icon class="loading-icon"><Loading /></el-icon>
<p class="loading-text">正在处理文档...</p>
</div>
<div v-else-if="!results" class="empty-state">
<div class="empty-icon"></div>
<p class="empty-text">暂无转换结果</p>
</div>
<div v-else class="result-tab-content result-fade-in">
<div v-show="activeTab === 'markdown'" class="markdown-content">
<div class="markdown-box">
<MarkdownRenderer
:content="renderedMarkdownContent"
:mode="markdownRenderMode"
:flavor="markdownCompatibilityFlavor"
@update:mode="handleMarkdownRenderModeChange"
/>
</div>
</div>
<div v-show="activeTab === 'source'" class="source-content">
<div class="source-toolbar">
<el-button
:type="hasActiveReplacementRules ? 'primary' : 'default'"
class="replacement-config-button"
@click="openReplacementDialog"
>
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }}
</el-button>
<div v-if="templateRenderError" class="template-error-inline">
{{ templateRenderError }}
</div>
</div>
<div class="source-panel">
<el-input
v-model="sourcePanelContent"
type="textarea"
:rows="20"
:readonly="sourceViewMode === 'ast'"
:placeholder="sourceViewMode === 'markdown' ? '请在这里粘贴 Markdown 模板源码' : 'AST 内容将根据替换后的 Markdown 自动生成'"
class="source-textarea"
/>
</div>
</div>
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
<div class="mindmap-box">
<MindMapRenderer :content="renderedMindmapContent" />
</div>
</div>
</div>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
multiple
accept=".pdf,.doc,.docx,.png"
style="position: absolute; width: 0; height: 0; overflow: hidden;"
@change="handleFileInputChange"
/>
<el-dialog
v-model="showReplacementDialog"
title="动态模板渲染配置"
width="720px"
:fullscreen="isMobileDialog"
destroy-on-close
class="replacement-dialog"
>
<div class="replacement-dialog-body">
<div class="replacement-dialog-tip">
支持字符替换。保存后会实时作用到 Markdown 预览、AST、思维导图和导出结果。
</div>
<div class="replacement-rule-list">
<div v-for="(rule, index) in draftReplacementRules" :key="rule.id" class="replacement-rule-row">
<div class="replacement-rule-head">
<div class="replacement-rule-index">规则 {{ index + 1 }}</div>
</div>
<el-input v-model="rule.search" placeholder="被替换字符,例如:{{title}}" class="replacement-rule-input" />
<el-input v-model="rule.replace" placeholder="替换成,例如:项目演示标题" class="replacement-rule-input" />
<el-button class="replacement-delete-button" @click="removeDraftRule(index)">删除</el-button>
</div>
</div>
<el-button class="add-rule-button" @click="addDraftRule">新增替换规则</el-button>
</div>
<template #footer>
<div class="replacement-dialog-footer">
<el-button @click="resetDraftRules">重置</el-button>
<el-button @click="showReplacementDialog = false">取消</el-button>
<el-button type="primary" @click="saveReplacementRules">保存生效</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Delete, Document, Folder, Close, Loading, ArrowDown } from '@element-plus/icons-vue'
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 { markdownToAstString } from '@/utils/markdownAst'
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
const {
uploadedFiles,
config,
results,
isProcessing,
backendOptions,
languageOptions,
clearAll,
initializeManualResult,
handleFileUpload
} = useDocumentProcessor()
const showSettings = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false)
const activeTab = ref('markdown')
const isUploadAreaCollapsed = ref(false)
const markdownRenderMode = ref<'markdown' | 'html' | 'pdf' | 'richtext'>('markdown')
const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm')
const sourceViewMode = ref<'markdown' | 'ast'>('markdown')
const showReplacementDialog = ref(false)
const isMobileDialog = ref(false)
const replacementRules = ref<ReplacementRule[]>([])
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 templateRenderError = computed(() => templateRenderResult.value.error)
const activeReplacementRuleCount = computed(() => replacementRules.value.filter((rule) => rule.search.trim()).length)
const hasActiveReplacementRules = computed(() => activeReplacementRuleCount.value > 0)
const markdownRenderModeLabel = computed(() => {
const labels = {
markdown: 'Markdown渲染',
html: 'HTML渲染',
pdf: 'PDF渲染',
richtext: '富文本格式渲染'
}
return labels[markdownRenderMode.value]
})
const cloneRules = (rules: ReplacementRule[]) => rules.map((rule) => ({ ...rule }))
const updateViewportState = () => {
isMobileDialog.value = window.innerWidth <= 768
}
const sourcePanelContent = computed({
get: () => {
if (sourceViewMode.value === 'ast') {
return markdownToAstString(renderedMarkdownContent.value)
}
return manualMarkdownContent.value
},
set: (value: string) => {
if (sourceViewMode.value === 'markdown' && results.value) {
results.value.source = value
}
}
})
const toggleSettings = () => {
if (showSettings.value) {
const settingsPanel = document.querySelector('.settings-panel')
if (settingsPanel) {
settingsPanel.classList.remove('showing')
settingsPanel.classList.add('hiding')
setTimeout(() => {
showSettings.value = false
settingsPanel.classList.remove('hiding')
document.removeEventListener('click', handleClickOutside)
}, 400)
}
} else {
showSettings.value = true
setTimeout(() => {
const settingsPanel = document.querySelector('.settings-panel')
if (settingsPanel) {
settingsPanel.classList.add('showing')
}
document.addEventListener('click', handleClickOutside)
}, 10)
}
}
const handleClickOutside = (event: MouseEvent) => {
const settingsPanel = document.querySelector('.settings-panel')
const settingsButton = document.querySelector('.settings-button')
if (
settingsPanel &&
!settingsPanel.contains(event.target as Node) &&
settingsButton &&
!settingsButton.contains(event.target as Node)
) {
settingsPanel.classList.remove('showing')
settingsPanel.classList.add('hiding')
setTimeout(() => {
showSettings.value = false
settingsPanel.classList.remove('hiding')
document.removeEventListener('click', handleClickOutside)
}, 400)
}
}
const handleBackendChange = (backend: string) => {
console.log('Backend changed to:', backend)
}
const handleMarkdownRenderModeChange = (command: 'markdown' | 'html' | 'pdf' | 'richtext') => {
markdownRenderMode.value = command
activeTab.value = 'markdown'
}
const handleSourceViewModeChange = (command: string) => {
if (command === 'markdown' || command === 'ast') {
sourceViewMode.value = command
activeTab.value = 'source'
}
}
const openReplacementDialog = () => {
draftReplacementRules.value = cloneRules(replacementRules.value)
if (draftReplacementRules.value.length === 0) {
draftReplacementRules.value = [createEmptyReplacementRule()]
}
showReplacementDialog.value = true
}
const addDraftRule = () => {
draftReplacementRules.value.push(createEmptyReplacementRule())
}
const removeDraftRule = (index: number) => {
draftReplacementRules.value.splice(index, 1)
if (draftReplacementRules.value.length === 0) {
draftReplacementRules.value.push(createEmptyReplacementRule())
}
}
const resetDraftRules = () => {
draftReplacementRules.value = [createEmptyReplacementRule()]
}
const saveReplacementRules = () => {
replacementRules.value = cloneRules(draftReplacementRules.value).filter((rule) => rule.search.trim() || rule.replace.trim())
showReplacementDialog.value = false
ElMessage.success(replacementRules.value.length > 0 ? '替换规则已保存并生效' : '已清空替换规则')
}
const triggerUpload = () => {
fileInput.value?.click()
}
const resetReplacementConfig = () => {
replacementRules.value = []
draftReplacementRules.value = []
}
const handleFileInputChange = async (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files) {
await handleFileUpload(input.files)
input.value = ''
if (uploadedFiles.value.length > 0) {
isUploadAreaCollapsed.value = true
initializeManualResult()
resetReplacementConfig()
markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown'
activeTab.value = 'source'
}
}
}
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
if (event.dataTransfer?.files) {
await handleFileUpload(event.dataTransfer.files)
if (uploadedFiles.value.length > 0) {
isUploadAreaCollapsed.value = true
initializeManualResult()
resetReplacementConfig()
markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown'
activeTab.value = 'source'
}
}
}
const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1)
if (uploadedFiles.value.length === 0) {
isUploadAreaCollapsed.value = false
}
}
const clearAllFiles = () => {
clearAll()
resetReplacementConfig()
isUploadAreaCollapsed.value = false
}
onMounted(() => {
updateViewportState()
window.addEventListener('resize', updateViewportState)
})
onUnmounted(() => {
window.removeEventListener('resize', updateViewportState)
})
</script>
<style scoped>
.document-processor {
height: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #F8F9FA;
margin: 0;
padding: 0;
box-sizing: border-box;
}
.top-header {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: transparent;
border-bottom: none;
}
.ellipsis {
font-size: 18px;
font-weight: 500;
color: #343A40;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.settings-button {
color: #6C757D;
font-size: 14px;
padding: 4px 12px;
}
.settings-button:hover {
color: #165DFF;
}
.main-content {
flex: 1;
padding: 24px;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.upload-section {
margin-bottom: 16px;
flex-shrink: 0;
}
.drag-upload-area {
border: 2px dashed #CED4DA;
border-radius: 8px;
padding: 20px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #FFFFFF;
margin-bottom: 16px;
overflow: hidden;
}
.drag-upload-area.collapsed {
padding: 8px 16px;
min-height: 36px;
max-height: 60px;
display: block;
}
.drag-upload-area:hover,
.drag-upload-area.drag-over {
border-color: #165DFF;
background-color: #F8F9FF;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.upload-icon {
font-size: 48px;
color: #165DFF;
}
.upload-text {
font-size: 16px;
font-weight: 500;
color: #343A40;
}
.upload-hint {
font-size: 14px;
color: #6C757D;
line-height: 1.5;
max-width: 400px;
}
.uploaded-files {
margin-top: 24px;
text-align: left;
}
.drag-upload-area.collapsed .uploaded-files {
margin-top: 0;
width: 100%;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background-color: #F8F9FA;
border-radius: 4px;
margin-bottom: 2px;
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
font-size: 13px;
}
.file-item:hover {
background-color: #E9ECEF;
}
.file-icon {
font-size: 18px;
color: #165DFF;
}
.file-name {
flex: 1;
font-size: 13px;
color: #343A40;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - 40px);
}
.remove-button {
color: #6C757D;
padding: 4px;
}
.remove-button:hover {
color: #DC3545;
}
.settings-panel {
position: fixed;
top: 0;
right: 0;
width: 320px;
height: 100vh;
background-color: #FFFFFF;
border-left: 1px solid #E9ECEF;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1);
padding: 24px;
z-index: 1000;
transform: translateX(100%);
opacity: 0;
transition: transform 0.3s ease, opacity 0.4s ease;
overflow-y: auto;
}
.settings-panel.showing {
transform: translateX(0);
opacity: 1;
}
.settings-panel.hiding {
transform: translateX(100%);
opacity: 0;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #E9ECEF;
}
.close-button {
color: #6C757D;
padding: 4px;
font-size: 16px;
}
.close-button:hover {
color: #343A40;
}
.settings-title {
font-size: 16px;
font-weight: 500;
color: #343A40;
margin: 0;
}
.result-section {
background-color: #FFFFFF;
border: 1px solid #E9ECEF;
border-radius: 8px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.result-tabs {
border-bottom: 1px solid #E9ECEF;
}
.result-tabs :deep(.el-tabs__nav) {
padding-left: 16px;
}
.result-tabs :deep(.el-tabs__item) {
height: 40px;
line-height: 40px;
padding: 0 16px;
margin-right: 0;
color: #6C757D;
font-size: 14px;
font-weight: 400;
transition: all 0.3s ease;
}
.result-tabs :deep(.el-tabs__item:hover) {
color: #165DFF;
}
.result-tabs :deep(.el-tabs__item.is-active) {
color: #165DFF;
font-weight: 500;
}
.result-tabs :deep(.el-tabs__active-bar) {
background-color: #165DFF;
height: 2px;
}
.source-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.source-tab-arrow {
font-size: 12px;
}
.result-content {
flex: 1;
min-height: 0;
padding: 16px;
display: flex;
flex-direction: column;
}
.loading-container,
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 400px;
gap: 16px;
}
.loading-icon {
font-size: 48px;
color: #165DFF;
animation: spin 1s linear infinite;
}
.loading-text,
.empty-text {
font-size: 14px;
color: #6C757D;
margin: 0;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-icon {
width: 120px;
height: 120px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Crect x='20' y='30' width='80' height='60' rx='4' fill='%23F8F9FA' stroke='%23E9ECEF' stroke-width='2'/%3E%3Crect x='40' y='15' width='40' height='20' rx='2' fill='%23F8F9FA' stroke='%23E9ECEF' stroke-width='2'/%3E%3Crect x='30' y='40' width='60' height='10' rx='2' fill='%23E9ECEF'/%3E%3Crect x='30' y='55' width='50' height='8' rx='2' fill='%23E9ECEF'/%3E%3Crect x='30' y='70' width='40' height='8' rx='2' fill='%23E9ECEF'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
}
.result-tab-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.result-fade-in {
animation: fadeInScale 0.5s ease-out forwards;
}
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.markdown-content,
.mindmap-content {
flex: 1;
min-height: 0;
}
.markdown-box {
height: 100%;
min-height: 0;
border: 1px solid #E9ECEF;
border-radius: 4px;
box-shadow: none;
padding: 16px;
overflow-y: auto;
overflow-x: hidden;
background-color: white;
}
.mindmap-box {
height: 100%;
min-height: 0;
border: 1px solid #E9ECEF;
border-radius: 4px;
box-shadow: none;
overflow: hidden;
background-color: white;
}
.source-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.source-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.replacement-config-button {
border-radius: 6px;
}
.template-error-inline {
color: #CF1322;
font-size: 13px;
line-height: 1.5;
}
.source-panel {
flex: 1;
min-height: 0;
}
.source-textarea {
height: 100%;
min-height: 0;
}
.source-textarea :deep(.el-textarea__wrapper) {
border: 1px solid #E9ECEF;
border-radius: 4px;
box-shadow: none;
height: 100%;
min-height: 0;
}
.source-textarea :deep(.el-textarea__inner) {
height: 100%;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
color: #495057;
padding: 16px;
}
.replacement-dialog-body {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 70vh;
overflow-y: auto;
padding-right: 4px;
}
.replacement-dialog-tip {
padding: 10px 12px;
border-radius: 8px;
background: #F8FAFF;
border: 1px solid #DCE7FF;
color: #4E5969;
font-size: 13px;
line-height: 1.6;
}
.replacement-rule-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.replacement-rule-row {
display: grid;
grid-template-columns: 120px 1fr 1fr 72px;
gap: 12px;
align-items: center;
}
.replacement-rule-head {
display: flex;
align-items: center;
min-height: 32px;
}
.replacement-rule-index {
font-size: 13px;
color: #4E5969;
}
.replacement-delete-button {
border: 1px solid #D0D5DD;
border-radius: 6px;
padding: 0 12px;
height: 32px;
}
.replacement-rule-input {
width: 100%;
}
.add-rule-button {
align-self: flex-start;
}
.replacement-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 768px) {
.main-content,
.result-content {
padding: 16px;
}
.top-header {
padding: 12px 16px;
}
.drag-upload-area {
padding: 32px 16px;
}
.upload-icon {
font-size: 32px;
}
.upload-text {
font-size: 14px;
}
.upload-hint {
font-size: 12px;
}
.result-tabs :deep(.el-tabs__nav) {
padding-left: 16px;
}
.result-tabs :deep(.el-tabs__item) {
padding: 0 16px;
font-size: 12px;
}
.source-toolbar {
flex-direction: column;
align-items: stretch;
}
.replacement-dialog-body {
max-height: none;
padding-right: 0;
}
.replacement-rule-row {
grid-template-columns: 1fr;
gap: 10px;
}
.replacement-rule-head {
justify-content: space-between;
}
.replacement-delete-button {
width: 100%;
}
.replacement-dialog-footer {
flex-direction: column;
align-items: stretch;
}
.replacement-dialog-footer :deep(.el-button) {
margin-left: 0 !important;
}
}
</style>