feat(思维导图助手):测试优化功能页面

master
panyy 2026-04-02 14:50:20 +08:00
parent e5d281a7da
commit a999121ff8
20 changed files with 1729 additions and 725 deletions

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

28
web_ui/dist/index.html vendored 100644
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>思维导图</title>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
height: 100%;
}
</style>
<script type="module" crossorigin src="/assets/index-DnACqG0E.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-DwrfALIr.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-_Dy2zPzC.js">
<link rel="stylesheet" crossorigin href="/assets/index-HnN589X2.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

2
web_ui/dist/markmap/d3.min.js vendored 100644

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,7 @@
"build": "^0.1.4",
"d3": "^7.9.0",
"element-plus": "^2.4.0",
"handlebars": "^4.7.9",
"markdown-it": "^14.0.0",
"markmap-lib": "^0.18.12",
"markmap-toolbar": "^0.18.12",
@ -2261,6 +2262,40 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmmirror.com/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/handlebars/node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmmirror.com/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -2684,6 +2719,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/moo-server": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/moo-server/-/moo-server-1.3.0.tgz",
@ -2723,6 +2767,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
@ -2995,6 +3045,15 @@
"node": ">=10"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3299,6 +3358,12 @@
"node": ">= 12.0.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrench": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/wrench/-/wrench-1.3.9.tgz",

View File

@ -13,6 +13,7 @@
"build": "^0.1.4",
"d3": "^7.9.0",
"element-plus": "^2.4.0",
"handlebars": "^4.7.9",
"markdown-it": "^14.0.0",
"markmap-lib": "^0.18.12",
"markmap-toolbar": "^0.18.12",

View File

@ -1,6 +1,66 @@
<template>
<div class="markdown-renderer">
<div v-if="content" class="markdown-content" v-html="renderedContent"></div>
<template v-if="content">
<div class="render-actions">
<div class="render-actions-left">
<el-segmented
v-if="mode === 'markdown'"
v-model="activeFlavor"
:options="flavorOptions"
size="small"
/>
<el-segmented
v-else-if="mode === 'richtext'"
v-model="activeRichtextFormat"
:options="richtextOptions"
size="small"
/>
</div>
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
<template #icon>
<el-icon><Download /></el-icon>
</template>
下载
</el-button>
</div>
<div class="render-content">
<div v-if="mode === 'markdown'" class="render-shell">
<div ref="markdownRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
</div>
<div v-else-if="mode === 'html'" class="render-shell">
<div ref="htmlRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
</div>
<div v-else-if="mode === 'pdf'" class="render-shell">
<div class="pdf-stage">
<div ref="pdfRef" class="pdf-preview-pages">
<div
v-for="(page, index) in pdfPages"
:key="`pdf-page-${index}`"
class="pdf-page"
>
<div class="markdown-content pdf-content" v-html="page"></div>
</div>
</div>
</div>
<div
ref="pdfMeasureRef"
class="pdf-measure markdown-content pdf-content"
v-html="renderedContent"
></div>
</div>
<div v-else class="render-shell">
<div class="richtext-stage">
<article ref="richtextRef" class="richtext-document">
<div class="markdown-content richtext-content" v-html="renderedContent"></div>
</article>
</div>
</div>
</div>
</template>
<div v-else class="empty-state">
<el-empty :description="$t('results.noResults')" />
</div>
@ -8,114 +68,477 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import type { PropType } from 'vue'
import { Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { loadHtml2Pdf } from '@/utils/wordToPdf'
type RenderMode = 'markdown' | 'html' | 'pdf' | 'richtext'
type FlavorMode = 'commonmark' | 'gfm'
type RichtextFormat = 'word' | 'rtf'
const props = defineProps({
content: {
type: String as PropType<string>,
default: ''
},
mode: {
type: String as PropType<RenderMode>,
default: 'markdown'
},
flavor: {
type: String as PropType<FlavorMode>,
default: 'gfm'
}
})
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true
})
const activeFlavor = ref<FlavorMode>(props.flavor)
const activeRichtextFormat = ref<RichtextFormat>('word')
const markdownRef = ref<HTMLElement | null>(null)
const htmlRef = ref<HTMLElement | null>(null)
const pdfRef = ref<HTMLElement | null>(null)
const pdfMeasureRef = ref<HTMLElement | null>(null)
const richtextRef = ref<HTMLElement | null>(null)
const pdfPages = ref<string[]>([])
// LaTeX
md.use(function (md) {
// $$...$$
md.inline.ruler.after('escape', 'math_inline', function (state, silent) {
let start = state.pos
if (state.src.charCodeAt(start) !== 0x24/* $ */) return false
if (state.src.charCodeAt(start + 1) !== 0x24/* $ */) return false
watch(
() => props.flavor,
(value) => {
activeFlavor.value = value
}
)
let pos = start + 2
let found = false
const flavorOptions = [
{ label: 'CommonMark', value: 'commonmark' },
{ label: 'GFM', value: 'gfm' }
]
while (pos < state.posMax) {
if (state.src.charCodeAt(pos) === 0x24/* $ */ &&
state.src.charCodeAt(pos + 1) === 0x24/* $ */) {
found = true
break
const richtextOptions = [
{ label: 'Word', value: 'word' },
{ label: 'RTF', value: 'rtf' }
]
function applyMathSupport(md: MarkdownIt) {
md.use(function (md) {
md.inline.ruler.after('escape', 'math_inline', function (state, silent) {
const start = state.pos
if (state.src.charCodeAt(start) !== 0x24) return false
if (state.src.charCodeAt(start + 1) !== 0x24) return false
let pos = start + 2
let found = false
while (pos < state.posMax) {
if (state.src.charCodeAt(pos) === 0x24 && state.src.charCodeAt(pos + 1) === 0x24) {
found = true
break
}
pos++
}
pos++
}
if (!found) return false
if (!found) return false
if (!silent) {
const token = state.push('math_inline', 'span', 0)
token.content = state.src.slice(start + 2, pos)
token.markup = '$$'
}
if (!silent) {
const token = state.push('math_inline', 'span', 0)
token.content = state.src.slice(start + 2, pos)
token.markup = '$$'
}
state.pos = pos + 2
return true
})
state.pos = pos + 2
return true
md.inline.ruler.after('escape', 'math_inline_single', function (state, silent) {
const start = state.pos
if (state.src.charCodeAt(start) !== 0x24) return false
if (state.src.charCodeAt(start + 1) === 0x24) return false
let pos = start + 1
let found = false
while (pos < state.posMax) {
if (state.src.charCodeAt(pos) === 0x24) {
found = true
break
}
pos++
}
if (!found) return false
if (!silent) {
const token = state.push('math_inline_single', 'span', 0)
token.content = state.src.slice(start + 1, pos)
token.markup = '$'
}
state.pos = pos + 1
return true
})
})
// $...$
md.inline.ruler.after('escape', 'math_inline_single', function (state, silent) {
let start = state.pos
if (state.src.charCodeAt(start) !== 0x24/* $ */) return false
if (state.src.charCodeAt(start + 1) === 0x24/* $ */) return false // $$
md.renderer.rules.math_inline = function(tokens, idx) {
return `<span class="math-inline">$$${tokens[idx].content}$$</span>`
}
let pos = start + 1
let found = false
while (pos < state.posMax) {
if (state.src.charCodeAt(pos) === 0x24/* $ */) {
found = true
break
}
pos++
}
if (!found) return false
if (!silent) {
const token = state.push('math_inline_single', 'span', 0)
token.content = state.src.slice(start + 1, pos)
token.markup = '$'
}
state.pos = pos + 1
return true
})
})
//
md.renderer.rules.math_inline = function(tokens, idx) {
return `<span class="math-inline">$$${tokens[idx].content}$$</span>`
md.renderer.rules.math_inline_single = function(tokens, idx) {
return `<span class="math-inline">$${tokens[idx].content}$</span>`
}
}
md.renderer.rules.math_inline_single = function(tokens, idx) {
return `<span class="math-inline">$${tokens[idx].content}$</span>`
function createMarkdownRenderer(flavor: FlavorMode) {
const md = flavor === 'commonmark'
? new MarkdownIt('commonmark', {
html: true,
linkify: true,
typographer: true,
breaks: false
})
: new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true
})
applyMathSupport(md)
return md
}
const renderedContent = computed(() => {
if (!props.content) return ''
return md.render(props.content)
const flavor = props.mode === 'markdown' ? activeFlavor.value : 'gfm'
const renderer = createMarkdownRenderer(flavor)
return renderer.render(props.content)
})
async function recalculatePdfPages() {
if (props.mode !== 'pdf') {
pdfPages.value = []
return
}
await nextTick()
const measureEl = pdfMeasureRef.value
if (!measureEl) {
pdfPages.value = renderedContent.value ? [renderedContent.value] : []
return
}
const children = Array.from(measureEl.children) as HTMLElement[]
if (children.length === 0) {
pdfPages.value = renderedContent.value ? [renderedContent.value] : []
return
}
const maxPageHeight = 980
const pages: string[] = []
let currentPageHtml = ''
let currentHeight = 0
children.forEach((child) => {
const childHeight = child.offsetHeight || child.scrollHeight || 0
const childHtml = child.outerHTML
if (currentPageHtml && currentHeight + childHeight > maxPageHeight) {
pages.push(currentPageHtml)
currentPageHtml = childHtml
currentHeight = childHeight
return
}
currentPageHtml += childHtml
currentHeight += childHeight
})
if (currentPageHtml) {
pages.push(currentPageHtml)
}
pdfPages.value = pages.length > 0 ? pages : [renderedContent.value]
}
watch(
() => [props.mode, renderedContent.value],
() => {
void recalculatePdfPages()
},
{ immediate: true }
)
onMounted(() => {
void recalculatePdfPages()
})
const baseFileName = computed(() => `markdown_render_${new Date().getTime()}`)
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 createFullHtmlDocument(innerHtml: string, title: string) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<style>
body { font-family: "Microsoft YaHei", Arial, sans-serif; color: #1f2937; padding: 32px; line-height: 1.75; }
h1, h2, h3, h4, h5, h6 { color: #111827; }
pre { background: #f5f7fa; padding: 16px; border-radius: 8px; overflow: auto; }
code { background: #f5f7fa; padding: 2px 4px; border-radius: 4px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #dcdfe6; padding: 8px 12px; text-align: left; }
blockquote { border-left: 4px solid #dcdfe6; margin: 1em 0; padding-left: 1em; color: #606266; }
img { max-width: 100%; }
</style>
</head>
<body>${innerHtml}</body>
</html>`
}
function htmlToPlainText(html: string) {
const temp = document.createElement('div')
temp.innerHTML = html
return temp.innerText || temp.textContent || ''
}
function escapeRtf(text: string) {
return text
.replace(/\\/g, '\\\\')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\n/g, '\\par\n')
}
async function exportHtml(innerHtml: string, fileName: string) {
const html = createFullHtmlDocument(innerHtml, fileName)
downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), fileName)
}
async function exportMarkdown(markdown: string, fileName: string) {
downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), fileName)
}
async function exportPdf(element: HTMLElement | null, fileName: string) {
if (!element) {
ElMessage.error('当前没有可导出的 PDF 内容')
return
}
await nextTick()
await loadHtml2Pdf()
const pdfBlob = await window.html2pdf()
.set({
margin: 10,
filename: fileName,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
})
.from(element)
.outputPdf('blob')
downloadBlob(pdfBlob, fileName)
}
async function exportDoc(innerHtml: string, fileName: string) {
const docHtml = createFullHtmlDocument(innerHtml, fileName)
downloadBlob(new Blob(['\ufeff', docHtml], { type: 'application/msword' }), fileName)
}
async function exportRtf(innerHtml: string, fileName: string) {
const plainText = htmlToPlainText(innerHtml)
const rtf = `{\\rtf1\\ansi\\deff0 {\\fonttbl{\\f0 Calibri;}}\n\\fs24 ${escapeRtf(plainText)}}`
downloadBlob(new Blob([rtf], { type: 'application/rtf' }), fileName)
}
async function handleDownload() {
if (props.mode === 'markdown') {
const suffix = activeFlavor.value === 'commonmark' ? '_commonmark' : '_gfm'
await exportMarkdown(props.content, `${baseFileName.value}${suffix}.md`)
return
}
if (props.mode === 'html') {
await exportHtml(renderedContent.value, `${baseFileName.value}.html`)
return
}
if (props.mode === 'pdf') {
await exportPdf(pdfRef.value, `${baseFileName.value}.pdf`)
return
}
if (activeRichtextFormat.value === 'word') {
await exportDoc(renderedContent.value, `${baseFileName.value}.doc`)
return
}
await exportRtf(renderedContent.value, `${baseFileName.value}.rtf`)
}
</script>
<style scoped>
.markdown-renderer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #FFFFFF;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
overflow: hidden;
min-height: 0;
}
.render-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 16px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
background-color: #FFFFFF;
}
.render-actions-left {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.action-button {
border-radius: 6px;
transition: all 0.3s ease;
height: 32px;
padding: 0 12px;
}
.action-button.primary {
background-color: #165dff;
border-color: #165dff;
color: #ffffff;
}
.action-button.primary:hover {
background-color: #0e42d2;
border-color: #0e42d2;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(22, 93, 255, 0.3);
}
.render-shell {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.render-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 16px 16px;
}
.render-header {
padding: 14px 16px;
border-radius: 10px;
background: linear-gradient(135deg, #f7faff 0%, #eef4ff 100%);
border: 1px solid #d7e4ff;
}
.render-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.render-desc {
margin-top: 4px;
font-size: 13px;
line-height: 1.6;
color: #5b6472;
}
.markdown-content {
padding: 20px;
background: white;
border-radius: 4px;
border-radius: 10px;
min-height: 400px;
}
.rendered-html {
border: 1px solid #e9ecef;
}
.pdf-stage {
padding: 20px;
border-radius: 12px;
background:
radial-gradient(circle at top, rgba(22, 93, 255, 0.08), transparent 30%),
#eef2f7;
}
.pdf-preview-pages {
display: flex;
flex-direction: column;
gap: 20px;
}
.pdf-page {
width: min(100%, 840px);
min-height: 1160px;
margin: 0 auto;
background: white;
border: 1px solid #d9dee8;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12);
padding: 28px 36px 36px;
}
.pdf-content {
padding: 0;
min-height: auto;
}
.pdf-measure {
position: absolute;
left: -99999px;
top: 0;
width: 768px;
visibility: hidden;
pointer-events: none;
z-index: -1;
}
.richtext-stage {
padding: 12px;
border-radius: 12px;
background: linear-gradient(180deg, #f3f4f6 0%, #eef2f7 100%);
}
.richtext-document {
min-height: 100%;
background: white;
border: 1px solid #dde3ea;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.richtext-content {
padding: 32px 40px 48px;
}
.markdown-content :deep(h1) {
font-size: 2em;
margin: 0.67em 0;
@ -136,7 +559,7 @@ const renderedContent = computed(() => {
.markdown-content :deep(p) {
margin: 1em 0;
line-height: 1.6;
line-height: 1.75;
}
.markdown-content :deep(code) {
@ -149,7 +572,7 @@ const renderedContent = computed(() => {
.markdown-content :deep(pre) {
background-color: #f5f7fa;
padding: 16px;
border-radius: 4px;
border-radius: 8px;
overflow-x: auto;
}
@ -200,11 +623,32 @@ const renderedContent = computed(() => {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
height: 100%;
min-height: 400px;
}
.math-inline {
font-family: 'Cambria Math', 'STIX Two Math', serif;
font-size: 1.1em;
}
</style>
@media (max-width: 768px) {
.render-actions-left {
flex: 1;
min-width: 0;
}
.pdf-page {
padding: 24px 20px 32px;
min-height: auto;
}
.richtext-content {
padding: 24px 18px 32px;
}
.action-button {
flex-shrink: 0;
}
}
</style>

View File

@ -71,7 +71,16 @@ const nodeCount = computed(() => {
})
const initMarkmap = async () => {
if (!props.content || !svgRef.value) return
if (!svgRef.value) return
if (!props.content) {
svgRef.value.innerHTML = ''
if (mmInstance && typeof mmInstance.destroy === 'function') {
mmInstance.destroy()
}
mmInstance = null
return
}
try {
console.log('Initializing markmap with content:', props.content.substring(0, 100) + '...')
@ -430,4 +439,4 @@ const handleWheel = (event: WheelEvent) => {
gap: 4px;
}
}
</style>
</style>

View File

@ -4,7 +4,7 @@ import { documentApi, type ParseParams } from '@/api/document'
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
// 根据上一级标题自动补全下一级标题
function autoPromoteParagraphsToSubheading(text: string): string {
export function autoPromoteParagraphsToSubheading(text: string): string {
const lines = text.split('\n')
const result: string[] = []
let inSection = false
@ -186,6 +186,16 @@ export function useDocumentProcessor() {
results.value = null
error.value = null
}
// 演示模式:上传文件后由用户手动粘贴 Markdown 源码
const initializeManualResult = () => {
results.value = {
markdown: '',
source: '',
mindmap: ''
}
error.value = null
}
// 处理文档转换
const processDocument = async () => {
@ -276,9 +286,10 @@ export function useDocumentProcessor() {
// 方法
handleFileUpload,
initializeManualResult,
clearAll,
processDocument,
getFormulaLabel,
getFormulaInfo
}
}
}

View File

@ -0,0 +1,55 @@
import MarkdownIt from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true
})
type AstNode = {
type: string
tag?: string
nesting?: number
level?: number
content?: string
markup?: string
info?: string
block?: boolean
hidden?: boolean
map?: number[] | null
attrs?: Record<string, string>
children?: AstNode[]
}
function tokenToAstNode(token: Token): AstNode {
return {
type: token.type,
tag: token.tag || undefined,
nesting: token.nesting,
level: token.level,
content: token.content || undefined,
markup: token.markup || undefined,
info: token.info || undefined,
block: token.block,
hidden: token.hidden,
map: token.map || null,
attrs: token.attrs ? Object.fromEntries(token.attrs) : undefined,
children: token.children?.map(tokenToAstNode)
}
}
export function markdownToAstString(markdown: string): string {
if (!markdown.trim()) {
return ''
}
const tokens = md.parse(markdown, {})
const ast = {
type: 'root',
children: tokens.map(tokenToAstNode)
}
return JSON.stringify(ast, null, 2)
}

View File

@ -0,0 +1,45 @@
export interface ReplacementRule {
id: string
search: string
replace: string
}
export interface MarkdownTemplateRenderResult {
markdown: string
error: string | null
}
export function createEmptyReplacementRule(): ReplacementRule {
return {
id: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
search: '',
replace: ''
}
}
export function renderMarkdownTemplate(template: string, rules: ReplacementRule[]): MarkdownTemplateRenderResult {
if (!template.trim()) {
return {
markdown: '',
error: null
}
}
const activeRules = rules.filter((rule) => rule.search.trim())
try {
const markdown = activeRules.reduce((current, rule) => {
return current.split(rule.search).join(rule.replace)
}, template)
return {
markdown,
error: null
}
} catch (error) {
return {
markdown: template,
error: `替换规则执行失败:${(error as Error).message}`
}
}
}

View File

@ -38,7 +38,7 @@ async function loadMammoth(): Promise<void> {
/**
* html2pdf
*/
async function loadHtml2Pdf(): Promise<void> {
export async function loadHtml2Pdf(): Promise<void> {
if (typeof window.html2pdf === 'undefined') {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
@ -140,4 +140,4 @@ export async function convertWordToPdf(
export function isWordFile(file: File): boolean {
const fileName = file.name.toLowerCase();
return fileName.endsWith('.docx') || fileName.endsWith('.doc');
}
}

View File

@ -1,25 +1,17 @@
<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>
<el-button type="text" @click="toggleSettings" class="settings-button">
设置
</el-button>
</header>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 文件上传区域 -->
<div class="upload-section">
<!-- 拖拽上传区 -->
<div
<div
class="drag-upload-area"
:class="{ 'drag-over': isDragging, 'collapsed': isUploadAreaCollapsed }"
:class="{ 'drag-over': isDragging, collapsed: isUploadAreaCollapsed }"
@drop="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@ -28,39 +20,31 @@
<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格式文件点击或拖拽文件至此处上传</div>
<div class="upload-hint">支持PDFWordPNG格式文件上传后可手动粘贴 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"
<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-button type="text" @click="toggleSettings" class="close-button">
<el-icon><Close /></el-icon>
</el-button>
</div>
<ConfigPanel
<ConfigPanel
v-model="config"
:backend-options="backendOptions"
:language-options="languageOptions"
@ -68,61 +52,107 @@
/>
</div>
</div>
<!-- 结果显示区域 -->
<div class="result-section">
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="result-tabs">
<el-tab-pane label="Markdown 渲染" name="markdown" />
<el-tab-pane label="Markdown 源码" name="source" />
<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">
<!-- Markdown 渲染 -->
<div v-show="activeTab === 'markdown'" class="markdown-content">
<div class="markdown-box">
<MarkdownRenderer :content="results.markdown || ''" />
<MarkdownRenderer
:content="renderedMarkdownContent"
:mode="markdownRenderMode"
:flavor="markdownCompatibilityFlavor"
@update:mode="handleMarkdownRenderModeChange"
/>
</div>
</div>
<!-- Markdown 源码 -->
<div v-show="activeTab === 'source'" class="source-content">
<el-input
v-model="results.source"
type="textarea"
:rows="20"
class="source-textarea"
/>
<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="results?.mindmap || ''" />
<MindMapRenderer :content="renderedMindmapContent" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
@ -131,28 +161,65 @@
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 { ref } from 'vue'
import { Delete, Document, Folder, Setting, Close, Loading } from '@element-plus/icons-vue'
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 { useDocumentProcessor } from '@/composables/useDocumentProcessor'
import { autoPromoteParagraphsToSubheading, useDocumentProcessor } from '@/composables/useDocumentProcessor'
import { markdownToAstString } from '@/utils/markdownAst'
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
// 使
const {
uploadedFiles,
config,
results,
isProcessing,
error,
backendOptions,
languageOptions,
clearAll,
processDocument: originalProcessDocument,
initializeManualResult,
handleFileUpload
} = useDocumentProcessor()
@ -161,10 +228,54 @@ 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')
@ -172,20 +283,16 @@ const toggleSettings = () => {
setTimeout(() => {
showSettings.value = false
settingsPanel.classList.remove('hiding')
//
document.removeEventListener('click', handleClickOutside)
}, 400)
}
} else {
//
showSettings.value = true
// DOM showing
setTimeout(() => {
const settingsPanel = document.querySelector('.settings-panel')
if (settingsPanel) {
settingsPanel.classList.add('showing')
}
//
document.addEventListener('click', handleClickOutside)
}, 10)
}
@ -194,10 +301,13 @@ const toggleSettings = () => {
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)) {
//
if (
settingsPanel &&
!settingsPanel.contains(event.target as Node) &&
settingsButton &&
!settingsButton.contains(event.target as Node)
) {
settingsPanel.classList.remove('showing')
settingsPanel.classList.add('hiding')
setTimeout(() => {
@ -212,24 +322,68 @@ const handleBackendChange = (backend: string) => {
console.log('Backend changed to:', backend)
}
const triggerUpload = () => {
if (fileInput.value) {
fileInput.value.click()
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) {
// 使 handleFileUpload Word PDF
await handleFileUpload(input.files)
// input
input.value = ''
//
if (uploadedFiles.value.length > 0) {
isUploadAreaCollapsed.value = true
//
processDocument()
initializeManualResult()
resetReplacementConfig()
markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown'
activeTab.value = 'source'
}
}
}
@ -237,37 +391,41 @@ const handleFileInputChange = async (event: Event) => {
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
//
processDocument()
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 processDocument = async () => {
await originalProcessDocument()
//
activeTab.value = 'mindmap'
}
// clearAll
const clearAllFiles = () => {
clearAll()
resetReplacementConfig()
isUploadAreaCollapsed.value = false
}
onMounted(() => {
updateViewportState()
window.addEventListener('resize', updateViewportState)
})
onUnmounted(() => {
window.removeEventListener('resize', updateViewportState)
})
</script>
<style scoped>
@ -282,7 +440,6 @@ const clearAllFiles = () => {
box-sizing: border-box;
}
/* 顶部标题栏 */
.top-header {
padding: 16px 24px;
display: flex;
@ -313,7 +470,6 @@ const clearAllFiles = () => {
color: #165DFF;
}
/* 主内容区域 */
.main-content {
flex: 1;
padding: 24px;
@ -323,20 +479,11 @@ const clearAllFiles = () => {
flex-direction: column;
}
/* 上传区域 */
.upload-section {
margin-bottom: 16px;
flex-shrink: 0;
}
.section-title {
font-size: 16px;
font-weight: 500;
color: #343A40;
margin: 0 0 16px 0;
}
/* 拖拽上传区 */
.drag-upload-area {
border: 2px dashed #CED4DA;
border-radius: 8px;
@ -356,11 +503,7 @@ const clearAllFiles = () => {
display: block;
}
.drag-upload-area:hover {
border-color: #165DFF;
background-color: #F8F9FF;
}
.drag-upload-area:hover,
.drag-upload-area.drag-over {
border-color: #165DFF;
background-color: #F8F9FF;
@ -391,7 +534,6 @@ const clearAllFiles = () => {
max-width: 400px;
}
/* 已上传文件 */
.uploaded-files {
margin-top: 24px;
text-align: left;
@ -444,46 +586,6 @@ const clearAllFiles = () => {
color: #DC3545;
}
/* 操作按钮 */
.action-buttons {
display: flex;
gap: 12px;
margin-top: 8px;
}
.process-button {
flex: 1;
background-color: #165DFF;
border-color: #165DFF;
color: #FFFFFF;
border-radius: 6px;
height: 40px;
font-size: 14px;
transition: all 0.3s ease;
}
.process-button:hover:not(:disabled) {
background-color: #0E42D2;
border-color: #0E42D2;
}
.clear-button {
flex: 1;
background-color: #FFFFFF;
border-color: #CED4DA;
color: #495057;
border-radius: 6px;
height: 40px;
font-size: 14px;
transition: all 0.3s ease;
}
.clear-button:hover:not(:disabled) {
border-color: #165DFF;
color: #165DFF;
}
/* 设置面板 */
.settings-panel {
position: fixed;
top: 0;
@ -504,13 +606,11 @@ const clearAllFiles = () => {
.settings-panel.showing {
transform: translateX(0);
opacity: 1;
transition: transform 0.3s ease, opacity 0.4s ease;
}
.settings-panel.hiding {
transform: translateX(100%);
opacity: 0;
transition: transform 0.3s ease, opacity 0.4s ease;
}
.settings-header {
@ -539,9 +639,6 @@ const clearAllFiles = () => {
margin: 0;
}
/* 结果区域 */
.result-section {
background-color: #FFFFFF;
border: 1px solid #E9ECEF;
@ -553,7 +650,6 @@ const clearAllFiles = () => {
min-height: 0;
}
/* 标签页 */
.result-tabs {
border-bottom: 1px solid #E9ECEF;
}
@ -587,7 +683,16 @@ const clearAllFiles = () => {
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;
@ -596,8 +701,8 @@ const clearAllFiles = () => {
flex-direction: column;
}
/* 加载状态 */
.loading-container {
.loading-container,
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
@ -612,7 +717,8 @@ const clearAllFiles = () => {
animation: spin 1s linear infinite;
}
.loading-text {
.loading-text,
.empty-text {
font-size: 14px;
color: #6C757D;
margin: 0;
@ -623,16 +729,6 @@ const clearAllFiles = () => {
to { transform: rotate(360deg); }
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 400px;
gap: 16px;
}
.empty-icon {
width: 120px;
height: 120px;
@ -641,13 +737,6 @@ const clearAllFiles = () => {
background-position: center;
}
.empty-text {
font-size: 14px;
color: #6C757D;
margin: 0;
}
/* 标签内容 */
.result-tab-content {
flex: 1;
min-height: 0;
@ -670,7 +759,8 @@ const clearAllFiles = () => {
}
}
.markdown-content {
.markdown-content,
.mindmap-content {
flex: 1;
min-height: 0;
}
@ -700,6 +790,32 @@ const clearAllFiles = () => {
.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 {
@ -724,56 +840,135 @@ const clearAllFiles = () => {
padding: 16px;
}
.mindmap-content {
flex: 1;
min-height: 0;
.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 {
padding: 16px;
}
.top-header {
padding: 12px 16px;
}
.page-title {
font-size: 16px;
}
.drag-upload-area {
padding: 32px 16px;
}
.upload-icon {
font-size: 32px;
}
.upload-text {
font-size: 14px;
}
.upload-hint {
font-size: 12px;
}
.action-buttons {
flex-direction: column;
}
.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>
</style>