提交 91303665 authored 作者: 张伊明's avatar 张伊明

feat 新增pdf预览和文档编辑

上级 aef3ad69
流水线 #623 已通过 于阶段
in 1 分 53 秒
差异被折叠。
...@@ -22,6 +22,16 @@ ...@@ -22,6 +22,16 @@
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@kangc/v-md-editor": "^2.3.18", "@kangc/v-md-editor": "^2.3.18",
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@tiptap/extension-bubble-menu": "^3.22.4",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-highlight": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@tiptap/vue-3": "^3.22.4",
"@traptitech/markdown-it-katex": "^3.6.0", "@traptitech/markdown-it-katex": "^3.6.0",
"axios": "^1.12.2", "axios": "^1.12.2",
"d3": "^7.9.0", "d3": "^7.9.0",
......
<template>
<aside class="article-nav-tree">
<div class="tree-title">导航</div>
<div class="tree-scroll">
<el-tree
ref="treeRef"
:data="treeData"
node-key="id"
:props="treeProps"
:current-node-key="currentNodeKey"
highlight-current
default-expand-all
@node-click="onNodeClick"
/>
</div>
</aside>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
treeData: {
type: Array,
default: () => []
},
currentNodeKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['node-click'])
const treeRef = ref(null)
const treeProps = { label: 'title', children: 'children' }
function onNodeClick(nodeData) {
emit('node-click', nodeData)
}
</script>
<style scoped lang="scss">
.article-nav-tree {
width: 280px;
flex-shrink: 0;
padding: 16px 19px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border: 1px solid #e8ecf2;
border-radius: 8px;
background: #fff;
}
.tree-title {
font-family: 'Source Han Sans CN', sans-serif;
font-weight: 700;
font-size: 16px;
line-height: 24px;
color: var(--text-primary-80-color, #344054);
margin-bottom: 17px;
}
:deep(.article-nav-tree .el-tree) {
min-width: 0;
}
.tree-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.article-nav-tree .el-tree-node__label) {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
line-height: 22px;
color: var(--text-primary-80-color, #344054);
}
</style>
<template>
<div class="page-block-content">
<div class="page-block-content__head">{{ page.page_idx + 1 }}</div>
<article
v-for="node in sortedNodes"
:key="node.node_id"
class="page-row"
:class="{
'page-row--page-number': isPageNumber(node),
'page-row--selected': selectedNodeKey === node.node_id
}"
:data-node-id="node.node_id"
:data-semantic-type="node.semantic_type"
:data-node-page-idx="page.page_idx"
@click="onNodeClick(node)"
>
<div class="page-row__en" :class="{ 'page-row__en--page-number': isPageNumber(node) }">
<component :is="resolveComponent(node)" :node="node" :placeholder-src="placeholderSrc" />
</div>
<div class="page-row__zh" :class="{ 'page-row__zh--page-number': isPageNumber(node) }">
<p class="page-row__zh-text" :class="{ 'page-row__zh-text--page-number': isPageNumber(node) }">{{ getZhPreview(node) }}</p>
</div>
</article>
</div>
</template>
<script setup>
import { computed } from 'vue'
import HeadingNode from './nodes/HeadingNode.vue'
import TextNode from './nodes/TextNode.vue'
import ImageNode from './nodes/ImageNode.vue'
import TableNode from './nodes/TableNode.vue'
import RichHtmlNode from './nodes/RichHtmlNode.vue'
const props = defineProps({
page: { type: Object, default: () => ({}) },
nodes: { type: Array, default: () => [] },
placeholderSrc: { type: String, default: '' },
selectedNodeKey: { type: String, default: '' }
})
const emit = defineEmits(['node-select'])
const sortedNodes = computed(() => [...props.nodes].sort((left, right) => Number(left?.layout?.reading_order || 0) - Number(right?.layout?.reading_order || 0)))
function resolveComponent(node) {
const semanticType = String(node?.semantic_type || '')
const payload = node?.content_payload || {}
if (semanticType === 'heading' || payload.heading_level != null) return HeadingNode
if (semanticType === 'table' || payload.type === 'table') return TableNode
if (semanticType === 'image' || payload.type === 'image') return ImageNode
if (payload.type === 'html' && payload.html) return RichHtmlNode
return TextNode
}
function getZhPreview(node) {
const text = String(node?.content_payload?.text || '').trim()
if (text) return `待翻译:${text}`
if (node?.semantic_type === 'image') return '待翻译:图片说明'
if (node?.semantic_type === 'table') return '待翻译:表格内容'
return '待翻译'
}
function isPageNumber(node) {
return String(node?.semantic_type || '') === 'page_number'
}
function onNodeClick(node) {
if (!node?.node_id) return
emit('node-select', node)
}
</script>
<style scoped lang="scss">
.page-block-content {
border: 1px solid #e4e7ec;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.page-block-content__head {
padding: 8px 12px;
font-size: 13px;
color: #667085;
border-bottom: 1px solid #eaecf0;
}
.page-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #f2f4f7;
cursor: pointer;
transition: background-color 0.16s ease, box-shadow 0.16s ease;
}
.page-row:last-child { border-bottom: none; }
.page-row:hover { background: #f8fafc; }
.page-row--selected { background: #eff6ff; box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12); }
.page-row--selected .page-row__zh-text { color: #1d4ed8; font-weight: 600; }
.page-row--selected .page-row__en { border-right-color: rgba(37, 99, 235, 0.18); }
.page-row--page-number { cursor: default; }
.page-row--page-number:hover { background: inherit; }
.page-row__en { padding-right: 12px; border-right: 1px solid #eaecf0; }
.page-row__zh-text { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 14px; line-height: 1.8; color: #475467; }
.page-row__en--page-number, .page-row__zh--page-number { text-align: center; }
.page-row__zh-text--page-number { text-align: center; }
@media (max-width: 1360px) {
.page-row { grid-template-columns: 1fr; }
.page-row__en { padding-right: 0; border-right: none; border-bottom: 1px solid #eaecf0; padding-bottom: 10px; margin-bottom: 2px; }
}
</style>
<template>
<div ref="scrollRef" class="sync-compare-scroll" @scroll="onScroll">
<section
v-for="page in pages"
:key="page.page_idx"
:id="`page-${page.page_idx}`"
:ref="(element) => setPageRef(page.page_idx, element)"
class="page-container"
:class="{ 'page-container--active': currentPageIdx === page.page_idx }"
>
<PageBlock
v-if="pageDataMap[page.page_idx]"
:page="page"
:nodes="pageDataMap[page.page_idx].nodes"
:placeholder-src="placeholderSrc"
:selected-node-key="selectedNodeKey"
@node-select="onSelectNode"
/>
<PageShell v-else :page="page" />
</section>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import PageBlock from './PageBlock.vue'
import PageShell from './PageShell.vue'
const props = defineProps({
pages: { type: Array, default: () => [] },
pageDataMap: { type: Object, default: () => ({}) },
currentPageIdx: { type: Number, default: 0 },
currentNodeKey: { type: String, default: '' },
selectedNodeKey: { type: String, default: '' },
placeholderSrc: { type: String, default: '' }
})
const emit = defineEmits(['page-visible', 'active-page-change', 'active-node-change', 'node-select'])
const scrollRef = ref(null)
const pageRefMap = new Map()
let observer = null
let rafId = null
let lastActiveNodeId = ''
let jumpTimerIds = []
function setPageRef(pageIdx, element) {
if (element) {
pageRefMap.set(pageIdx, element)
if (observer) observer.observe(element)
return
}
const oldElement = pageRefMap.get(pageIdx)
if (oldElement && observer) observer.unobserve(oldElement)
pageRefMap.delete(pageIdx)
}
function getVisibleHeadings() {
const scroller = scrollRef.value
if (!scroller) return []
const scrollerRect = scroller.getBoundingClientRect()
return Array.from(scroller.querySelectorAll('[data-semantic-type="heading"]'))
.map((element) => ({
element,
rect: element.getBoundingClientRect()
}))
.filter(({ rect }) => rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom)
}
function getActiveHeading() {
const visibleHeadings = getVisibleHeadings()
if (!visibleHeadings.length) return null
// 全局视窗内最靠上的标题优先;当它越过顶部后,自然切换到下一个可见标题。
const topVisible = visibleHeadings.reduce((candidate, current) => {
if (!candidate) return current
if (current.rect.top < candidate.rect.top) return current
return candidate
}, null)
return topVisible?.element || null
}
function emitActiveNode() {
const activeHeadingEl = getActiveHeading()
const nodeId = activeHeadingEl?.getAttribute('data-node-id') || ''
if (!nodeId || nodeId === lastActiveNodeId) return
lastActiveNodeId = nodeId
emit('active-node-change', nodeId)
}
function onSelectNode(node) {
emit('node-select', node)
}
function onScroll() {
if (rafId !== null) return
rafId = window.requestAnimationFrame(() => {
rafId = null
emitActiveNode()
})
}
function scrollToNode(nodeId, options = {}) {
const { behavior = 'auto' } = options
const scroller = scrollRef.value
if (!scroller || !nodeId) return false
const targetElement = scroller.querySelector(`[data-node-id="${CSS.escape(String(nodeId))}"]`)
if (!targetElement) return false
const targetTop = Math.max(0, targetElement.offsetTop - 12)
scroller.scrollTo({ top: targetTop, behavior })
jumpTimerIds.forEach((timerId) => window.clearTimeout(timerId))
jumpTimerIds = [
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 12), behavior: 'auto' })
onScroll()
}, 120),
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 12), behavior: 'auto' })
onScroll()
}, 280)
]
return true
}
function scrollToPage(pageIdx, options = {}) {
const { behavior = 'auto' } = options
const scroller = scrollRef.value
const targetElement = pageRefMap.get(pageIdx)
if (!targetElement || !scroller) return
const targetTop = Math.max(0, targetElement.offsetTop - 16)
scroller.scrollTo({ top: targetTop, behavior })
jumpTimerIds.forEach((timerId) => window.clearTimeout(timerId))
jumpTimerIds = [
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 16), behavior: 'auto' })
onScroll()
}, 120),
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 16), behavior: 'auto' })
onScroll()
}, 280)
]
}
defineExpose({ scrollToPage, scrollToNode })
onMounted(async () => {
await nextTick()
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const pageId = String(entry.target.id || '').replace('page-', '')
const pageIdx = Number(pageId)
if (!Number.isNaN(pageIdx)) emit('page-visible', pageIdx)
})
},
{ root: scrollRef.value, rootMargin: '600px 0px 600px 0px', threshold: 0.01 }
)
pageRefMap.forEach((element) => observer.observe(element))
onScroll()
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId)
rafId = null
}
jumpTimerIds.forEach((timerId) => window.clearTimeout(timerId))
jumpTimerIds = []
})
</script>
<style scoped lang="scss">
.sync-compare-scroll {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid #e8ecf2;
border-radius: 8px;
background: #fff;
padding: 16px;
box-sizing: border-box;
}
.page-container { padding: 0 0 18px; }
.page-container--active { background: #f8fafc; }
</style>
<template>
<div class="page-shell">
<div class="page-shell__header">{{ page.page_idx + 1 }}</div>
<div class="page-shell__body">
<span class="page-shell__text">页面内容按需加载中</span>
</div>
</div>
</template>
<script setup>
defineProps({
page: {
type: Object,
default: () => ({})
}
})
</script>
<style scoped lang="scss">
.page-shell {
border: 1px solid #e4e7ec;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.page-shell__header {
padding: 8px 12px;
font-size: 13px;
color: #667085;
border-bottom: 1px solid #eaecf0;
}
.page-shell__body {
height: 420px;
display: flex;
align-items: center;
justify-content: center;
color: #98a2b3;
background: linear-gradient(180deg, #f8fafc 0%, #f2f4f7 100%);
}
.page-shell__text {
font-size: 14px;
}
</style>
<template>
<div class="translation-editor">
<div class="translation-editor__toolbar">
<div class="translation-editor__toolbar-group">
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('heading', { level: 1 }) }" @click="setHeading(1)">H1</button>
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('heading', { level: 2 }) }" @click="setHeading(2)">H2</button>
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('heading', { level: 3 }) }" @click="setHeading(3)">H3</button>
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('bulletList') }" @click="toggleBulletList">列表</button>
<button type="button" class="translation-editor__btn" @click="insertParagraphAfter">新增段落</button>
<button type="button" class="translation-editor__btn" @click="deleteCurrentBlock">删除段落</button>
<button type="button" class="translation-editor__btn" @click="insertTable">表格</button>
</div>
<div class="translation-editor__toolbar-group">
<span class="translation-editor__hint">块数 {{ blockCount }}</span>
<span class="translation-editor__hint">只读块 {{ readonlyBlockCount }}</span>
</div>
</div>
<div class="translation-editor__content">
<div class="translation-editor__page-hint">当前联动页:第 {{ activePage }}</div>
<div v-for="block in readonlyBlocks" :key="block.id" class="translation-editor__readonly-block">
<div class="translation-editor__readonly-label">只读块 · {{ block.semanticType }}</div>
<div class="translation-editor__readonly-text">{{ block.displayText || block.text || '未识别内容' }}</div>
</div>
<EditorContent v-if="editor" :editor="editor" />
</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, ref, watch } from 'vue'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { TextStyle } from '@tiptap/extension-text-style'
import { Highlight } from '@tiptap/extension-highlight'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableCell } from '@tiptap/extension-table-cell'
const props = defineProps({
doc: {
type: Object,
default: () => ({ type: 'doc', content: [] })
},
blockCount: {
type: Number,
default: 0
},
readonlyBlockCount: {
type: Number,
default: 0
},
readonlyBlocks: {
type: Array,
default: () => []
},
activePage: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:doc'])
const updateTimer = ref(null)
function normalizeDoc(doc) {
const safeContent = Array.isArray(doc?.content)
? doc.content.filter((node) => node && typeof node === 'object' && typeof node.type === 'string')
: []
return {
type: 'doc',
content: safeContent
}
}
const editor = useEditor({
content: normalizeDoc(props.doc),
editable: true,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] }
}),
TextStyle,
Highlight,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell
],
onUpdate({ editor }) {
if (updateTimer.value) {
clearTimeout(updateTimer.value)
}
updateTimer.value = window.setTimeout(() => {
emit('update:doc', normalizeDoc(editor.getJSON()))
}, 120)
}
})
watch(
() => props.doc,
(nextDoc) => {
if (!editor.value) return
editor.value.commands.setContent(normalizeDoc(nextDoc), false)
},
{ deep: true }
)
function setHeading(level) {
if (!editor.value) return
editor.value.chain().focus().toggleHeading({ level }).run()
}
function toggleBulletList() {
if (!editor.value) return
editor.value.chain().focus().toggleBulletList().run()
}
function insertParagraphAfter() {
if (!editor.value) return
editor.value.chain().focus().insertContent({ type: 'paragraph' }).run()
}
function deleteCurrentBlock() {
if (!editor.value) return
if (!editor.value.state.selection.empty) {
editor.value.chain().focus().deleteSelection().run()
return
}
editor.value.chain().focus().deleteCurrentNode().run()
}
function insertTable() {
if (!editor.value) return
editor.value.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run()
}
onBeforeUnmount(() => {
if (updateTimer.value) {
clearTimeout(updateTimer.value)
updateTimer.value = null
}
editor.value?.destroy()
})
</script>
<style scoped lang="scss">
.translation-editor {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.translation-editor__toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e8ecf2;
background: #fff;
}
.translation-editor__toolbar-group {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.translation-editor__btn {
height: 32px;
padding: 0 12px;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
.translation-editor__btn.is-active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.translation-editor__hint {
font-size: 13px;
color: #667085;
}
.translation-editor__content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
box-sizing: border-box;
background: #fcfcfd;
}
.translation-editor__page-hint {
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
font-size: 13px;
}
.translation-editor__readonly-block {
margin-bottom: 12px;
padding: 12px 14px;
border: 1px dashed #d0d5dd;
border-radius: 10px;
background: #f8fafc;
}
.translation-editor__readonly-label {
font-size: 12px;
line-height: 18px;
color: #667085;
margin-bottom: 6px;
}
.translation-editor__readonly-text {
font-size: 14px;
line-height: 22px;
color: #101828;
white-space: pre-wrap;
word-break: break-word;
}
</style>
<template>
<div
class="heading-node"
:class="`heading-node--h${safeLevel}`"
:data-node-id="node?.node_id || ''"
data-semantic-type="heading"
:data-heading-level="safeLevel"
>
{{ text }}
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const text = computed(() => String(props.node?.content_payload?.text || '').trim() || '未命名标题')
const safeLevel = computed(() => {
const level = Number(props.node?.content_payload?.heading_level || 1)
if (level < 1) return 1
if (level > 6) return 6
return level
})
</script>
<style scoped lang="scss">
.heading-node {
color: #101828;
line-height: 1.7;
word-break: break-word;
}
.heading-node--h1 { font-size: 22px; font-weight: 700; }
.heading-node--h2 { font-size: 20px; font-weight: 700; }
.heading-node--h3 { font-size: 18px; font-weight: 700; }
.heading-node--h4 { font-size: 16px; font-weight: 600; }
.heading-node--h5 { font-size: 15px; font-weight: 600; }
.heading-node--h6 { font-size: 14px; font-weight: 600; }
</style>
<template>
<figure class="image-node">
<img class="image-node__img" :src="resolvedSrc" :alt="altText" loading="lazy" @error="onError" />
<figcaption v-if="captionText" class="image-node__caption">{{ captionText }}</figcaption>
</figure>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
},
placeholderSrc: {
type: String,
default: ''
}
})
const hasError = ref(false)
const rawSrc = computed(() => {
const payload = props.node?.content_payload || {}
return String(payload.img_path || payload.preview_img_path || '').trim()
})
const altText = computed(() => String(props.node?.content_payload?.alt_text || '图片'))
const captionText = computed(() => String(props.node?.content_payload?.caption || '').trim())
const resolvedSrc = computed(() => {
if (hasError.value) return props.placeholderSrc
if (!rawSrc.value) return props.placeholderSrc
if (/^(https?:)?\/\//i.test(rawSrc.value) || /^(data|blob):/i.test(rawSrc.value)) return rawSrc.value
const normalizedPath = rawSrc.value.replaceAll('\\', '/')
const baseUrl = String(import.meta.env.VITE_INTELLIGENT_TRANSLATION_ASSET_BASE_URL || import.meta.env.VITE_BASE_FILE_URL || '').trim()
if (!baseUrl) return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`
return `${baseUrl.replace(/\/+$/, '')}/${normalizedPath.replace(/^\/+/, '')}`
})
function onError() {
hasError.value = true
}
</script>
<style scoped lang="scss">
.image-node {
margin: 0;
}
.image-node__img {
width: 100%;
min-height: 120px;
object-fit: contain;
border-radius: 6px;
border: 1px solid #e4e7ec;
background: #f8fafc;
}
.image-node__caption {
margin-top: 8px;
font-size: 12px;
color: #667085;
}
</style>
<template>
<div class="rich-html-node" v-html="html"></div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const html = computed(() => String(props.node?.content_payload?.html || ''))
</script>
<style scoped lang="scss">
.rich-html-node {
font-size: 14px;
color: #344054;
line-height: 1.8;
word-break: break-word;
}
</style>
<template>
<div class="table-node">
<div class="table-node__content" v-html="safeHtml"></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const safeHtml = computed(() => String(props.node?.content_payload?.table_body_html || ''))
</script>
<style scoped lang="scss">
.table-node {
border: 1px solid #eaecf0;
border-radius: 6px;
overflow: hidden;
}
.table-node__content {
padding: 8px;
}
.table-node__content :deep(table) {
width: 100%;
border-collapse: collapse;
}
.table-node__content :deep(td),
.table-node__content :deep(th) {
border: 1px solid #d0d5dd;
padding: 6px;
vertical-align: top;
}
</style>
<template>
<p class="text-node">{{ text }}</p>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const text = computed(() => String(props.node?.content_payload?.text || '').trim() || ' ')
</script>
<style scoped lang="scss">
.text-node {
margin: 0;
font-size: 14px;
color: #344054;
line-height: 1.85;
white-space: pre-wrap;
word-break: break-word;
}
</style>
...@@ -131,6 +131,7 @@ const onTranslate = async () => { ...@@ -131,6 +131,7 @@ const onTranslate = async () => {
try { try {
const response = await submitTranslationTask(targetFile) const response = await submitTranslationTask(targetFile)
const taskId = response?.data?.task_id || response?.data?.task_ids?.[0] const taskId = response?.data?.task_id || response?.data?.task_ids?.[0]
const pdfUrl = URL.createObjectURL(targetFile)
if (!taskId) { if (!taskId) {
throw new Error('未获取到任务ID') throw new Error('未获取到任务ID')
} }
...@@ -139,7 +140,8 @@ const onTranslate = async () => { ...@@ -139,7 +140,8 @@ const onTranslate = async () => {
name: 'intelligentTranslationDocument', name: 'intelligentTranslationDocument',
query: { query: {
taskId, taskId,
fileName: targetFile.name || '' fileName: targetFile.name || '',
pdfUrl
} }
}) })
} catch (error) { } catch (error) {
......
import { computed, ref } from 'vue'
const uploadedPdfUrl = ref('')
const historyPdfUrl = ref('')
const activePdfSource = ref('upload')
export function usePdfPreviewState() {
const pdfUrl = computed(() => {
if (activePdfSource.value === 'history' && historyPdfUrl.value) {
return historyPdfUrl.value
}
return uploadedPdfUrl.value || historyPdfUrl.value || ''
})
function setUploadedPdfUrl(url) {
uploadedPdfUrl.value = url || ''
if (url) activePdfSource.value = 'upload'
}
function setHistoryPdfUrl(url) {
historyPdfUrl.value = url || ''
if (url && !uploadedPdfUrl.value) activePdfSource.value = 'history'
}
function setActivePdfSource(source) {
if (source === 'upload' || source === 'history') {
activePdfSource.value = source
}
}
return {
pdfUrl,
activePdfSource,
setUploadedPdfUrl,
setHistoryPdfUrl,
setActivePdfSource
}
}
# 智能翻译文档对比页前端重构实施方案
## 方案进度概述
- 当前阶段:重构方案定稿
- 目标:将现有全量渲染的文档对比页重构为页级懒加载架构
- 面向对象:大模型执行与前端开发协作
- 约束:原有接口不能改,只能基于新增接口重构
## 1. 需求理解
当前页面的核心问题不是单纯“慢”,而是:
1. 整包数据过大
2. 页和块一次性全量挂载
3. 富文本、图片、表格混合渲染
4. 目录与滚动状态依赖大量 DOM
因此前端重构的目标不是“微调样式”,而是从渲染架构上切换到更适合大文档的方式。
## 2. 最终结论
> 以“页”为最小渲染单位,采用“页级懒加载 + 视口内挂载 + 页外轻量壳 + 页内二级懒加载”的混合方案。
这是当前场景下最稳、最容易落地、也最符合文档阅读习惯的方案。
## 3. 重构目标
### 3.1 功能目标
- 支持超长文档平稳展示
- 支持目录跳转
- 支持页码同步
- 支持图片、表格、标题、正文正常展示
- 支持当前页高亮与章节高亮
### 3.2 性能目标
- 首屏加载更快
- 滚动更稳定
- 大文档不掉帧
- 降低一次性 DOM 规模
### 3.3 可维护性目标
- 页、块、节点职责分离
- 渲染逻辑可分层扩展
- 后续方便接搜索、高亮、差异对比
## 4. 重构原则
1. 页是最小渲染单位
2. 目录只负责导航,不参与重渲染
3. 可见页才挂载真实内容
4. 页外只保留占位壳
5. 图片、表格、富文本做二级懒加载
6. 原始完整结果保留,但不直接驱动 UI 全量渲染
## 5. 页面结构设计
### 5.1 页面容器
文件建议:`src/views/intelligentTranslation/documentCompare.vue`
职责:
- 请求文档摘要、目录、页索引、页内容
- 管理当前页、当前章节、加载状态
- 维护滚动容器
- 协调页面跳转和状态同步
### 5.2 页列表组件
文件建议:`PageList.vue`
职责:
- 遍历页元信息
- 判断页是否进入视口附近
- 决定挂载真实内容还是仅显示页壳
- 控制页缓存策略
### 5.3 页内容组件
文件建议:`PageBlock.vue`
职责:
- 渲染单页内部节点
-`semantic_type` 分发渲染子组件
- 处理 absolute 与 flow 两种布局模式
### 5.4 节点子组件
建议拆分:
- `HeadingNode.vue`
- `TextNode.vue`
- `ImageNode.vue`
- `TableNode.vue`
- `RichHtmlNode.vue`
职责:
- 各类节点独立渲染
- 减少单组件复杂度
- 控制 `v-html` 使用范围
### 5.5 目录树组件
文件建议:`ArticleNavTree.vue`
职责:
- 渲染标题目录
- 点击跳页
- 当前章节高亮
## 6. 状态设计
建议状态如下:
```js
const rawResult = ref(null)
const summary = ref(null)
const pageIndexList = ref([])
const outlineTree = ref([])
const pageBlockMap = ref(new Map())
const visiblePageSet = ref(new Set())
const mountedPageCache = ref(new Map())
const currentPageId = ref('')
const currentNodeKey = ref('')
const compareScrollRef = ref(null)
```
### 状态含义
- `rawResult`:原始完整结果,兼容旧逻辑
- `summary`:文档摘要数据
- `pageIndexList`:页级索引列表
- `outlineTree`:目录树
- `pageBlockMap`:页到节点的映射
- `visiblePageSet`:当前需要挂载的页集合
- `mountedPageCache`:已挂载页缓存
- `currentPageId`:当前激活页
- `currentNodeKey`:当前目录高亮节点
- `compareScrollRef`:滚动容器引用
## 7. 数据获取顺序
### 7.1 初始化顺序
1. 请求 `document-meta`
2. 解析出摘要、页索引、目录树
3. 请求首屏页内容 `document-page`
4. 根据滚动位置继续按需请求其他页
### 7.2 页内按需顺序
当某页进入视口附近:
1. 请求该页完整内容
2. 挂载页真实 DOM
### 7.3 目录跳转顺序
点击目录节点时:
1. 根据 `page_idx` 找目标页
2. 若该页未挂载,先请求页内容
3. 滚动到目标页
4. 更新当前页和章节高亮
## 8. 渲染策略
### 8.1 首屏策略
首屏只挂载:
- 当前页
- 当前页前 1 页
- 当前页后 2 页
其他页只渲染页壳,不渲染真实内容。
### 8.2 视口内挂载策略
使用 `IntersectionObserver` 或滚动监听判断页面是否进入视口附近:
- 进入可视区:挂载真实内容
- 离开较远:保留占位或卸载
### 8.3 页内二级懒加载策略
页内对重资源进一步控制:
- 图片 `loading="lazy"`
- 表格默认只渲染摘要或首屏片段
- 富文本仅对必要节点使用 `v-html`
### 8.4 页外壳策略
未挂载页仍保留:
- 页码
- 页面尺寸占位
- 可选的轻量标题摘要
这样可以保证滚动长度和定位稳定。
## 9. 现有代码改造重点
### 9.1 不再全量 `v-for` 渲染所有页内容
当前逻辑需要从“所有页一次性渲染”改成“页壳 + 条件挂载”。
### 9.2 控制 `v-html` 范围
只在确实需要富文本还原的节点使用 `v-html`,且尽量局部化。
### 9.3 目录与页级状态绑定
当前页、章节高亮、跳转定位统一使用页级索引,不再依赖海量 block DOM 计算。
### 9.4 图片和表格独立组件化
图片和表格不要和普通文本混成一个大渲染函数,必须独立拆组件。
## 10. 推荐组件结构
```text
documentCompare.vue
├── ArticleNavTree.vue
├── PageList.vue
│ ├── PageShell.vue
│ └── PageBlock.vue
│ ├── HeadingNode.vue
│ ├── TextNode.vue
│ ├── ImageNode.vue
│ ├── TableNode.vue
│ └── RichHtmlNode.vue
└── ComparePanel.vue
```
## 11. 实施步骤
### 第 1 步:接入新接口,但不改 UI
目标:先把数据链路切到新接口,保留现有页面结构。
验收:
- 能拿到摘要、页索引、目录
- 能按页拿到内容
### 第 2 步:改成页壳 + 条件挂载
目标:只挂载视口附近页。
验收:
- 非可见页不再创建真实 DOM
- 首屏明显变快
### 第 3 步:拆分页内节点组件
目标:降低单文件复杂度。
验收:
- 标题、正文、图片、表格分组件渲染
### 第 4 步:改造目录跳转和滚动同步
目标:按页同步当前章节。
验收:
- 点击目录可准确跳转
- 滚动时高亮正确
### 第 5 步:增加页内二级懒加载
目标:处理大图、大表和富文本。
验收:
- 长文档滚动更流畅
- 图片/表格不会拖慢主线程
## 12. 风险与注意事项
- 不能直接把整包 JSON 当唯一 UI 数据源长期使用
- 需要保证 `page_idx``node_id` 稳定唯一
- `bbox_norm` 优先使用,缺失时再由 `bbox` 换算
- `reading_order` 必须稳定,否则页内顺序会错乱
- `style_spans` 的字符区间必须与文本严格对齐
## 13. 验收标准
### 功能验收
- 文档正常渲染
- 目录可跳转
- 当前页可同步
- 图片、表格、标题、正文正常展示
### 性能验收
- 首屏快于原方案
- 长文档滚动不卡顿
- 不再出现明显“截断感”
### 结构验收
- 组件职责清晰
- 渲染链路分层明确
- 后续可继续拓展搜索和对比能力
## 14. 结论
前端最终采用:
> 页级懒加载渲染架构。
即:
- 页作为最小渲染单位
- 可见页才挂载真实内容
- 页外只保留轻量壳
- 目录和滚动按页同步
- 图片、表格、富文本做二级懒加载
这是当前场景下最稳妥、最可落地、最利于后续扩展的方案。
# intelligentTranslation 重构方案(阶段任务清单版)
## 进度概述
- [ ] 阶段 1:静态数据适配层
- [ ] 阶段 2:左侧 PDF 原文预览
- [ ] 阶段 3:右侧 Tiptap 译文编辑器
- [ ] 阶段 4:页对应 / 段级高亮 / 导航跳转
- [ ] 阶段 5:Word 导出
执行规则:每完成一个阶段,必须先经用户验收确认,再更新本文档进度,然后才能进入下一阶段。
---
## 1. 目标
- 左侧使用 PDF 直接预览原文,不再用 JSON 反推版式。
- 右侧使用 Tiptap 编辑译文,按段块编辑。
- 原文与译文保持块级一一对应,支持段级高亮联动。
- 文本型 PDF 优先段级对应;扫描件退化为页级对应。
- 译文支持纯文本、表格可编辑、段落可新增/删除。
- 最终导出 Word。
---
## 2. 已确认边界
- 译文按段块编辑。
- 允许新增段落、删除段落。
- 不允许合并段落、拆分段落。
- 新增段落插在当前段后面,并自动继承上一段样式。
- 译文只允许纯文本。
- 表格仅改单元格文本,允许增删行列。
- 图片 / 公式先原样保留,不做编辑。
- 左原右译,右侧自然流式,但尽量与左侧页对齐。
- 滚动时左右页尽量同步。
- 导航粒度到章节标题。
---
## 3. 现有代码现状
- 页面入口:`src/views/intelligentTranslation/documentCompare.vue`
- 服务层:`src/views/intelligentTranslation/services/documentCompareService.js`
- 静态数据:`src/views/intelligentTranslation/merged_ans.json`
- 已有能力:
- 页面数据按页获取
- 章节大纲生成
- 页面滚动容器
- 节点选择 / 页面跳转基础逻辑
---
## 4. 推荐技术路线
- 原文:直接 PDF 预览。
- 译文:Tiptap。
- 数据层:把 `merged_ans.json` 适配成统一 block 模型。
- 导出:从 block 模型生成 Word。
- Markdown:仅作为辅助,不作为主模型。
---
## 5. 统一数据模型
### 必须保留的字段
- `id`
- `pageIdx`
- `type`
- `text`
- `level`
- `style`
- `sourceNodeId`
- `translationNodeId`
- `children`
### 基本映射
- `heading` → 标题块
- `paragraph` → 段落块
- `table` → 表格块
- `list` → 列表块
- `image` / `formula` → 只读占位块
### 页对应规则
- 有真实文本的 PDF:优先按 `pageIdx + 顺序` 对齐。
- 扫描件:只做页级对应。
---
## 6. 分阶段任务清单
### 阶段 1:静态数据适配层
目标:把 `merged_ans.json` 转成前端可消费的 block 数据。
任务:
- [ ] 提取页面、节点、章节大纲。
- [ ] 为每个节点保留稳定 ID。
- [ ] 生成统一 block schema。
- [ ] 生成页映射和章节映射。
- [ ] 生成右侧 Tiptap 初始文档数据。
验收:
- 能稳定拿到页、标题、段落、表格等块。
- 每个块都有唯一 ID。
- 页面和章节能正常定位。
---
### 阶段 2:左侧 PDF 原文预览
目标:左侧直接预览原始 PDF。
任务:
- [ ] 接入 PDF 预览组件或 PDF.js。
- [ ] 固定左侧页面渲染上下文。
- [ ] 保持缩放、滚动、页定位能力。
- [ ] 不再依赖 JSON 重建原文版式。
验收:
- 原文页面视觉接近 PDF 原图。
- 不再出现 JSON 重建导致的文字重叠。
---
### 阶段 3:右侧 Tiptap 译文编辑器
目标:实现按段块编辑的右侧译文区。
任务:
- [ ] 初始化 Tiptap。
- [ ] 支持正文、一级标题、二级标题等预设样式。
- [ ] 支持纯文本段落编辑。
- [ ] 支持段落新增。
- [ ] 支持段落删除。
- [ ] 支持表格单元格文本编辑。
- [ ] 支持增删表格行列。
验收:
- 右侧可编辑纯文本块。
- 样式仅通过预设切换。
- 表格能按要求编辑。
---
### 阶段 4:页对应 / 段级高亮 / 导航跳转
目标:建立原文与译文的联动。
任务:
- [ ]`pageIdx` 建立页锚点。
- [ ] 建立原文块与译文块的一一映射。
- [ ] 点击目录跳转到章节。
- [ ] 点击块时高亮对应块。
- [ ] 滚动时同步更新当前页。
- [ ] 扫描件场景降级为页级对应。
验收:
- 章节跳转可用。
- 左右块能联动高亮。
- 滚动时页状态正确更新。
---
### 阶段 5:Word 导出
目标:将编辑结果导出为 docx。
任务:
- [ ] 从 block 模型生成 Word 结构。
- [ ] 保留标题层级。
- [ ] 保留表格结构。
- [ ] 图片 / 公式保持原样占位或原样保留。
- [ ] 生成下载文件。
验收:
- Word 内容正确。
- 标题层级正确。
- 表格尽量保留。
- 能正常导出下载。
---
## 7. 当前风险
- `page_idx` 可能不是绝对真实页码,需要验证。
- 右侧流式编辑与左侧页同步天然存在偏差。
- 扫描件无法做真正段级对齐。
- 复杂表格导出可能与原文存在差异。
---
## 8. 当前待确认项
- `page_idx` 是否为真实 PDF 页码。
- 表格单元格是否只允许纯文本。
- 图片 / 公式导出时是否保留原对象。
- Word 是否要求尽量分页一致。
---
## 9. 执行规则
1. 只做当前阶段,不提前做后续阶段。
2. 当前阶段完成后,先由用户验收。
3. 验收通过后,更新本文件进度,再进入下一阶段。
4. 所有后续改动必须围绕统一 block 模型展开。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论