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

feat 智能翻译接入接口、部分优化

上级 7f1603ac
流水线 #581 已失败 于阶段
in 1 分 3 秒
import request from '@/api/request.js'
const INTELLIGENT_TRANSLATION_BASE_URL = '/doc-detail-pdf-api'
const INTELLIGENT_TRANSLATION_HEADERS = {
'Client-ID': 'test_client',
'X-API-Key': 'test_key'
}
export function submitTranslationTask(file) {
const formData = new FormData()
formData.append('files', file)
return request({
url: `${INTELLIGENT_TRANSLATION_BASE_URL}/submit`,
method: 'POST',
data: formData,
headers: INTELLIGENT_TRANSLATION_HEADERS
})
}
export function queryTranslationTaskStatus(taskId) {
return request({
url: `${INTELLIGENT_TRANSLATION_BASE_URL}/status/${taskId}`,
method: 'GET',
headers: INTELLIGENT_TRANSLATION_HEADERS
})
}
export function queryTranslationTaskResult(taskId) {
return request({
url: `${INTELLIGENT_TRANSLATION_BASE_URL}/result/${taskId}`,
method: 'GET',
headers: INTELLIGENT_TRANSLATION_HEADERS
})
}
......@@ -59,35 +59,68 @@
class="page-block"
:class="{ 'page-block--active': currentPageId === page.pageId }"
>
<div class="page-block__header">{{ page.pageTitle }}</div>
<div class="page-block__body">
<div class="page-block__body" :class="{ 'page-block__body--absolute': page.pageLayoutMode === 'absolute' }">
<template v-if="page.pageLayoutMode === 'absolute'">
<div class="page-column">
<div class="page-canvas-wrapper">
<div class="page-canvas">
<article
v-for="item in page.items"
:key="`${page.pageId}-${item.id}`"
class="page-item"
:key="`${page.pageId}-${item.id}-en`"
class="page-node page-node--en"
:class="[getBlockVariantClass(item.blockType), shouldCenterBlock(item) ? 'page-node--center' : '', ENABLE_DEBUG_BOX ? 'page-node--debug' : '']"
:style="getBlockStyle(item)"
:data-section-id="item.id"
>
<div class="page-item__text page-item__text--en" :class="[getBlockVariantClass(item.type), shouldCenterBlock(item) ? 'page-item__text--center' : '']">
<div v-if="item.type === 'table' && item.tableHtml" class="page-item__html" v-html="item.tableHtml"></div>
<div class="page-node__content">
<div v-if="item.blockType === 'table' && item.tableHtml" class="page-item__html" v-html="item.tableHtml"></div>
<img
v-else-if="item.type === 'image' && item.previewImgPath"
v-else-if="item.blockType === 'image'"
class="page-item__image"
:src="item.previewImgPath"
:src="getEnglishImageSrc(item)"
alt=""
@error="onEnglishImageError(item)"
/>
<div v-else class="page-item__content" v-html="renderBlockHtml(item.text, item.styleSpans)"></div>
</div>
<div class="page-item__text page-item__text--zh" :class="[getBlockVariantClass(item.type), shouldCenterBlock(item) ? 'page-item__text--center' : '']">
<div v-if="item.type === 'table' && item.translatedTableHtml" class="page-item__html" v-html="item.translatedTableHtml"></div>
<span v-if="ENABLE_DEBUG_BOX" class="page-node__debug-label">{{ getBlockTypeLabel(item.blockType) }}</span>
</article>
</div>
</div>
</div>
<div class="page-column">
<div class="page-canvas-wrapper">
<div class="page-canvas">
<slot name="zh-page-canvas" :page="page"></slot>
</div>
</div>
</div>
</template>
<template v-else>
<article
v-for="item in page.items"
:key="`${page.pageId}-${item.id}`"
class="page-item"
:data-section-id="item.id"
>
<div class="page-item__text page-item__text--en" :class="[getBlockVariantClass(item.blockType), shouldCenterBlock(item) ? 'page-item__text--center' : '']">
<div v-if="item.blockType === 'table' && item.tableHtml" class="page-item__html" v-html="item.tableHtml"></div>
<img
v-else-if="item.type === 'image' && item.previewImgPath"
v-else-if="item.blockType === 'image'"
class="page-item__image"
:src="item.previewImgPath"
:src="getEnglishImageSrc(item)"
alt=""
@error="onEnglishImageError(item)"
/>
<div v-else class="page-item__content" v-html="renderBlockHtml(item.translatedText || item.text || '(待接入译文)', item.styleSpans)"></div>
<div v-else class="page-item__content" v-html="renderBlockHtml(item.text, item.styleSpans)"></div>
</div>
<div class="page-item__text page-item__text--zh" :class="[getBlockVariantClass(item.blockType), shouldCenterBlock(item) ? 'page-item__text--center' : '']">
<slot name="zh-flow-item" :item="item" :page="page"></slot>
</div>
</article>
</template>
</div>
</section>
</div>
......@@ -107,6 +140,9 @@ import comparePlaceholderUrl from './icons/container-1885-placeholder.png'
import mergedAns from './merged_ans.json'
const route = useRoute()
const ENABLE_ABSOLUTE_LAYOUT = true
const ENABLE_DEBUG_BOX = false
const PAGE_BBOX_VALID_THRESHOLD = 0.7
const ESTIMATED_SECONDS = 65
const headerActions = [
......@@ -128,60 +164,34 @@ const isCompareLoading = ref(true)
const elapsedSeconds = ref(0)
const translationProgress = ref(0)
const timerId = ref(null)
const imageLoadErrorMap = ref({})
const treeProps = { label: 'title', children: 'children' }
const translatedTextLookup = new Map([
['page0_node0', 'I. 基本信息'],
['page0_node1', '美国能源部(DOE)'],
['page0_node2', '科学办公室(SC)、关键矿产与能源创新办公室(CMEI)、环境管理办公室(EM)、电力办公室(OE)、核能办公室(NE)以及碳氢化合物与地热能源办公室(HGEO)'],
['page0_node3', '执行摘要'],
['page0_node4', 'DOE 相关办公室现公开征集跨学科团队申请,围绕 Genesis Mission 国家科学与技术挑战,推动利用新型人工智能模型和框架加速科学发现与研发流程。'],
['page0_node5', 'DOE 正在征集 FY26 第一阶段小团队与第二阶段大团队申请,涵盖先进制造、生物技术、关键材料、核裂变、核聚变、量子信息科学、半导体与微电子、发现科学和能源等领域。'],
['page0_node6', '本次 RFA 将继续开放,允许 FY26 第一阶段获奖者申请更大规模的第二阶段资助。'],
['page0_node7', '在对应截止日期之后,仍可提交第一阶段和第二阶段申请,但 DOE 保留拒绝此类申请且不予审查的权利。'],
['page0_node8', '1. Genesis Mission 联盟成员'],
['page0_node9', 'Genesis Mission 联盟成员可提交申请,资金将通过 DOE 的其他交易授权(OTA)提供。'],
['page1_node0', '就 OTA 而言,本 RFA 的部分行政规定可能不适用于申请人或其分包商。'],
['page1_node1', '请注意,是否属于 Genesis Mission 联盟成员并非申请本 RFA 或参与入选项目的必要条件。'],
['page1_node2', '所有申请人注意事项:'],
['page1_node3', '获得本 RFA 下的奖励并不赋予,也不要求申请人加入 Genesis Mission 联盟。'],
['page1_node4', '申请人可在申请中提议非本土实体作为分包方。'],
['page1_node5', '资助详情'],
['page1_node6', '预计总可用资金、奖项数量、单项资助金额与项目周期见表。'],
['page1_node7', '关键信息'],
['page1_node8', 'RFA 名称、编号、公告类型、援助清单、法定授权与适用法规。'],
['page2_node0', '表格信息摘要'],
['page2_node1', '关键日期'],
['page2_node2', '关键日期见本 RFA 封面页。'],
['page2_node3', '提交组队要求'],
['page2_node4', '本 RFA 要求提交内容为多机构团队。'],
['page2_node5', '机构联系信息'],
['page2_node6', 'Grants.gov 客服与项目联系人信息。'],
['page2_node7', '说明性网络研讨会 / 办公时间'],
['page2_node8', 'DOE 将于 2026 年 3 月 26 日星期四下午 3 点(东部时间)举办说明性网络研讨会。'],
['page2_node9', '建议'],
['page2_node10', '建议尽早完成各系统注册并尽早提交申请。'],
['page2_node11', '3']
])
const displayFileName = computed(() => {
const fileName = route.query.fileName
return typeof fileName === 'string' && fileName ? fileName : '未命名文档(占位)'
})
const compareLoadingText = computed(() => `文档解析中,预计用时${ESTIMATED_SECONDS}秒,已用时${elapsedSeconds.value}秒...`)
const pageBlocks = computed(() => {
return normalizedPages.value.map((page) => ({
return normalizedPages.value.map((page) => {
const pageItems = normalizedBlocks.value
.filter((block) => block.pageIdx === page.pageIdx)
.sort((a, b) => a.readingOrder - b.readingOrder)
const absoluteCount = pageItems.filter((item) => item.layoutMode === 'absolute').length
const hitRate = pageItems.length ? absoluteCount / pageItems.length : 0
const pageLayoutMode = ENABLE_ABSOLUTE_LAYOUT && pageItems.length > 0 && hitRate >= PAGE_BBOX_VALID_THRESHOLD ? 'absolute' : 'flow'
return {
pageId: `page-${page.pageIdx}`,
pageTitle: `第 ${page.pageIdx + 1} 页`,
items: normalizedBlocks.value.filter((block) => block.pageIdx === page.pageIdx)
}))
pageLayoutMode,
items: pageItems
}
})
})
function shouldCenterBlock(block) {
if (!block) return false
if (block.type !== 'heading') return false
if (block.blockType !== 'heading') return false
const text = String(block.text || '').trim()
if (!text) return false
const strongHeading = block.headingLevel != null
......@@ -210,6 +220,39 @@ function getBlockVariantClass(type) {
return `document-block--${type || 'unknown'}`
}
function isValidBboxNorm(rawBboxNorm) {
if (!Array.isArray(rawBboxNorm) || rawBboxNorm.length !== 4) return false
const [l, t, r, b] = rawBboxNorm.map((value) => Number(value))
if (![l, t, r, b].every((value) => Number.isFinite(value))) return false
if (l < 0 || t < 0 || r > 1 || b > 1) return false
if (r <= l || b <= t) return false
return true
}
function normalizeBboxFromPixel(rawBbox, pageMeta) {
if (!Array.isArray(rawBbox) || rawBbox.length !== 4) return []
const pageWidth = Number(pageMeta?.width || 0)
const pageHeight = Number(pageMeta?.height || 0)
if (pageWidth <= 0 || pageHeight <= 0) return []
const [l, t, r, b] = rawBbox.map((value) => Number(value))
if (![l, t, r, b].every((value) => Number.isFinite(value))) return []
return [l / pageWidth, t / pageHeight, r / pageWidth, b / pageHeight]
}
function getBlockStyle(item) {
if (!isValidBboxNorm(item?.bboxNorm)) return {}
const [l, t, r, b] = item.bboxNorm
const baseHeightPercent = (b - t) * 100
return {
left: `${l * 100}%`,
top: `${t * 100}%`,
width: `${(r - l) * 100}%`,
height: `${Math.min(100 - t * 100, baseHeightPercent)}%`,
position: 'absolute',
overflow: 'hidden'
}
}
function escapeHtml(text = '') {
return String(text)
.replaceAll('&', '&amp;')
......@@ -247,9 +290,42 @@ function renderBlockHtml(text = '', styleSpans = []) {
return html.replaceAll('\n', '<br />')
}
function resolveImageUrl(rawPath) {
const path = String(rawPath || '').trim()
if (!path) return ''
if (/^(https?:)?\/\//i.test(path) || /^(data|blob):/i.test(path)) return path
const normalizedPath = path.replaceAll('\\', '/')
const baseUrl = String(
import.meta.env.VITE_INTELLIGENT_TRANSLATION_ASSET_BASE_URL || import.meta.env.VITE_BASE_FILE_URL || ''
).trim()
if (baseUrl) {
return `${baseUrl.replace(/\/+$/, '')}/${normalizedPath.replace(/^\/+/, '')}`
}
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`
}
function getEnglishImageSrc(item) {
if (!item?.id) return comparePlaceholderUrl
if (imageLoadErrorMap.value[item.id]) return comparePlaceholderUrl
const resolvedUrl = resolveImageUrl(item.imagePath)
return resolvedUrl || comparePlaceholderUrl
}
function onEnglishImageError(item) {
if (!item?.id) return
imageLoadErrorMap.value = {
...imageLoadErrorMap.value,
[item.id]: true
}
}
function buildNormalizedBlocks(rawData) {
const pages = Array.isArray(rawData?.data?.pages) ? rawData.data.pages : []
const nodes = Array.isArray(rawData?.data?.merged_nodes) ? rawData.data.merged_nodes : []
const pageSizeMap = pages.reduce((acc, page) => {
acc[page.page_idx] = { width: page.width, height: page.height }
return acc
}, {})
normalizedPages.value = pages.map((page) => ({
pageIdx: page.page_idx,
......@@ -261,25 +337,36 @@ function buildNormalizedBlocks(rawData) {
normalizedBlocks.value = nodes.map((node, index) => {
const contentPayload = node?.content_payload || {}
const type = mapSemanticTypeToNodeType(node.semantic_type, contentPayload)
const blockType = mapSemanticTypeToNodeType(node.semantic_type, contentPayload)
const bbox = Array.isArray(node?.layout?.bbox) ? node.layout.bbox : []
const originBboxNorm = Array.isArray(node?.layout?.bbox_norm) ? node.layout.bbox_norm : []
const pageMeta = pageSizeMap[node.page_idx] || null
const convertedBboxNorm = normalizeBboxFromPixel(bbox, pageMeta)
const bboxNorm = isValidBboxNorm(originBboxNorm) ? originBboxNorm : (isValidBboxNorm(convertedBboxNorm) ? convertedBboxNorm : [])
const layoutMode = isValidBboxNorm(bboxNorm) ? 'absolute' : 'flow'
return {
id: node.node_id || `node_${index}`,
nodeId: node.node_id || '',
pageIdx: node.page_idx ?? null,
type,
type: blockType,
blockType,
semanticType: node.semantic_type || 'unknown',
readingOrder: node?.layout?.reading_order ?? index,
title: contentPayload.text || node.node_id || `未命名块${index + 1}`,
text: contentPayload.text || '',
translatedText: translatedTextLookup.get(node.node_id) || '',
translatedText: '',
html: contentPayload.html || '',
headingLevel: contentPayload.heading_level ?? null,
tableHtml: contentPayload.table_body_html || '',
translatedTableHtml: contentPayload.table_body_html || '',
imagePath: contentPayload.img_path || contentPayload.preview_img_path || '',
previewImgPath: contentPayload.preview_img_path || '',
bbox,
bboxNorm,
layoutMode,
layout: {
bbox: node?.layout?.bbox || [],
bboxNorm: node?.layout?.bbox_norm || [],
bbox,
bboxNorm,
angle: node?.layout?.angle ?? 0,
link: node?.layout?.link ?? null,
zIndex: node?.layout?.z_index ?? null
......@@ -297,7 +384,7 @@ function buildNavigationTree(rawData) {
const nodeType = mapSemanticTypeToNodeType(node.semantic_type, contentPayload)
const text = contentPayload.text || node.node_id || '未命名块'
if (nodeType !== 'heading') return null
if (!shouldCenterBlock({ ...node, text })) return null
if (!shouldCenterBlock({ ...node, blockType: nodeType, headingLevel: contentPayload.heading_level, text })) return null
return {
id: node.node_id || `heading_${index}`,
title: text,
......@@ -353,7 +440,7 @@ function getFirstNavigableNode(nodes) {
return nodes.find((node) => node?.id) || null
}
onMounted(async () => {
onMounted(() => {
const requestStartTime = Date.now()
const updateElapsedSeconds = () => {
elapsedSeconds.value = Math.floor((Date.now() - requestStartTime) / 1000)
......@@ -371,6 +458,8 @@ onMounted(async () => {
currentNodeKey.value = firstNode.id
currentPageId.value = `page-${firstNode.pageIdx ?? 0}`
}
} catch (error) {
ElMessage.error(error?.message || '本地 JSON 加载失败')
} finally {
isCompareLoading.value = false
}
......@@ -451,21 +540,46 @@ onUnmounted(() => {
.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-block { display: flex; flex-direction: column; gap: 12px; padding: 12px 0 20px; border-bottom: 1px solid #d9e2ec; }
.page-block--active { background: #f8fafc; }
.page-block__header { font-family: 'Source Han Sans CN', sans-serif; font-size: 15px; font-weight: 700; line-height: 24px; color: #101828; }
.page-block__body { display: flex; flex-direction: column; gap: 12px; }
.page-block__body--absolute { flex-direction: row; align-items: flex-start; }
.page-column { width: calc(50% - 6px); min-width: 0; }
.page-canvas-wrapper { width: 100%; }
.page-canvas { position: relative; width: 100%; aspect-ratio: 21 / 29.7; border: 1px solid #e4e7ec; border-radius: 8px; overflow: hidden; background: #fff; }
.page-node { position: absolute; box-sizing: border-box; padding: 0; }
.page-node--en { border-right: 1px solid #eef2f7; }
.page-node__content { width: 100%; height: 100%; overflow: hidden; padding: 1px 2px 4px; box-sizing: border-box; }
.page-node--center .page-item__content { text-align: center; }
.page-node--debug { border: 1px dashed #1677ff; }
.page-node__debug-label { position: absolute; left: 0; top: 0; font-size: 10px; line-height: 14px; padding: 0 4px; color: #fff; background: rgba(22, 119, 255, 0.8); }
.page-item { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: stretch; }
.page-item__text { min-height: 72px; padding: 0; background: transparent; border: none; }
.page-item__text--en { border-right: 1px solid #eef2f7; padding-right: 12px; }
.page-item__text--zh { padding-left: 12px; }
.page-item__text--center .page-item__content { text-align: center; }
.page-item__content { font-size: 14px; line-height: 1.8; color: #344054; white-space: pre-wrap; word-break: break-word; }
.page-item__content { font-size: 14px; line-height: 1.85; color: #344054; white-space: pre-wrap; word-break: break-word; }
.page-item__html :deep(table) { width: 100%; border-collapse: collapse; font-size: 13px; }
.page-item__html :deep(td), .page-item__html :deep(th) { border: 1px solid #d0d5dd; padding: 8px; vertical-align: top; }
.page-item__image { max-width: 100%; display: block; border-radius: 6px; }
.page-item__image { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 6px; }
.page-item--heading .page-item__content { font-size: 18px; font-weight: 700; color: #101828; }
.page-item--paragraph .page-item__content { font-size: 14px; }
.page-item--table .page-item__content { font-size: 13px; }
.page-item--image .page-item__content { font-size: 13px; color: #667085; }
.page-item--page_number .page-item__content { font-size: 12px; color: #98a2b3; }
.page-item--unknown .page-item__content { color: #667085; font-style: italic; }
.page-node--en.document-block--heading .page-item__content,
.page-node--en.document-block--paragraph .page-item__content {
line-height: 1.9;
padding-bottom: 3px;
}
.page-node--en.document-block--table .page-item__content,
.page-node--en.document-block--page_number .page-item__content {
line-height: 1.9;
padding-bottom: 3px;
}
@media (max-width: 1360px) {
.page-block__body { gap: 10px; }
.page-block__body--absolute { flex-direction: column; }
.page-column { width: 100%; }
}
</style>
......@@ -10,8 +10,8 @@
<img class="workspace-toolbar-icon" src="@/assets/icons/tool-item-icon2.png" alt="" />
<span class="workspace-toolbar-heading">智能翻译</span>
</div>
<button class="translate-btn" type="button" @click="onTranslate">
AI翻译
<button class="translate-btn" type="button" :disabled="submitLoading" @click="onTranslate">
{{ submitLoading ? '提交中...' : 'AI翻译' }}
</button>
</div>
......@@ -22,7 +22,7 @@
<div v-else class="upload-file-panel">
<ul class="upload-file-list">
<li v-for="(file, index) in selectedFiles" :key="`${file.name}-${index}`" class="upload-file-item">
<li v-for="(file, index) in selectedFiles" :key="`${file.name}-${file.lastModified}-${file.size}`" class="upload-file-item">
<img class="upload-file-type-icon" :src="fileTypeIconUrl" alt="" />
<span class="upload-file-name">{{ file.name }}</span>
<button type="button" class="upload-file-item-delete" aria-label="删除该文件" @click="removeFileAt(index)">
......@@ -90,8 +90,10 @@
<script setup>
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import IntelligenceLeftTabBar from '@/components/intelligenceLeftTabBar/index.vue'
import { submitTranslationTask } from '@/api/intelligentTranslation/index'
import { uploadRecordMock } from './mock'
import fileTypeIconUrl from './icons/file-type-icon.png'
import lineUploadIconSvg from './icons/Line_Upload.svg?raw'
......@@ -106,24 +108,45 @@ const sourceText = ref('')
const translatedText = ref('点击“翻译”后展示占位结果')
const selectedFiles = ref([])
const uploadRecords = ref(uploadRecordMock)
const submitLoading = ref(false)
const isTextMode = computed(() => selectedFiles.value.length === 0)
const onTranslate = () => {
const onTranslate = async () => {
if (isTextMode.value) {
const text = sourceText.value.trim()
translatedText.value = text
? `【Mock译文】${text}`
: '【Mock译文】当前没有输入文本,请先填写原文内容。'
ElMessage.warning('请先上传 PDF 文件后再发起翻译')
return
}
const targetFile = selectedFiles.value[0]
if (!targetFile) {
ElMessage.warning('请先上传 PDF 文件')
return
}
if (submitLoading.value) {
return
}
submitLoading.value = true
try {
const response = await submitTranslationTask(targetFile)
const taskId = response?.data?.task_id || response?.data?.task_ids?.[0]
if (!taskId) {
throw new Error('未获取到任务ID')
}
router.push({
name: 'intelligentTranslationDocument',
query: {
fileName: selectedFiles.value[0]?.name || ''
taskId,
fileName: targetFile.name || ''
}
})
} catch (error) {
ElMessage.error(error?.message || '翻译任务提交失败,请稍后重试')
} finally {
submitLoading.value = false
}
}
const onFileChange = (event) => {
......@@ -291,6 +314,11 @@ function formatRecordDateOnly(timeStr) {
cursor: pointer;
}
.translate-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.workspace-source-block {
box-sizing: border-box;
flex: 1;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论