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

feat 更新智能翻译

上级 482124c8
流水线 #544 已通过 于阶段
in 1 分 48 秒
# 智能翻译开发任务拆分(Checklist)
> 目标:按最小可交付顺序完成“智能翻译”功能,并保持结构清晰、便于后续接接口与调整样式。
## 阶段 1:入口与路由
- [x]`moduleHeader` 增加“智能翻译”入口(图标 `tool-item-icon2.png`
- [x] 配置主路由 `intelligentTranslation`
- [x] 配置文档对照页路由(建议 `intelligentTranslationDocument`
- [x] 本地验证:点击入口可进入主页面
## 阶段 2:页面骨架搭建
- [x] 创建目录 `src/views/intelligentTranslation/`
- [x] 创建 `index.vue`(主页面)
- [x] 创建 `documentCompare.vue`(文档对照页)
- [x] 引入左侧通用组件 `src/components/intelligenceLeftTabBar/index.vue`
- [x] 搭建基础布局结构(左侧导航 + 右侧内容区)
## 阶段 3:主页面核心交互(需求 a)
- [x] 实现“文本模式”默认视图
- [x] 原文输入框
- [x] 上传文档按钮
- [x] 翻译按钮(位于右上)
- [x] 右侧翻译内容区
- [x] 实现文本翻译占位逻辑(点击翻译后展示 mock 结果)
- [x] 实现“文件模式”切换逻辑
- [x] 上传后输入区替换为上传文件列表
- [x] 右侧翻译内容区隐藏
- [x] 实现文件模式“翻译”按钮跳转到文档对照页
- [x] 传递上传文件名到对照页
## 阶段 4:上传记录区(需求 a 下半区)
- [x] 在主页面底部实现上传记录区域
- [x] 以占位数据展示:文档名、时间
- [x] 保持结构和类名清晰,便于后续替换为接口
## 阶段 5:文档对照页实现(需求 b)
- [x] 顶部 header 显示文档名称(来自上传文件名)
- [x] 左侧实现文章标题树(mock)
- [x] 右侧实现段落对照(左原文右译文)
- [x] 实现点击树节点后右侧过滤/定位对应段落
## 阶段 6:占位数据与类型管理
- [x] 新建 `mock.js` 统一管理页面 mock 数据
- [x] (可选)不使用 `types.ts`,采用 JS 方案
- [x] 在关键位置加 `TODO: replace with API`
## 阶段 7:样式与可维护性
- [x] 输出简洁样式(不做复杂视觉)
- [x] 统一语义化 class 命名
- [x] 检查结构是否便于后续改样式
## 阶段 8:联调与验收
- [x] 从入口进入主页面流程完整可跑通
- [x] 文本翻译流程可用(占位)
- [x] 文档上传 -> 文件模式 -> 跳转对照页流程可用
- [x] 对照页标题、树、段落对照展示正确
- [x] 检查并修复本次改动引入的 lint 问题
- [x] 左侧导航“智能翻译”支持直接跳转并高亮
# 智能翻译页面实现计划
## 1. 目标概述
- 新增“智能翻译”页面,并接入现有入口与路由。
- 页面包含两个视图:
- a)主页面:文本翻译 / 文档上传翻译 + 上传记录
- b)文档对照页面:文档标题 + 文章导航树 + 原文/译文段落对照
- 当前阶段全部使用前端占位数据,不接真实接口。
---
## 2. 入口与路由接入
### 2.1 moduleHeader 入口
- 文件:`src/components/base/moduleHeader/index.vue`
- 在指定区域(315-326 附近)新增“智能翻译”入口
- 文案:`智能翻译`
- 图标:`@/assets/icons/tool-item-icon2.png`
- 点击跳转到路由名:`intelligentTranslation`
### 2.2 路由
- 新增主路由:
- `name: intelligentTranslation`
- `path: /intelligent-translation`(可按项目现有习惯微调)
- `component: src/views/intelligentTranslation/index.vue`
- 新增文档对照页路由:
- 建议 `name: intelligentTranslationDocument`
- 建议 `path: /intelligent-translation/document`
- `component: src/views/intelligentTranslation/documentCompare.vue`
---
## 3. 目录与文件规划
`src/views/intelligentTranslation/` 下创建:
1. `index.vue`:主页面(需求 a)
2. `documentCompare.vue`:文档对照页面(需求 b)
3. `mock.ts`(可选):占位数据统一管理
4. `types.ts`(可选):类型定义,便于后续接接口
---
## 4. 页面 a(主页面)实现方案
## 4.1 布局结构
- 页面最左侧使用通用组件:`src/components/intelligenceLeftTabBar/index.vue`
- 右侧为主工作区:
- 上方约 2/3:翻译操作区
- 下方约 1/3:文档翻译上传记录
## 4.2 上方翻译区状态
定义两种状态:
### 状态 1:文本模式(默认)
- 左侧:原文输入区(文本输入框)
- 左侧底部:上传文档按钮
- 左侧右上:翻译按钮
- 右侧:翻译内容展示区
- 交互:输入原文并点击翻译,在右侧展示占位翻译结果
### 状态 2:文件模式(上传文件后)
- 上传后左侧输入区切换为“上传文件列表”
- 右侧翻译内容区隐藏
- 左侧右上继续保留翻译按钮
- 点击翻译按钮后跳转到页面 b
## 4.3 上传记录区
- 展示“文档名 + 时间”两列(占位数据)
- 数据来源先用 mock,后续替换为接口
---
## 5. 页面 b(文档对照页)实现方案
## 5.1 页面结构
- 顶部 Header:显示文档名称(来自上传文件名)
- 下方左右两栏:
- 左侧:文章导航树(占位数据模拟接口)
- 右侧:原文/译文按段落一一对照
## 5.2 数据与交互
- 树数据:模拟章节结构(可含 children)
- 段落数据:`[{ sectionId, sourceText, translatedText }]`
- 交互:点击左侧章节,右侧过滤或定位对应段落
---
## 6. 占位数据策略
- 所有“接口返回数据”先统一在 `mock.ts` 管理,避免散落到组件内。
- 页面仅依赖数据结构,后续接真实接口时只替换数据来源。
- 在关键数据位置添加 `TODO: replace with API` 便于后续联调。
---
## 7. 样式与结构约束
- 样式先做简洁版,保证可读与可用。
- 重点保证 HTML 结构和 class 命名清晰,便于后续二次改样式。
- 建议类名示例:
- `intelligent-translation-page`
- `translation-main`
- `source-upload-panel`
- `translation-result-panel`
- `upload-record-list`
- `document-compare-page`
- `article-nav-tree`
- `paragraph-compare-row`
---
## 8. 验收清单
1. moduleHeader 中新增“智能翻译”入口并可跳转
2. 主页面默认文本模式可输入并生成占位翻译结果
3. 上传文档后切换到文件模式(输入区变文件列表,右侧翻译区隐藏)
4. 文件模式点击翻译可进入文档对照页
5. 文档对照页 header 正确显示上传文件名
6. 文档对照页左侧展示文章树,右侧按段落对照展示原文/译文
7. 上传记录区展示占位“文档名+时间”
8. 代码结构清晰,便于后续样式和接口替换
<template> <template>
<div class="document-compare-page"> <div class="document-compare-page" @click="onPageClick">
<intelligence-left-tab-bar /> <intelligence-left-tab-bar />
<div class="compare-main"> <div class="compare-main">
<div v-if="isCompareLoading" class="compare-loading-placeholder"> <header class="page-header">
<img <div class="page-header-left">
class="compare-loading-placeholder-img" <img class="header-file-icon" src="./icons/file-type-icon.png" alt="" />
:src="comparePlaceholderUrl" <span class="page-header-file-name">{{ displayFileName }}</span>
alt=""
/>
</div> </div>
<template v-else> <div v-if="isCompareLoading" class="page-header-status">
<div class="compare-header"> <span class="status-loading-dot" />
<div class="header-title">文档对照</div> <span class="page-header-status-title">文档翻译中</span>
<div class="header-file-name">{{ displayFileName }}</div> <span class="page-header-status-text">
已完成...{{ translationProgress }}%,已用时{{ elapsedSeconds }}
</span>
</div> </div>
<div class="compare-content"> <div v-else class="page-header-action-group" @click.stop>
<div class="article-nav-tree">
<div class="tree-title">文章标题</div>
<div class="tree-list">
<button <button
v-for="item in articleTree" v-for="action in headerActions"
:key="item.id" :key="action.key"
type="button" type="button"
class="tree-item" class="header-action-btn"
:class="{ active: activeSectionId === item.id }" :class="action.variant === 'primary' ? 'header-action-btn--primary' : ''"
@click="onSelectSection(item.id)" @click="onHeaderAction(action.key)"
> >
{{ item.title }} {{ action.label }}
</button> </button>
</div> </div>
</header>
<div class="compare-body">
<div v-if="isCompareLoading" class="compare-loading-placeholder">
<img class="compare-loading-placeholder-img" :src="comparePlaceholderUrl" alt="" />
<div class="compare-loading-placeholder-text">{{ compareLoadingText }}</div>
</div>
<template v-else>
<div class="compare-content">
<div class="article-nav-tree">
<div class="tree-title">导航</div>
<el-tree
:data="articleTree"
node-key="id"
:props="treeProps"
:current-node-key="currentNodeKey"
highlight-current
@node-click="onClickTreeNode"
/>
</div> </div>
<div class="paragraph-compare-wrapper">
<div class="paragraph-compare-panel"> <div class="paragraph-compare-panel">
<div class="panel-title">段落对照</div> <div class="panel-title">段落对照</div>
<div class="paragraph-list"> <div class="paragraph-list">
<div <div
v-for="paragraph in displayParagraphs" v-for="section in sectionBlocks"
:key="section.id"
class="section-block"
:data-section-id="section.id"
>
<div class="section-title">{{ section.title }}</div>
<div
v-for="paragraph in section.paragraphs"
:key="paragraph.id" :key="paragraph.id"
class="paragraph-compare-row" class="paragraph-compare-row"
:class="{ 'paragraph-compare-row--active': isSelectedParagraph(paragraph.id) }"
@click.stop="toggleParagraphSelection(paragraph)"
> >
<div class="paragraph-cell source-cell"> <div class="paragraph-cell source-cell">
<div class="cell-label">原文</div> <div class="cell-label">原文</div>
...@@ -51,67 +78,199 @@ ...@@ -51,67 +78,199 @@
<div class="cell-content">{{ paragraph.translatedText }}</div> <div class="cell-content">{{ paragraph.translatedText }}</div>
</div> </div>
</div> </div>
</div>
<div v-if="displayParagraphs.length === 0" class="empty-tip"> <div v-if="sectionBlocks.length === 0" class="empty-tip">当前章节暂无段落占位数据</div>
当前章节暂无段落占位数据
</div> </div>
</div> </div>
<transition name="fade">
<aside v-if="showParagraphActionPanel" class="paragraph-action-panel" @click.stop>
<div class="paragraph-action-panel-header">
<button type="button" class="paragraph-action-btn" @click="onParagraphAction('insert')">插入</button>
<button type="button" class="paragraph-action-btn" @click="onParagraphAction('delete')">删除</button>
</div>
<div class="paragraph-style-section">
<div class="paragraph-style-title">样式</div>
<div class="paragraph-style-list">
<button
v-for="style in paragraphStyles"
:key="style.key"
type="button"
class="paragraph-style-btn"
:class="{ 'paragraph-style-btn--active': isStyleSelected(style.key) }"
@click="onStyleSelect(style.key)"
>
{{ style.label }}
</button>
</div>
</div>
</aside>
</transition>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import IntelligenceLeftTabBar from '@/components/intelligenceLeftTabBar/index.vue' import IntelligenceLeftTabBar from '@/components/intelligenceLeftTabBar/index.vue'
import comparePlaceholderUrl from './icons/container-1885-placeholder.png' import comparePlaceholderUrl from './icons/container-1885-placeholder.png'
import { fetchDocumentCompareMock } from './mock' import { fetchDocumentComparePageData } from './services/documentCompareService'
const route = useRoute() const route = useRoute()
const ESTIMATED_SECONDS = 65
const headerActions = [
{ key: 'submit-review', label: '提交审核' },
{ key: 'preview', label: '预览' },
{ key: 'export', label: '导出' },
{ key: 'write-report', label: '写报' },
{ key: 'save', label: '保存', variant: 'primary' }
]
const paragraphStyles = [
{ key: 'body', label: '正文' },
{ key: 'chart-title', label: '图表标题' },
{ key: 'figure-caption', label: '图标注释' },
{ key: 'cover-title', label: '封面标题' },
{ key: 'cover-subtitle', label: '封面副标题' },
{ key: 'catalog', label: '目录' },
{ key: 'h1', label: '一级标题' },
{ key: 'h2', label: '二级标题' },
{ key: 'h3', label: '三级标题' },
{ key: 'h4', label: '四级标题' },
{ key: 'author', label: '作者' },
{ key: 'time', label: '时间' },
{ key: 'publisher', label: '发布机构' }
]
const isCompareLoading = ref(true) const isCompareLoading = ref(true)
const elapsedSeconds = ref(0)
const timerId = ref(null)
const translationProgress = ref(0)
const articleTree = ref([])
const paragraphRows = ref([])
const currentNodeKey = ref('')
const selectedParagraphIds = ref([])
const paragraphStyleMap = ref({})
const treeProps = { label: 'title', children: 'children' }
const displayFileName = computed(() => { const displayFileName = computed(() => {
const fileName = route.query.fileName const fileName = route.query.fileName
return typeof fileName === 'string' && fileName return typeof fileName === 'string' && fileName ? fileName : '未命名文档(占位)'
? fileName
: '未命名文档(占位)'
}) })
const articleTree = ref([]) const sectionBlocks = computed(() => {
const tree = Array.isArray(articleTree.value) ? articleTree.value : []
return tree
.filter((item) => item.id)
.map((item) => ({
id: item.id,
title: item.title,
paragraphs: paragraphRows.value.filter((row) => row.sectionId === item.id)
}))
.filter((item) => item.paragraphs.length > 0)
})
const paragraphRows = ref([]) const showParagraphActionPanel = computed(() => selectedParagraphIds.value.length > 0)
const compareLoadingText = computed(
() => `文档解析中,预计用时${ESTIMATED_SECONDS}秒,已用时${elapsedSeconds.value}秒...`
)
const activeSectionId = ref('all') function getFirstNode(nodes) {
if (!Array.isArray(nodes) || nodes.length === 0) return null
const [firstNode] = nodes
if (firstNode.children?.length) return getFirstNode(firstNode.children)
return firstNode
}
const displayParagraphs = computed(() => { async function onClickTreeNode(nodeData) {
if (activeSectionId.value === 'all') { currentNodeKey.value = nodeData.id
return paragraphRows.value const targetSectionId = nodeData.sectionId || nodeData.id
await nextTick()
const targetEl = document.querySelector(`[data-section-id='${targetSectionId}']`)
targetEl?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function toggleParagraphSelection(paragraph) {
const idx = selectedParagraphIds.value.indexOf(paragraph.id)
if (idx >= 0) {
selectedParagraphIds.value = selectedParagraphIds.value.filter((id) => id !== paragraph.id)
return
} }
selectedParagraphIds.value = [...selectedParagraphIds.value, paragraph.id]
}
return paragraphRows.value.filter( function isSelectedParagraph(paragraphId) {
(item) => item.sectionId === activeSectionId.value return selectedParagraphIds.value.includes(paragraphId)
) }
})
function isStyleSelected(styleKey) {
return selectedParagraphIds.value.some((id) => paragraphStyleMap.value[id] === styleKey)
}
const onSelectSection = (sectionId) => { function onStyleSelect(styleKey) {
activeSectionId.value = sectionId selectedParagraphIds.value.forEach((id) => {
paragraphStyleMap.value[id] = styleKey
})
ElMessage.success(`已选择样式:${paragraphStyles.find((item) => item.key === styleKey)?.label || styleKey}`)
console.log('样式选择占位', {
selectedParagraphIds: [...selectedParagraphIds.value],
styleKey
})
}
function onParagraphAction(actionKey) {
ElMessage.info(`段落操作占位:${actionKey}`)
console.log('段落操作占位', {
actionKey,
selectedParagraphIds: [...selectedParagraphIds.value]
})
}
function onHeaderAction(actionKey) {
ElMessage.info(`顶部功能按钮占位:${actionKey}`)
console.log('顶部功能按钮占位', { actionKey })
}
function onPageClick() {
selectedParagraphIds.value = []
} }
onMounted(async () => { onMounted(async () => {
const fileName = const fileName = typeof route.query.fileName === 'string' ? route.query.fileName : ''
typeof route.query.fileName === 'string' ? route.query.fileName : '' const requestStartTime = Date.now()
const updateElapsedSeconds = () => {
elapsedSeconds.value = Math.floor((Date.now() - requestStartTime) / 1000)
}
updateElapsedSeconds()
timerId.value = window.setInterval(updateElapsedSeconds, 1000)
try { try {
const data = await fetchDocumentCompareMock(fileName) const data = await fetchDocumentComparePageData({ fileName })
articleTree.value = data.articleTree articleTree.value = data.articleTree
paragraphRows.value = data.paragraphRows paragraphRows.value = data.paragraphRows
translationProgress.value = Number(data.translationProgress) || 0
const firstNode = getFirstNode(data.articleTree)
if (firstNode) currentNodeKey.value = firstNode.id
} finally { } finally {
isCompareLoading.value = false isCompareLoading.value = false
} }
}) })
onUnmounted(() => {
if (timerId.value !== null) {
clearInterval(timerId.value)
timerId.value = null
}
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
...@@ -125,146 +284,77 @@ onMounted(async () => { ...@@ -125,146 +284,77 @@ onMounted(async () => {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 20px; min-height: calc(100vh - 64px);
gap: 12px;
overflow: auto; overflow: auto;
position: relative;
} }
.compare-loading-placeholder { .page-header {
flex: 1;
min-height: 560px;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-white-100); flex-shrink: 0;
border-radius: 8px; width: 100%;
} height: 64px;
padding: 0 24px;
.compare-loading-placeholder-img {
max-width: 100%;
max-height: min(720px, calc(100vh - 160px));
width: auto;
height: auto;
object-fit: contain;
display: block;
}
.compare-header {
min-height: 56px;
border: 1px solid #e8ecf2;
border-radius: 8px;
padding: 10px 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 16px;
} background: var(--bg-white-100);
box-shadow: 0 0 20px 0 rgba(25, 69, 130, 0.1);
.header-title { border-bottom: 1px solid var(--bg-black-5);
color: #344054;
font-weight: 600;
}
.header-file-name {
color: #475467;
font-size: 14px;
}
.compare-content {
flex: 1;
min-height: 560px;
display: flex;
gap: 12px;
}
.article-nav-tree {
width: 300px;
flex-shrink: 0;
border: 1px solid #e8ecf2;
border-radius: 8px;
background: #fff;
padding: 12px;
display: flex;
flex-direction: column;
}
.tree-title,
.panel-title {
color: #344054;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.tree-item {
height: 36px;
border: 1px solid #d0d5dd;
border-radius: 6px;
background: #fff;
color: #344054;
cursor: pointer;
text-align: left;
padding: 0 10px;
}
.tree-item.active {
border-color: #2563eb;
color: #2563eb;
background: #eff4ff;
} }
.paragraph-compare-panel { .compare-body {
flex: 1; flex: 1;
border: 1px solid #e8ecf2; min-height: 0;
border-radius: 8px;
background: #fff;
padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 8px;
background: var(--color-primary-2);
box-sizing: border-box;
position: relative;
} }
.paragraph-list { .page-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
display: flex; .header-file-icon { width: 24px; height: 24px; flex-shrink: 0; display: block; }
flex-direction: column; .page-header-file-name { font-family: 'Source Han Sans CN', sans-serif; font-size: 18px; font-weight: 400; line-height: 26px; color: var(--text-primary-80-color, #344054); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
gap: 12px; .page-header-status { display: inline-flex; align-items: center; height: 38px; padding: 0 12px; border: 1px solid var(--color-primary-35); border-radius: 50px; background: var(--color-primary-10); gap: 12px; flex-shrink: 0; box-sizing: border-box; }
} .status-loading-dot { width: 14px; height: 14px; border: 2px solid var(--color-primary-35); border-top-color: var(--color-primary-100, #2563eb); border-radius: 50%; animation: compare-spin 0.8s linear infinite; }
.page-header-status-title, .page-header-status-text { font-family: 'Source Han Sans CN', sans-serif; font-size: 14px; line-height: 22px; color: var(--color-primary-100); }
.paragraph-compare-row { .page-header-status-title { font-weight: 700; }
display: grid; .page-header-status-text { font-weight: 400; }
grid-template-columns: 1fr 1fr; .page-header-action-group { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
gap: 12px; .header-action-btn { box-sizing: border-box; padding: 6px 19px; border: 1px solid var(--bg-black-10); border-radius: 6px; background: #fff; font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; color: var(--text-primary-80-color, #344054); cursor: pointer; }
} .header-action-btn--primary { background: var(--color-primary-100); border: none; color: var(--bg-white-100); }
.paragraph-cell { @keyframes compare-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
border: 1px solid #e4e7ec; .compare-loading-placeholder { flex: 1; min-height: 560px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; box-sizing: border-box; background: var(--bg-white-100); border-radius: 8px; }
border-radius: 6px; .compare-loading-placeholder-img { max-width: 100%; max-height: min(720px, calc(100vh - 160px)); width: auto; height: auto; object-fit: contain; display: block; }
padding: 10px; .compare-loading-placeholder-text { font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 16px; line-height: 30px; color: #344054; }
min-height: 120px; .compare-content { flex: 1; min-height: 560px; display: flex; gap: 12px; position: relative; }
} .article-nav-tree, .paragraph-compare-panel { border: 1px solid #e8ecf2; border-radius: 8px; background: #fff; }
.article-nav-tree { width: 280px; flex-shrink: 0; padding: 16px 19px; display: flex; flex-direction: column; }
.cell-label { .tree-title { font-family: 'Source Han Sans CN', sans-serif; font-weight: 700; font-size: 16px; line-height: 24px; letter-spacing: 1px; color: var(--text-primary-80-color); margin-bottom: 17px; }
font-size: 12px; :deep(.article-nav-tree .el-tree-node__label) { font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 14px; line-height: 22px; color: var(--text-primary-80-color); }
color: #667085; .paragraph-compare-wrapper { flex: 1; display: flex; align-items: stretch; gap: 12px; min-width: 0; }
margin-bottom: 8px; .paragraph-compare-panel { flex: 1; min-width: 0; padding: 12px; display: flex; flex-direction: column; }
} .panel-title { color: #344054; font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.paragraph-list { display: flex; flex-direction: column; gap: 12px; overflow: auto; }
.cell-content { .section-block { display: flex; flex-direction: column; gap: 12px; }
font-size: 14px; .section-title { font-family: 'Source Han Sans CN', sans-serif; font-size: 14px; font-weight: 600; line-height: 22px; color: var(--text-primary-80-color, #344054); }
line-height: 22px; .paragraph-compare-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; cursor: pointer; }
color: #344054; .paragraph-compare-row--active .paragraph-cell { outline: 2px solid var(--color-primary-35); }
white-space: pre-wrap; .paragraph-cell { border: 1px solid #e4e7ec; border-radius: 6px; padding: 10px; min-height: 120px; }
} .cell-label { font-size: 12px; color: #667085; margin-bottom: 8px; }
.cell-content { font-size: 14px; line-height: 22px; color: #344054; white-space: pre-wrap; }
.empty-tip { .empty-tip { color: #98a2b3; font-size: 14px; }
color: #98a2b3; .paragraph-action-panel { width: 160px; flex: 0 0 160px; align-self: stretch; background: #fff; border: 1px solid var(--bg-black-10); border-radius: 10px; box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12); padding: 12px; display: flex; flex-direction: column; z-index: 20; }
font-size: 14px; .paragraph-action-panel-header { display: flex; gap: 8px; margin-bottom: 12px; }
} .paragraph-action-btn { flex: 1; border: 1px solid var(--bg-black-10); background: #fff; border-radius: 6px; padding: 6px 12px; font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; cursor: pointer; }
.paragraph-style-title { font-family: 'Source Han Sans CN', sans-serif; font-size: 14px; line-height: 22px; color: #344054; margin-bottom: 8px; }
.paragraph-style-list { display: flex; flex-wrap: wrap; gap: 8px; }
.paragraph-style-btn { flex: 0 1 auto; width: auto; padding: 6px 19px; border: 1px solid var(--bg-black-10); border-radius: 6px; background: #fff; font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; cursor: pointer; color: var(--text-primary-80-color, #344054); }
.paragraph-style-btn--active { border-color: var(--color-primary-100); background: var(--color-primary-2); color: var(--color-primary-100); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style> </style>
...@@ -39,10 +39,24 @@ export const uploadRecordListMock = [ ...@@ -39,10 +39,24 @@ export const uploadRecordListMock = [
// TODO: replace with API // TODO: replace with API
export const articleTreeMock = [ export const articleTreeMock = [
{ id: 'all', title: '全部章节' }, {
{ id: 'sec-1', title: '一、风险概览' }, id: 'sec-1',
{ id: 'sec-2', title: '二、重点行业观察' }, title: '一、风险概览',
{ id: 'sec-3', title: '三、处置建议' } children: [
{ id: 'sec-1-p1', title: '1.1 全球流动性', sectionId: 'sec-1' },
{ id: 'sec-1-p2', title: '1.2 跨境资本流动', sectionId: 'sec-1' }
]
},
{
id: 'sec-2',
title: '二、重点行业观察',
children: [{ id: 'sec-2-p1', title: '2.1 科技供应链', sectionId: 'sec-2' }]
},
{
id: 'sec-3',
title: '三、处置建议',
children: [{ id: 'sec-3-p1', title: '3.1 预警与演练', sectionId: 'sec-3' }]
}
] ]
// TODO: replace with API // TODO: replace with API
...@@ -76,13 +90,78 @@ export const paragraphRowsMock = [ ...@@ -76,13 +90,78 @@ export const paragraphRowsMock = [
} }
] ]
/**
* 文档对照页显示用的版式识别结果示例 JSON。
* 后续可按 1 PDF -> 1 JSON 的接口返回直接映射。
*/
export const layoutStyleResultMock = {
schema_version: '3.0',
document_meta: {
source_file_name: '测试文档.pdf',
source_file_type: 'pdf',
page_count: 3,
created_at: '2026-04-08T15:00:00Z'
},
pages: [
{
page_idx: 0,
width: 594.96,
height: 841.92,
rotation: null,
node_ids: ['page0_node0', 'page0_node1', 'page0_node2', 'page0_node3', 'page0_node4']
}
],
merged_nodes: [
{
node_id: 'page0_node0',
semantic_type: 'page_header',
page_idx: 0,
content_payload: {
type: 'text',
text: '格式转换测试文件',
html: '<b><span style="color:#333333">格式转换测试文件</span></b>',
heading_level: null,
content_fragments: [{ type: 'text', content: '格式转换测试文件' }],
markdown: null
},
layout: {
bbox: [67, 78, 395, 111],
bbox_norm: [0.069, 0.079, 0.397, 0.112],
angle: 0,
reading_order: 0,
page_idx: 0,
link: null,
z_index: null
},
style_spans: [
{
span_id: 'page0_node0_s0',
start: 0,
end: 8,
text: '格式转换测试文件',
style: {
bold: true,
italic: false,
underline: false,
superscript: false,
subscript: false,
color_hex: '#333333'
}
}
]
}
]
}
/** 文档对照数据(TODO: 替换为真实接口) */ /** 文档对照数据(TODO: 替换为真实接口) */
export function fetchDocumentCompareMock(_fileName, delayMs = 2500) { export function fetchDocumentCompareMock(_fileName, delayMs = 2500) {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve({ resolve({
articleTree: articleTreeMock, articleTree: articleTreeMock,
paragraphRows: paragraphRowsMock paragraphRows: paragraphRowsMock,
translationProgress: 12,
layoutStyleResult: layoutStyleResultMock
}) })
}, delayMs) }, delayMs)
}) })
......
import { articleTreeMock, paragraphRowsMock } from '../mock'
import { fetchLayoutStyleResult, mapLayoutStyleResult } from './layoutStyleService'
/**
* 对比页接口占位 service
* 后续后端接入时,只需要替换这里的数据获取方式和字段映射逻辑。
*/
export async function fetchDocumentComparePageData(params = {}) {
const { fileName = '' } = params
const response = await fetchDocumentCompareRawData({ fileName })
return mapDocumentComparePageData(response)
}
/**
* 原始接口占位:模拟后端可能返回的结构
* 这里保留为后续分接口时的聚合入口。
*/
export async function fetchDocumentCompareRawData(_params = {}) {
const layoutStyleRaw = await fetchLayoutStyleResult()
return Promise.resolve({
file_info: {
file_name: '测试文档.pdf',
file_type: 'pdf'
},
translation_progress: 12,
article_tree: articleTreeMock,
paragraph_rows: paragraphRowsMock,
layout_style_result: layoutStyleRaw
})
}
/**
* 将后端返回结构映射为页面可直接使用的数据结构。
*/
export function mapDocumentComparePageData(rawData) {
const articleTree = Array.isArray(rawData?.article_tree) ? rawData.article_tree : []
const paragraphRows = Array.isArray(rawData?.paragraph_rows) ? rawData.paragraph_rows : []
const layoutStyleResult = rawData?.layout_style_result
? mapLayoutStyleResult(rawData.layout_style_result)
: null
return {
fileName: rawData?.file_info?.file_name || '',
fileType: rawData?.file_info?.file_type || 'pdf',
translationProgress: Number(rawData?.translation_progress) || 0,
articleTree,
paragraphRows,
layoutStyleResult
}
}
/**
* 版式识别与样式还原结果接口占位 service
* 后续 PDF + JSON 接口接入时,在这里做字段映射。
*/
export async function fetchLayoutStyleResult(_params = {}) {
return Promise.resolve({
schema_version: '3.0',
document_meta: {
source_file_name: '测试文档.pdf',
source_file_type: 'pdf',
page_count: 3,
created_at: '2026-04-08T15:00:00Z'
},
pages: [],
merged_nodes: []
})
}
/**
* 将后端返回的 JSON 结构映射为前端可用结构。
*/
export function mapLayoutStyleResult(rawData) {
return {
schemaVersion: rawData?.schema_version || '',
documentMeta: {
sourceFileName: rawData?.document_meta?.source_file_name || '',
sourceFileType: rawData?.document_meta?.source_file_type || '',
pageCount: Number(rawData?.document_meta?.page_count) || 0,
createdAt: rawData?.document_meta?.created_at || ''
},
pages: Array.isArray(rawData?.pages) ? rawData.pages : [],
mergedNodes: Array.isArray(rawData?.merged_nodes) ? rawData.merged_nodes : []
}
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论